Options bar: the airbrush, smoothing options and symmetry buttons work (#1177) (#1214)

In the Photoshop themes (the default ProMedium included) the Brush and
Eraser options bar drew three icon buttons that did nothing when clicked.
The engine already supports everything behind them:

- Airbrush toggles the brush's Build-up (lit while on).
- The gear beside Smoothing opens Photoshop's smoothing options: Pulled
  String Mode, Stroke Catch-up, Catch-up on Stroke End and Adjust for
  Zoom. They edit the brush like the Smoothing % does, so they stay per
  tool and reach the live stroke and the commit.
- The symmetry button opens a menu with Symmetry Off and every path the
  Paths panel lists (saved paths, the Work Path, the selected layer's
  shape path or vector mask), running paint.symmetryFromPath /
  paint.symmetryDisable. It is lit while symmetry is on; with no path it
  says how to make one.

The three buttons get accessible names (brush_picker::named), and
"Symmetry Off" is added to the twelve complete catalogs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chris Walden 2026-10-08 15:32:49 -04:00 committed by GitHub
parent 15cfdcd9e8
commit acddca58b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 131 additions and 4 deletions

View File

@ -286,6 +286,56 @@ fn options_bar_edits_are_one_set_brush_per_gesture() {
assert_eq!(s.tools.brush, h.state().session.tools.brush);
}
#[test]
fn airbrush_smoothing_options_and_symmetry_buttons_work() {
// #1177: in the default (Photoshop) theme these options-bar buttons did nothing.
use egui_kittest::kittest::Queryable;
let mut h = app_harness();
let brush = |h: &Harness<'static, PhotocraftApp>| h.state().session.tools.brush.clone();
// The airbrush toggles Build-up, one setBrush each click.
let was = brush(&h).build_up;
h.get_by_label("Enable airbrush-style build-up effects").click();
h.run_steps(3);
assert_eq!(brush(&h).build_up, !was);
let (id, p) = last_journal(h.state()).unwrap();
assert_eq!((id.as_str(), &p["brush"]), ("tools.setBrush", &json!({"buildUp": !was})));
// The gear opens Photoshop's four smoothing options, each editing the brush.
assert!(h.query_by_label("Pulled String Mode").is_none());
h.get_by_label("Set additional smoothing options").click();
h.run_steps(3);
let before = brush(&h).smoothing;
for label in ["Pulled String Mode", "Stroke Catch-up", "Catch-up on Stroke End", "Adjust for Zoom"] {
h.get_by_label(label).click();
h.run_steps(3);
}
let after = brush(&h).smoothing;
assert_eq!(
(after.pulled_string, after.catch_up, after.catch_up_on_end, after.adjust_for_zoom),
(!before.pulled_string, !before.catch_up, !before.catch_up_on_end, !before.adjust_for_zoom)
);
assert_eq!(after.amount, before.amount);
// Symmetry: the menu lists the document's paths and turns symmetry on and off.
h.state_mut().run("file.new", json!({"width": 80, "height": 60})).unwrap();
h.state_mut().run("path.set", json!({"name": "work", "path": {"subpaths": [{"closed": false, "knots": [[40, 0], [40, 60]]}]}})).unwrap();
h.run_steps(3);
h.get_by_label("Set painting symmetry options").click();
h.run_steps(3);
h.get_by_label("Work Path").click();
h.run_steps(3);
let source = |h: &Harness<'static, PhotocraftApp>| h.state().session.active().unwrap().symmetry_path.as_ref().map(|a| a.source.clone());
assert_eq!(source(&h).as_deref(), Some("work"));
assert_eq!(last_journal(h.state()).unwrap(), ("paint.symmetryFromPath".to_string(), json!({"name": "work"})));
h.get_by_label("Set painting symmetry options").click();
h.run_steps(3);
h.get_by_label("Symmetry Off").click();
h.run_steps(3);
assert_eq!(source(&h), None);
assert!(h.state().ui.status.is_empty(), "{}", h.state().ui.status);
}
#[test]
fn mixer_brush_options_update_the_persisted_mixer_settings() {
use egui_kittest::kittest::Queryable;

View File

@ -81,7 +81,7 @@ pub fn settings_toggle(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
/// An icon button's accessible name (icon buttons have only a tooltip).
fn named(resp: egui::Response, label: &str) -> egui::Response {
pub(crate) fn named(resp: egui::Response, label: &str) -> egui::Response {
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
resp
}

View File

@ -1357,6 +1357,7 @@
Set background color Nastavit barvu pozadí
Set foreground color Nastavit barvu popředí
Set painting symmetry options Nastavit možnosti symetrie malby
Symmetry Off Symetrie vypnuta
Set shape fill type Nastavit typ výplně tvaru
Set shape stroke type Nastavit typ tahu tvaru
Set the text color Nastavit barvu textu

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1340,6 +1340,7 @@
Set background color Hintergrundfarbe festlegen
Set foreground color Vordergrundfarbe festlegen
Set painting symmetry options Symmetrieoptionen für das Malen festlegen
Symmetry Off Symmetrie aus
Set shape fill type Fülltyp der Form festlegen
Set shape stroke type Konturtyp der Form festlegen
Set the text color Textfarbe festlegen

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1342,6 +1342,7 @@
Set background color Establecer el color de fondo
Set foreground color Establecer el color frontal
Set painting symmetry options Establecer opciones de simetría de pintura
Symmetry Off Simetría desactivada
Set shape fill type Establecer el tipo de relleno de la forma
Set shape stroke type Establecer el tipo de trazo de la forma
Set the text color Establecer el color del texto

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -1329,6 +1329,7 @@
Set background color Définir la couleur d'arrière-plan
Set foreground color Définir la couleur de premier plan
Set painting symmetry options Définir les options de symétrie de peinture
Symmetry Off Symétrie désactivée
Set shape fill type Définir le type de remplissage de la forme
Set shape stroke type Définir le type de contour de la forme
Set the text color Définir la couleur du texte

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1323,6 +1323,7 @@
Set background color Atur warna background
Set foreground color Atur warna foreground
Set painting symmetry options Atur opsi simetri lukisan
Symmetry Off Simetri Mati
Set shape fill type Atur jenis isian bentuk
Set shape stroke type Atur jenis stroke bentuk
Set the text color Atur warna teks

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -1340,6 +1340,7 @@
Set background color Imposta colore sfondo
Set foreground color Imposta colore primo piano
Set painting symmetry options Imposta opzioni simmetria pittura
Symmetry Off Simmetria disattivata
Set shape fill type Imposta tipo riempimento forma
Set shape stroke type Imposta tipo traccia forma
Set the text color Imposta colore testo

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -1347,6 +1347,7 @@
Set background color 背景色を設定
Set foreground color 描画色を設定
Set painting symmetry options 対称描画のオプションを設定
Symmetry Off 対称オフ
Set shape fill type シェイプの塗りの種類を設定
Set shape stroke type シェイプの線の種類を設定
Set the text color 文字色を設定

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1324,6 +1324,7 @@
Set background color 배경색 설정
Set foreground color 전경색 설정
Set painting symmetry options 페인팅 대칭 옵션 설정
Symmetry Off 대칭 끔
Set shape fill type 모양 채우기 유형 설정
Set shape stroke type 모양 획 유형 설정
Set the text color 텍스트 색상 설정

Can't render this file because it has a wrong number of fields in line 10.

View File

@ -1328,6 +1328,7 @@
Set background color Definir cor de fundo
Set foreground color Definir cor de primeiro plano
Set painting symmetry options Definir opções de simetria de pintura
Symmetry Off Simetria desativada
Set shape fill type Definir tipo de preenchimento da forma
Set shape stroke type Definir tipo de traçado da forma
Set the text color Definir a cor do texto

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1637,6 +1637,7 @@
Measure Измерить
Heal Восстановление
Off Выкл
Symmetry Off Без симметрии
Heal Off Восстановление выкл
Heal On Восстановление вкл
Heal Auto Автовосстановление

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1352,6 +1352,7 @@
Set background color 设置背景色
Set foreground color 设置前景色
Set painting symmetry options 设置绘画对称选项
Symmetry Off 关闭对称
Set shape fill type 设置形状填充类型
Set shape stroke type 设置形状描边类型
Set the text color 设置文字颜色

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -1348,6 +1348,7 @@
Set background color 設定背景色
Set foreground color 設定前景色
Set painting symmetry options 設定繪畫對稱選項
Symmetry Off 關閉對稱
Set shape fill type 設定形狀填滿類型
Set shape stroke type 設定形狀筆畫類型
Set the text color 設定文字顏色

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -522,15 +522,18 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
b.pressure_opacity = !b.pressure_opacity;
}
percent_field(ui, tl!("Flow"), &mut b.flow, 1.0..=100.0, 62.0);
let _ = icons::button(ui, "sparkles", 24.0, false, tl!("Enable airbrush-style build-up effects"));
let airbrush = icons::button(ui, "sparkles", 24.0, b.build_up, tl!("Enable airbrush-style build-up effects"));
if crate::brush_picker::named(airbrush, tl!("Enable airbrush-style build-up effects")).clicked() {
b.build_up = !b.build_up;
}
opt_label(ui, tl!("Smoothing"));
smoothing_field(ui, b, 58.0);
let _ = icons::button(ui, "settings", 24.0, false, tl!("Set additional smoothing options"));
smoothing_options(ui, b);
widgets::vline(ui, 22.0);
if icons::button(ui, "circle-dot", 24.0, b.pressure_size, tl!("Always use pressure for size")).clicked() {
b.pressure_size = !b.pressure_size;
}
let _ = icons::button(ui, "arrow-left-right", 24.0, false, tl!("Set painting symmetry options"));
symmetry_menu(app, ui);
}
// Pencil: Photoshop's options (no hardness or flow: the pencil is always hard).
Tool::Pencil => {
@ -2465,6 +2468,68 @@ fn smoothing_field(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings,
}
}
/// Options-bar gear beside Smoothing: Photoshop's smoothing options popup, edited on the brush
/// like the Smoothing % (so they stay per tool and reach the live stroke and the commit).
fn smoothing_options(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings) {
let resp = icons::button(ui, "settings", 24.0, false, tl!("Set additional smoothing options"));
let resp = crate::brush_picker::named(resp, tl!("Set additional smoothing options"));
egui::Popup::from_toggle_button_response(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
let s = &mut b.smoothing;
widgets::checkbox(ui, &mut s.pulled_string, tl!("Pulled String Mode"));
widgets::checkbox(ui, &mut s.catch_up, tl!("Stroke Catch-up"));
widgets::checkbox(ui, &mut s.catch_up_on_end, tl!("Catch-up on Stroke End"));
widgets::checkbox(ui, &mut s.adjust_for_zoom, tl!("Adjust for Zoom"));
});
}
/// The symmetry menu's rows: `paint.symmetryFromPath` name and label for each path the Paths
/// panel lists (saved paths, the Work Path, the selected layer's shape path or vector mask).
fn symmetry_choices(app: &PhotocraftApp) -> Vec<(String, String)> {
use crate::vector_ui::PathRow;
let Some(st) = app.session.active() else { return Vec::new() };
crate::vector_ui::path_rows(&st.doc, st.active_layer)
.into_iter()
.map(|row| match row.kind {
PathRow::Saved => (row.name.clone(), row.name),
PathRow::Work => ("work".to_string(), tl!("Work Path").to_string()),
PathRow::Layer => ("layer".to_string(), row.name),
})
.collect()
}
/// Options-bar painting symmetry: Symmetry Off, or mirror Brush and Eraser strokes across one of
/// the document's paths (`paint.symmetryFromPath`). The button is lit while symmetry is on.
fn symmetry_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let current = app.session.active().and_then(|st| st.symmetry_path.as_ref()).map(|axis| axis.source.clone());
let choices = symmetry_choices(app);
let resp = icons::button(ui, "arrow-left-right", 24.0, current.is_some(), tl!("Set painting symmetry options"));
let resp = crate::brush_picker::named(resp, tl!("Set painting symmetry options"));
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(200.0);
let mut run = None;
if ui.add(egui::Button::selectable(current.is_none(), tl!("Symmetry Off"))).clicked() {
run = current.is_some().then(|| ("paint.symmetryDisable", json!({})));
ui.close();
}
ui.separator();
if choices.is_empty() {
ui.label(RichText::new(tl!("Draw with the Pen tool (P) or make a work path from a selection.")).color(t.text_faint));
}
for (name, label) in &choices {
if ui.add(egui::Button::selectable(current.as_ref() == Some(name), label)).clicked() {
run = Some(("paint.symmetryFromPath", json!({ "name": name })));
ui.close();
}
}
if let Some((id, params)) = run
&& let Err(e) = app.run(id, params)
{
app.ui.status = e;
}
});
}
/// Options-bar brush chip; opens Photoshop's Brush Preset picker (size, hardness, the preset
/// library). Returns what the picker asked for beyond the size and hardness edits in `b`.
fn brush_preset_chip(