Brush panel: update-current-brush button, identity-based preset selection, tip-grid fixes (#2007)

* update brush button

* ui brush duplicated highlight fix

* formatting fix + locale

* i18n: translate "Update the current brush with these settings" in remaining complete catalogs

* Tip Shape picks the tip only; set_brush records the preset once accepted

- A click in the Brush Tip Shape grid copies the tip fields only (main's
  Photoshop behaviour: the dynamics sections stay); the picked preset is
  marked current with brush.presets.setCurrent instead of a whole-preset
  tools.setBrush, so identity-based selection still tracks it.
- tools.setBrush sets current_preset only after the brush merged and
  validated, so a failed call leaves no state behind; a reset still wins
  over a preset in the same call. Tested.
- brush.presets.save / brush.defineFromSelection append at the end again
  (insert-after-the-copied-brush had no Photoshop reference).

* Brush presets: forget the picked preset when the tool's brush swaps; no indexing in brush.presets.update

Each painting tool keeps its own brush (#218), so Update Brush could overwrite the
preset picked with one tool with another tool's brush.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: JJ <jj@dot.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
siraxe 2026-10-09 23:56:41 +00:00 committed by GitHub
parent 917fbcb006
commit 35f832f03a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 261 additions and 73 deletions

View File

@ -595,6 +595,7 @@ fn name_param(p: &Value, cmd: &str) -> Result<String> {
p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(cmd, "missing `name`"))
}
/// Replace the preset of that name, or append it at the end of the list.
fn upsert(s: &mut Session, preset: BrushPreset) {
match s.tools.presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&preset.name)) {
Some(x) => *x = preset,
@ -611,6 +612,8 @@ fn presets_save(s: &mut Session, p: &Value) -> Result<Value> {
None => s.tools.brush.clone(),
};
upsert(s, BrushPreset { name: name.clone(), brush, builtin: false, group: String::new(), folder: Vec::new() });
// The saved brush is the current brush now (Photoshop selects the brush it just saved).
s.tools.current_preset = Some(name.clone());
Ok(json!({ "name": name, "count": s.tools.presets.len() }))
}
@ -622,10 +625,48 @@ fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
if s.tools.presets.len() == before {
return Err(bad(cmd, format!("no brush preset named `{name}`")));
}
if s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(&name)) {
s.tools.current_preset = None;
}
s.brush_presets_changed();
Ok(json!({ "count": s.tools.presets.len() }))
}
/// Overwrite an existing preset with the current brush (or the current brush plus a `brush`
/// patch), in place: no new preset, the position, name and group stay. The preset defaults to the
/// one the current brush was picked from. A built-in that is updated becomes the user's preset
/// (built-ins regenerate with their factory settings).
fn presets_update(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "brush.presets.update";
let name = match p.get("name").filter(|v| !v.is_null()) {
Some(_) => name_param(p, cmd)?,
None => s.tools.current_preset.clone().ok_or_else(|| bad(cmd, "the current brush was not picked from a preset"))?,
};
let brush = match p.get("brush").filter(|v| v.is_object()) {
Some(patch) => merge_brush(&s.tools.brush, patch, cmd)?,
None => s.tools.brush.clone(),
};
let pr = s.tools.presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)).ok_or_else(|| bad(cmd, format!("no brush preset named `{name}`")))?;
pr.brush = brush;
pr.builtin = false;
let (name, group) = (pr.name.clone(), pr.group.clone());
s.brush_presets_changed();
Ok(json!({ "name": name, "group": group }))
}
/// Mark a preset as the one the current brush was picked from. The tip grids' click copies only
/// the tip fields (the rest of the brush stays), so the engine cannot infer the preset from the
/// brush: the UI names it. Selection is by identity, so look-alike duplicates stay distinct.
fn presets_set_current(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "brush.presets.setCurrent";
let name = name_param(p, cmd)?;
if !s.tools.presets.iter().any(|x| x.name.eq_ignore_ascii_case(&name)) {
return Err(bad(cmd, format!("no brush preset named `{name}`")));
}
s.tools.current_preset = Some(name.clone());
Ok(json!({ "name": name }))
}
fn has_selection_and_pixels(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document")?;
if d.doc.selection.is_none() {
@ -688,16 +729,20 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
let brush = BrushSettings { tip: TipShape::Sampled(tip), size: tw.max(th) as f32, spacing: 0.25, pressure_size: false, ..BrushSettings::default() };
upsert(s, BrushPreset { name: name.clone(), brush: brush.clone(), builtin: false, group: String::new(), folder: Vec::new() });
s.tools.brush = brush;
s.tools.current_preset = Some(name.clone());
Ok(json!({ "name": name, "width": tw, "height": th }))
}
pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "tools.setBrush";
let mut b = s.tools.brush.clone();
let mut picked = None;
if let Some(name) = p.get("preset").and_then(Value::as_str) {
b = find_preset(s, name, cmd)?.brush.clone().picked_over(&s.tools.brush);
picked = Some(name.to_string());
}
if flag(p, "reset", false) {
let reset = flag(p, "reset", false);
if reset {
b = BrushSettings::default();
}
let mut patch = p.clone();
@ -715,6 +760,14 @@ pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
}
b = merge_brush(&b, &patch, cmd)?;
validate_brush(&b, cmd)?;
// The picked preset stays "the current brush" across later edits, so `brush.presets.update`
// knows what to overwrite. Only once the brush is accepted: a failed call leaves no state
// behind (a `reset` wins over a `preset`, as it replaces the whole brush).
if reset {
s.tools.current_preset = None;
} else if let Some(name) = picked {
s.tools.current_preset = Some(name);
}
// The tool's brush always paints with full tips: load a library preset's from the store.
s.load_brush_tips(&mut b).map_err(|e| bad(cmd, e))?;
let before = std::mem::replace(&mut s.tools.brush, b);
@ -835,7 +888,16 @@ pub fn specs() -> Vec<CommandSpec> {
),
spec!("brush.presets.list", "List Brush Presets", r##"{"full":bool=false}"##, always, presets_list, false),
spec!("brush.presets.save", "Save Brush Preset", r##"{"name":string,"brush":{…BrushSettings}?=current brush}"##, always, presets_save, true),
spec!(
"brush.presets.update",
"Update Brush Preset",
r##"{"name":string?=the preset the current brush was picked from,"brush":{…BrushSettings}?=current brush}"##,
always,
presets_update,
true
),
spec!("brush.presets.delete", "Delete Brush Preset", r##"{"name":string}"##, always, presets_delete, true),
spec!("brush.presets.setCurrent", "Set Current Brush Preset", r##"{"name":string}"##, always, presets_set_current, true),
spec!("brush.defineFromSelection", "Define Brush Preset…", r##"{"name":string}"##, has_selection_and_pixels, define_from_selection, true),
spec!("brush.get", "Get Brush", "{}", always, |s, _| Ok(brush_json(&s.tools.brush)), false),
spec!("tools.setBrush", "Set Brush", r##"{"preset":name?,"reset":bool?,…BrushSettings fields (camelCase, deep-merged)}"##, always, set_brush, true),

View File

@ -183,6 +183,62 @@ fn presets_save_load_delete_round_trip() {
assert!(s.execute("brush.presets.save", json!({})).is_err());
}
#[test]
fn presets_save_appends_at_the_end_and_update_overwrites_in_place() {
let mut s = session(10, 10);
s.execute("tools.setBrush", json!({"preset": "Chalk"})).unwrap();
assert_eq!(s.tools.current_preset.as_deref(), Some("Chalk"));
let n = s.tools.presets.len();
let chalk_at = s.tools.presets.iter().position(|p| p.name == "Chalk").unwrap();
// A new preset appends at the end, picked preset or not.
s.execute("brush.presets.save", json!({"name": "Mine"})).unwrap();
assert_eq!(s.tools.presets.last().unwrap().name, "Mine");
s.execute("brush.presets.save", json!({"name": "Tail"})).unwrap();
assert_eq!(s.tools.presets.last().unwrap().name, "Tail");
// A reset clears the picked preset.
s.execute("tools.setBrush", json!({"reset": true})).unwrap();
assert_eq!(s.tools.current_preset, None);
// Editing the brush keeps the picked preset current; update overwrites it in place.
s.execute("tools.setBrush", json!({"preset": "Chalk"})).unwrap();
s.execute("tools.setBrush", json!({"size": 55})).unwrap();
assert_eq!(s.tools.current_preset.as_deref(), Some("Chalk"));
let r = s.execute("brush.presets.update", json!({})).unwrap();
assert_eq!(r["name"], "Chalk");
assert_eq!(s.tools.presets.len(), n + 2, "no new preset (the two saves added two)");
assert_eq!(s.tools.presets.iter().position(|p| p.name == "Chalk"), Some(chalk_at));
let chalk = s.tools.presets.iter().find(|p| p.name == "Chalk").unwrap();
assert_eq!(chalk.brush.size, 55.0);
assert!(!chalk.builtin, "an updated built-in becomes the user's preset");
// A named update of another preset.
s.execute("brush.presets.update", json!({"name": "Mine", "brush": {"size": 9}})).unwrap();
assert_eq!(s.tools.presets.iter().find(|p| p.name == "Mine").unwrap().brush.size, 9.0);
// Errors: no such preset, and no preset to update after a reset.
assert!(s.execute("brush.presets.update", json!({"name": "Nope"})).is_err());
s.execute("tools.setBrush", json!({"reset": true})).unwrap();
assert!(s.execute("brush.presets.update", json!({})).is_err());
// Deleting the current preset clears it, so a later update fails rather than guessing.
s.execute("tools.setBrush", json!({"preset": "Chalk"})).unwrap();
s.execute("brush.presets.delete", json!({"name": "Chalk"})).unwrap();
assert_eq!(s.tools.current_preset, None);
assert!(s.execute("brush.presets.update", json!({})).is_err());
}
#[test]
fn a_failed_set_brush_leaves_no_current_preset_behind() {
let mut s = session(10, 10);
let before = s.tools.brush.clone();
// A rejected brush (a wrong-typed size) must not record the preset it came with.
assert!(s.execute("tools.setBrush", json!({"preset": "Chalk", "brush": {"size": "big"}})).is_err());
assert_eq!(s.tools.current_preset, None);
assert_eq!(s.tools.brush, before);
// Once the brush is accepted, the preset is recorded.
s.execute("tools.setBrush", json!({"preset": "Chalk"})).unwrap();
assert_eq!(s.tools.current_preset.as_deref(), Some("Chalk"));
// A `reset` wins over a `preset` in the same call.
s.execute("tools.setBrush", json!({"preset": "Chalk", "reset": true})).unwrap();
assert_eq!(s.tools.current_preset, None);
}
#[test]
fn set_brush_merges_fields() {
let mut s = session(10, 10);

View File

@ -112,8 +112,12 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
}
let pr = s.tools.presets.get_mut(i).ok_or_else(|| bad(cmd, "no such preset"))?;
if pr.name != new {
let was_current = s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(&pr.name));
pr.name = new.clone();
pr.builtin = false;
if was_current {
s.tools.current_preset = Some(new.clone());
}
s.brush_presets_changed();
}
Ok(json!({ "name": new }))
@ -318,6 +322,9 @@ fn delete_group(s: &mut Session, p: &Value) -> Result<Value> {
let (g, f) = target_folder(s, p, cmd)?;
let before = s.tools.presets.len();
s.tools.presets.retain(|x| !in_folder(x, &g, &f));
if s.tools.current_preset.as_ref().is_some_and(|c| !s.tools.presets.iter().any(|x| x.name.eq_ignore_ascii_case(c))) {
s.tools.current_preset = None;
}
s.brush_presets_changed();
Ok(json!({ "deleted": before - s.tools.presets.len(), "count": s.tools.presets.len() }))
}

View File

@ -252,6 +252,10 @@ pub struct ToolState {
/// The coalescing key of the running `tools.setBrush` gesture and the brush before it, so the
/// gesture journals as one call ([`brush_cmds::coalesce_journal`]).
pub brush_gesture: Option<(String, photocraft_paint::BrushSettings)>,
/// Name of the preset the current brush was last picked from. Kept across later edits (the
/// preset stays "the current brush" so `brush.presets.update` can overwrite it); cleared when
/// the brush is reset or the preset is gone.
pub current_preset: Option<String>,
}
impl Default for ToolState {
@ -268,6 +272,7 @@ impl Default for ToolState {
presets_rev: 0,
mixer: Default::default(),
brush_gesture: None,
current_preset: None,
}
}
}

View File

@ -190,23 +190,6 @@ pub fn grouped_presets(presets: &[paint::BrushPreset]) -> Vec<(String, Vec<usize
out
}
/// Does the current brush match `preset` (everything but colour, size and the tool state that
/// picking a preset keeps: smoothing and the section locks)? Cheap fields first: the full
/// comparison clones the preset and its tip.
pub fn is_current(preset: &BrushSettings, brush: &BrushSettings) -> bool {
preset.hardness == brush.hardness
&& preset.spacing == brush.spacing
&& preset.tip == brush.tip
&& BrushSettings {
color: brush.color,
size: brush.size,
background: brush.background,
smoothing: brush.smoothing.clone(),
locks: brush.locks.clone(),
..preset.clone()
} == *brush
}
/// A fresh "Brush N" name.
pub(crate) fn new_preset_name(presets: &[paint::BrushPreset]) -> String {
(1..).map(|n| format!("Brush {n}")).find(|n| !presets.iter().any(|p| &p.name == n)).unwrap_or_else(|| "Brush".into())
@ -316,8 +299,14 @@ fn settings_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let on = section_flag(&mut b, section).is_none_or(|f| *f);
egui::ScrollArea::vertical().id_salt(("brush-section", section)).max_height(450.0).auto_shrink([false, false]).show(ui, |ui| {
ui.set_width(WIDTH - 204.0);
// A section that's off shows its options greyed out (Photoshop).
ui.add_enabled_ui(on, |ui| section_body(ui, &mut b, section, &app.session.tools.presets));
// A section that's off shows its options greyed out (Photoshop). A tip picked in
// the grid copies the tip fields only — the dynamics sections stay (Photoshop).
// The picked preset is recorded by name (`brush.presets.setCurrent`, identity, so
// look-alike duplicates stay distinct), since the engine can't infer it from the
// brush afterwards.
ui.add_enabled_ui(on, |ui| section_body(ui, &mut b, section, &app.session.tools.presets, app.session.tools.current_preset.as_deref()))
.inner
.inspect(|name| run_or_status(app, "brush.presets.setCurrent", json!({ "name": name })));
});
});
});
@ -337,6 +326,15 @@ fn settings_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if icons::button(ui, "undo-2", 24.0, false, tl!("Reset the brush to the defaults")).clicked() {
b = BrushSettings { color: b.color, background: b.background, smoothing: b.smoothing.clone(), locks: b.locks.clone(), ..Default::default() };
}
// Overwrite the preset the current brush was picked from (Photoshop's "update the
// selected brush"): no new preset, the brush keeps its place in the list.
let current = app.session.tools.current_preset.clone().filter(|n| app.session.tools.presets.iter().any(|p| p.name.eq_ignore_ascii_case(n)));
let update =
ui.add_enabled_ui(current.is_some(), |ui| icons::button(ui, "check", 24.0, false, tl!("Update the current brush with these settings"))).inner;
update.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, current.is_some(), "Update the current brush with these settings"));
if update.clicked() {
run_or_status(app, "brush.presets.update", json!({ "brush": serde_json::to_value(&b).unwrap_or(Value::Null) }));
}
});
});
commit_gesture(app, ui.ctx(), &before, &b);

View File

@ -696,6 +696,49 @@ fn f5_edits_the_default_brush_with_every_section() {
assert!(!h.state().ui.panels.brush_settings);
}
#[test]
fn update_current_brush_button_overwrites_the_picked_preset_in_place() {
use egui_kittest::kittest::Queryable;
let mut h = harness(0, 0);
// Nothing picked yet: the button is disabled and clicking it does nothing.
assert!(h.state().session.tools.current_preset.is_none());
h.get_by_label("Update the current brush with these settings").click();
h.run_steps(3);
assert!(!h.state().session.journal.iter().any(|(id, _)| id == "brush.presets.update"));
// Pick a preset, then turn a section on.
h.state_mut().run("tools.setBrush", json!({ "preset": "Chalk" })).unwrap();
h.run_steps(2);
let n = h.state().session.tools.presets.len();
let at = h.state().session.tools.presets.iter().position(|p| p.name == "Chalk").unwrap();
h.get_by_label("Enable Scattering").click();
h.run_steps(3);
assert!(h.state().session.tools.brush.scattering.enabled);
// The update button overwrites the picked preset in place: no new preset, position kept.
h.get_by_label("Update the current brush with these settings").click();
h.run_steps(3);
assert!(h.state().session.journal.iter().any(|(id, p)| id == "brush.presets.update" && p["brush"]["scattering"]["enabled"] == json!(true)));
assert_eq!(h.state().session.tools.presets.len(), n);
let chalk = h.state().session.tools.presets.get(at).unwrap();
assert_eq!(chalk.name, "Chalk");
assert!(chalk.brush.scattering.enabled);
assert!(!chalk.builtin, "an updated built-in becomes the user's preset");
}
/// Each painting tool keeps its own brush (#218): the preset picked for one tool is not the
/// current preset of the next, so Update can't overwrite it with the other tool's brush.
#[test]
fn switching_tools_forgets_the_picked_preset() {
let mut app = app();
app.ui.tool = crate::state::Tool::Brush;
crate::paint_mouse::sync_tool_brush(&mut app);
app.run("tools.setBrush", json!({ "preset": "Chalk" })).unwrap();
assert_eq!(app.session.tools.current_preset.as_deref(), Some("Chalk"));
app.ui.tool = crate::state::Tool::Eraser;
crate::paint_mouse::sync_tool_brush(&mut app);
assert_eq!(app.session.tools.current_preset, None);
assert!(app.run("brush.presets.update", json!({})).is_err());
}
#[test]
fn options_bar_reaches_brush_settings_and_the_preset_library() {
use egui_kittest::kittest::Queryable;
@ -719,11 +762,11 @@ fn options_bar_reaches_brush_settings_and_the_preset_library() {
assert!(h.query_by_label(&group).is_some(), "group {group}");
}
let target = presets.iter().find(|p| p.group == "Dry Media").or(presets.last()).unwrap().name.clone();
assert!(!is_current(&paint::presets::find(&presets, &target).unwrap().brush, &h.state().session.tools.brush));
assert_ne!(h.state().session.tools.current_preset.as_deref(), Some(target.as_str()));
h.get_by_label(&target).click();
h.run_steps(3);
assert_eq!(last_journal(h.state()).unwrap(), ("tools.setBrush".to_string(), json!({ "preset": target })));
assert!(is_current(&paint::presets::find(&presets, &target).unwrap().brush, &h.state().session.tools.brush));
assert_eq!(h.state().session.tools.current_preset.as_deref(), Some(target.as_str()));
// A click picks and keeps the picker open; the chip's click closes it again.
assert!(h.state().ui.brush_picker.is_some());
h.get_by_label("Brush Preset picker").click();

View File

@ -18,7 +18,7 @@ use photocraft_engine::BrushSettings;
use photocraft_engine::paint::{BrushPreset, MAX_BRUSH_SIZE, TipShape};
use serde_json::json;
use crate::brush_panel::{BrushesPanelState, BrushesView, Renaming, is_current, new_preset_name, run_or_status};
use crate::brush_panel::{BrushesPanelState, BrushesView, Renaming, new_preset_name, run_or_status};
use crate::brushes_tab::{self, Action, ListLayout};
use crate::theme::Tokens;
use crate::{PhotocraftApp, icons, widgets};
@ -121,8 +121,9 @@ pub(crate) fn named(resp: egui::Response, label: &str) -> egui::Response {
}
/// The picker's contents: Size, Hardness (round tips only: a sampled tip has none), a search
/// field with the Brush Settings, New Preset and gear buttons, and the preset list.
pub fn body(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset], st: &mut BrushesPanelState) -> Vec<Pick> {
/// field with the Brush Settings, New Preset and gear buttons, and the preset list. `current` is
/// the selected preset's name (by identity, so edits don't deselect).
pub fn body(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset], current: Option<&str>, st: &mut BrushesPanelState) -> Vec<Pick> {
let t = Tokens::get(ui.ctx());
let mut picks = Vec::new();
ui.set_width(WIDTH);
@ -154,7 +155,7 @@ pub fn body(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset], s
ui.add_space(20.0);
ui.add(egui::TextEdit::singleline(&mut st.filter).hint_text(tl!("Search Brushes")).desired_width(WIDTH - 118.0).id_salt("brush-picker-search"));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
gear_menu(ui, b, presets, st, &mut picks);
gear_menu(ui, current, presets, st, &mut picks);
if named(icons::button(ui, "square-plus", 24.0, false, "Create new brush preset from the current settings"), "New Brush Preset").clicked() {
picks.push(Pick::NewPreset);
}
@ -165,13 +166,13 @@ pub fn body(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset], s
});
});
ui.add_space(4.0);
picks.extend(brushes_tab::preset_list(ui, presets, b, st, LIST).into_iter().map(Pick::List));
picks.extend(brushes_tab::preset_list(ui, presets, current, st, LIST).into_iter().map(Pick::List));
picks
}
/// The gear: Photoshop's picker menu. A new preset, rename or delete the current one, the list's
/// view, and Import Brushes.
fn gear_menu(ui: &mut egui::Ui, b: &BrushSettings, presets: &[BrushPreset], st: &mut BrushesPanelState, picks: &mut Vec<Pick>) {
fn gear_menu(ui: &mut egui::Ui, current: Option<&str>, presets: &[BrushPreset], st: &mut BrushesPanelState, picks: &mut Vec<Pick>) {
let gear = named(icons::button(ui, "settings", 24.0, false, tl!("Brush Preset Options")), "Brush Preset Options");
egui::Popup::menu(&gear).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
if ui.button(tl!("New Brush Preset…")).clicked() {
@ -179,7 +180,7 @@ fn gear_menu(ui: &mut egui::Ui, b: &BrushSettings, presets: &[BrushPreset], st:
ui.close();
}
ui.separator();
let current = presets.iter().find(|p| is_current(&p.brush, b)).map(|p| p.name.clone());
let current = current.filter(|n| presets.iter().any(|p| p.name.eq_ignore_ascii_case(n))).map(str::to_string);
ui.add_enabled_ui(current.is_some(), |ui| {
if ui.button(tl!("Rename Brush…")).clicked()
&& let Some(name) = current.clone()

View File

@ -226,21 +226,9 @@ pub fn thumb_scale(size: f32) -> f32 {
(size / 40.0).clamp(0.3, 1.0)
}
/// Does `b`'s tip look like `p`'s (same bitmap or round hardness, and size)?
fn same_tip(tip: &TipShape, size: f32, hardness: f32, p: &BrushPreset) -> bool {
let (pt, ps, ph, ..) = preset_tip(p);
(size - ps).abs() < 0.5
&& match (tip, pt) {
(TipShape::Round, TipShape::Round) => (hardness - ph).abs() < 0.01,
(TipShape::Sampled(a), TipShape::Sampled(b)) => a.width == b.width && a.height == b.height && a.data == b.data,
// Stored tips (#1843) match by key: the bitmap is the same one.
(TipShape::Stored(a), TipShape::Stored(b)) => a == b,
_ => false,
}
}
/// Photoshop's scrolling grid of preset tips with their sizes. Returns the clicked preset index.
fn tip_grid(ui: &mut egui::Ui, id: &str, presets: &[BrushPreset], selected: impl Fn(&BrushPreset) -> bool) -> Option<usize> {
/// Photoshop's scrolling grid of preset tips with their sizes; `highlight` is the selected
/// preset (by identity: look-alike duplicates must stay distinct). Returns the clicked index.
fn tip_grid(ui: &mut egui::Ui, id: &str, presets: &[BrushPreset], highlight: Option<usize>) -> Option<usize> {
let t = Tokens::get(ui.ctx());
let mut clicked = None;
let frame = egui::Frame::NONE.fill(t.field).stroke(Stroke::new(1.0, t.field_border)).corner_radius(t.radius_sm as u8).inner_margin(egui::Margin::same(3));
@ -253,7 +241,7 @@ fn tip_grid(ui: &mut egui::Ui, id: &str, presets: &[BrushPreset], selected: impl
if !ui.is_rect_visible(cell) {
continue;
}
if selected(p) {
if highlight == Some(i) {
ui.painter().rect_filled(cell, 3.0, t.accent_soft);
ui.painter().rect_stroke(cell, 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
} else if resp.hovered() {
@ -281,10 +269,14 @@ fn tip_grid(ui: &mut egui::Ui, id: &str, presets: &[BrushPreset], selected: impl
clicked
}
fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset]) {
let (tip, size, hardness) = (b.tip.clone(), b.size, b.hardness);
if let Some(p) = tip_grid(ui, "brush-tip-grid", presets, |p| same_tip(&tip, size, hardness, p)).and_then(|i| presets.get(i)) {
// Picking a tip changes the tip only: the dynamics sections stay (Photoshop).
/// The Brush Tip Shape section: the tip grid and the tip's own controls. Picking a tip copies the
/// tip fields only — the dynamics sections stay as they are (Photoshop). The clicked preset's name
/// is returned so the caller can mark it current with `brush.presets.setCurrent` (by identity:
/// look-alike duplicates stay distinct; the engine can't infer it from the brush). The tip fields
/// are also copied into the working brush so this frame's controls start from them.
fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset], current: Option<&str>) -> Option<String> {
let highlight = current.and_then(|n| presets.iter().position(|p| p.name.eq_ignore_ascii_case(n)));
if let Some(p) = tip_grid(ui, "brush-tip-grid", presets, highlight).and_then(|i| presets.get(i)) {
let (pt, ps, ph, pa, pr, psp) = preset_tip(p);
b.tip = pt.clone();
b.size = ps;
@ -292,6 +284,7 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset])
b.angle = pa;
b.roundness = pr;
b.spacing = psp;
return Some(p.name.clone());
}
let orig = b.tip.bitmap_size().map(|(w, h)| w.max(h) as f32);
size_row(ui, tl!("Size"), &mut b.size, MAX_BRUSH_SIZE, orig);
@ -332,6 +325,7 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset])
ui.add_space(2.0);
let on = b.spacing_enabled;
ui.add_enabled_ui(on, |ui| signed_pct(ui, tl!("Spacing"), &mut b.spacing, 0.01, 10.0));
None
}
fn shape_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) {
@ -490,8 +484,9 @@ fn dual_brush(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset])
widgets::checkbox(ui, &mut d.flip, tl!("Flip"));
});
ui.add_space(4.0);
let (tip, size, hardness) = (d.tip.clone(), d.size, d.hardness);
if let Some(p) = tip_grid(ui, "brush-dual-grid", presets, |p| same_tip(&tip, size, hardness, p)).and_then(|i| presets.get(i)) {
// The dual tip is a sub-setting, not a selection: no persistent highlight (a look-alike match
// would highlight an arbitrary duplicate).
if let Some(p) = tip_grid(ui, "brush-dual-grid", presets, None).and_then(|i| presets.get(i)) {
let (pt, ps, ph, pa, pr, psp) = preset_tip(p);
d.tip = pt.clone();
d.size = ps;
@ -582,13 +577,17 @@ fn smoothing(ui: &mut egui::Ui, b: &mut BrushSettings) {
}
/// Controls for section `i` (see [`crate::brush_panel::SECTIONS`]). `presets` feed the tip grids.
pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets: &[BrushPreset]) {
/// Returns the name of a preset whose tip was picked in the Brush Tip Shape grid, for
/// `brush.presets.setCurrent`.
pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets: &[BrushPreset], current: Option<&str>) -> Option<String> {
let t = Tokens::get(ui.ctx());
let note = |ui: &mut egui::Ui, s: &str| {
ui.label(RichText::new(s).color(t.text_faint).font(theme::medium(11.5)));
};
if i == 0 {
return tip_shape(ui, b, presets, current);
}
match i {
0 => tip_shape(ui, b, presets),
1 => shape_dynamics(ui, b),
2 => scattering(ui, b),
3 => texture(ui, b),
@ -609,6 +608,7 @@ pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets:
11 => smoothing(ui, b),
_ => note(ui, "Keeps the current pattern and scale when you switch to another textured brush preset."),
}
None
}
#[cfg(test)]

View File

@ -17,7 +17,7 @@ use photocraft_engine::paint::{BrushPreset, MAX_BRUSH_SIZE};
use serde_json::json;
use crate::brush_panel::{
BrushesPanelState, BrushesView, Renaming, UNGROUPED, WIDTH, commit_gesture, full_uv, grouped_presets, is_current, new_preset_name, run_or_status,
BrushesPanelState, BrushesView, Renaming, UNGROUPED, WIDTH, commit_gesture, full_uv, grouped_presets, new_preset_name, run_or_status,
};
use crate::brush_preview;
use crate::theme::{self, Tokens};
@ -212,9 +212,7 @@ pub fn apply(app: &mut PhotocraftApp, acts: Vec<Action>) {
Action::Select(name) => run_or_status(app, "tools.setBrush", json!({ "preset": name })),
Action::Choose(name) => {
// The double-click's first click already picked it.
let picked =
photocraft_engine::paint::presets::find(&app.session.tools.presets, &name).is_some_and(|p| is_current(&p.brush, &app.session.tools.brush));
if !picked {
if app.session.tools.current_preset.as_deref() != Some(name.as_str()) {
run_or_status(app, "tools.setBrush", json!({ "preset": name }));
}
}
@ -469,7 +467,8 @@ struct Draw<'a> {
label: &'a str,
key: &'a str,
presets: &'a [BrushPreset],
brush: &'a photocraft_engine::BrushSettings,
/// The selected preset's name (by identity, so look-alike duplicates stay distinct).
current: Option<&'a str>,
collapsed: &'a [String],
filtering: bool,
grid: bool,
@ -496,14 +495,14 @@ fn draw_nodes(ui: &mut egui::Ui, d: &Draw, nodes: &[Node], depth: usize, acts: &
ui.horizontal(|ui| {
ui.add_space(left);
for p in row {
grid_cell(ui, p, is_current(&p.brush, d.brush), d.presets, d.layout.cell, acts);
grid_cell(ui, p, d.current.is_some_and(|n| p.name.eq_ignore_ascii_case(n)), d.presets, d.layout.cell, acts);
}
});
}
});
} else {
for p in run.iter() {
list_row(ui, p, is_current(&p.brush, d.brush), d.presets, indent, acts);
list_row(ui, p, d.current.is_some_and(|n| p.name.eq_ignore_ascii_case(n)), d.presets, indent, acts);
}
}
run.clear();
@ -562,15 +561,10 @@ fn rename_bar(ui: &mut egui::Ui, st: &mut BrushesPanelState, acts: &mut Vec<Acti
}
/// The presets in their groups, filtered by `st.filter`, as rows or tip cells (`st.view`), with
/// the rename bar above them while a rename is open. Group toggles and renames update `st`; the
/// returned actions are commands for [`apply`].
pub fn preset_list(
ui: &mut egui::Ui,
presets: &[BrushPreset],
brush: &photocraft_engine::BrushSettings,
st: &mut BrushesPanelState,
layout: ListLayout,
) -> Vec<Action> {
/// the rename bar above them while a rename is open. `current` is the selected preset's name:
/// selection is by identity, so it survives edits and tells look-alike duplicates apart. Group
/// toggles and renames update `st`; the returned actions are commands for [`apply`].
pub fn preset_list(ui: &mut egui::Ui, presets: &[BrushPreset], current: Option<&str>, st: &mut BrushesPanelState, layout: ListLayout) -> Vec<Action> {
let mut acts = Vec::new();
rename_bar(ui, st, &mut acts);
let filter = st.filter.trim().to_lowercase();
@ -590,7 +584,7 @@ pub fn preset_list(
if !open {
continue;
}
let d = Draw { label: &label, key: &key, presets, brush, collapsed, filtering: !filter.is_empty(), grid, layout };
let d = Draw { label: &label, key: &key, presets, current, collapsed, filtering: !filter.is_empty(), grid, layout };
draw_nodes(ui, &d, &folder_tree(&items), 0, &mut acts);
ui.add_space(2.0);
}
@ -662,7 +656,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
});
});
ui.add_space(6.0);
let acts = preset_list(ui, &app.session.tools.presets, &app.session.tools.brush, &mut app.ui.brushes_panel, PANEL_LIST);
let acts = preset_list(ui, &app.session.tools.presets, app.session.tools.current_preset.as_deref(), &mut app.ui.brushes_panel, PANEL_LIST);
apply(app, acts);
ui.add_space(4.0);
widgets::hairline(ui);
@ -670,7 +664,8 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.label(RichText::new(format!("{} presets", app.session.tools.presets.len())).color(t.text_faint));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let current = app.session.tools.presets.iter().find(|p| is_current(&p.brush, &app.session.tools.brush)).map(|p| p.name.clone());
// The current preset by identity: editing the brush after picking must not deselect it.
let current = app.session.tools.current_preset.clone().filter(|n| app.session.tools.presets.iter().any(|p| p.name.eq_ignore_ascii_case(n)));
if ui.add_enabled_ui(current.is_some(), |ui| icons::button(ui, "trash", 24.0, false, "Delete brush")).inner.clicked()
&& let Some(name) = current
{

View File

@ -1938,6 +1938,7 @@
Collapse group ({key}-click: all groups) Sbalit skupinu ({key}+kliknutí: všechny skupiny)
Create new brush from the current settings Vytvořit nový štětec z aktuálního nastavení
Create new brush from these settings Vytvořit nový štětec z tohoto nastavení
Update the current brush with these settings Aktualizovat aktuální štětec těmito nastaveními
Delete brush Odstranit štětec
Drag the arrow to set the angle, the dots to set the roundness Tažením šipky nastavíte úhel, tažením bodů kulatost
Expand Rozbalit

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

View File

@ -1912,6 +1912,7 @@
Collapse group ({key}-click: all groups) Gruppe einklappen ({key}-Klick: alle Gruppen)
Create new brush from the current settings Neuen Pinsel aus den aktuellen Einstellungen erstellen
Create new brush from these settings Neuen Pinsel aus diesen Einstellungen erstellen
Update the current brush with these settings Aktuellen Pinsel mit diesen Einstellungen aktualisieren
Delete brush Pinsel löschen
Drag the arrow to set the angle, the dots to set the roundness Ziehe den Pfeil für den Winkel, die Punkte für die Rundung
Expand Ausklappen

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

View File

@ -1463,6 +1463,7 @@
Collapse Panel Group Σύμπτυξη ομάδας πλαισίων
Create new brush from the current settings Δημιουργία νέου πινέλου από τις τρέχουσες ρυθμίσεις
Create new brush from these settings Δημιουργία νέου πινέλου από αυτές τις ρυθμίσεις
Update the current brush with these settings Ενημέρωση του τρέχοντος πινέλου με αυτές τις ρυθμίσεις
Delete brush Διαγραφή πινέλου
Drag the arrow to set the angle, the dots to set the roundness Σύρετε το βέλος για τη γωνία και τις κουκκίδες για τη στρογγυλότητα
Expand Panel Group Ανάπτυξη ομάδας πλαισίων

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

View File

@ -1922,6 +1922,7 @@
Collapse group ({key}-click: all groups) Contraer grupo ({key}-clic: todos los grupos)
Create new brush from the current settings Crear un pincel nuevo con los ajustes actuales
Create new brush from these settings Crear un pincel nuevo con estos ajustes
Update the current brush with these settings Actualizar el pincel actual con estos ajustes
Delete brush Eliminar pincel
Drag the arrow to set the angle, the dots to set the roundness Arrastre la flecha para fijar el ángulo y los puntos para fijar la redondez
Expand Expandir

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

View File

@ -1891,6 +1891,7 @@
Collapse group ({key}-click: all groups) Réduire le groupe ({key}-clic : tous les groupes)
Create new brush from the current settings Créer un nouveau pinceau à partir des paramètres actuels
Create new brush from these settings Créer un nouveau pinceau à partir de ces paramètres
Update the current brush with these settings Mettre à jour le pinceau actuel avec ces paramètres
Delete brush Supprimer le pinceau
Drag the arrow to set the angle, the dots to set the roundness Faites glisser la flèche pour régler l'angle, les points pour régler l'arrondi
Expand Développer

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

View File

@ -1885,6 +1885,7 @@
Collapse group ({key}-click: all groups) Ciutkan grup ({key}-klik: semua grup)
Create new brush from the current settings Buat kuas baru dari pengaturan saat ini
Create new brush from these settings Buat kuas baru dari pengaturan ini
Update the current brush with these settings Perbarui kuas saat ini dengan pengaturan ini
Delete brush Hapus kuas
Drag the arrow to set the angle, the dots to set the roundness Seret panah untuk mengatur sudut, titik untuk mengatur kebulatan
Expand Bentangkan

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

View File

@ -1920,6 +1920,7 @@
Collapse group ({key}-click: all groups) Comprimi gruppo ({key}-click: tutti i gruppi)
Create new brush from the current settings Crea nuovo pennello da impostazioni correnti
Create new brush from these settings Crea nuovo pennello da queste impostazioni
Update the current brush with these settings Aggiorna il pennello attuale con queste impostazioni
Delete brush Elimina pennello
Drag the arrow to set the angle, the dots to set the roundness Trascina la freccia per impostare l'angolo, i puntini per la rotondità
Expand Espandi

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

View File

@ -1928,6 +1928,7 @@
Create new brush from the current settings 現在の設定から新規ブラシを作成
Create new brush from these settings この設定から新規ブラシを作成
Delete brush ブラシを削除
Update the current brush with these settings この設定で現在のブラシを更新
Drag the arrow to set the angle, the dots to set the roundness 矢印をドラッグして角度、点をドラッグして真円率を設定
Expand 展開
Expand Panel Group パネルグループを展開

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

View File

@ -1887,6 +1887,7 @@
Collapse group ({key}-click: all groups) 그룹 접기 ({key} 키를 누른 채 클릭: 모든 그룹)
Create new brush from the current settings 현재 설정으로 새 브러시 만들기
Create new brush from these settings 이 설정으로 새 브러시 만들기
Update the current brush with these settings 이 설정으로 현재 브러시 업데이트
Delete brush 브러시 삭제
Drag the arrow to set the angle, the dots to set the roundness 화살표를 드래그하여 각도를, 점을 드래그하여 원형률을 설정하세요
Expand 펼치기

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

View File

@ -1902,6 +1902,7 @@
Collapse group ({key}-click: all groups) Groep samenvouwen ({key}-klik: alle groepen)
Create new brush from the current settings Nieuw penseel maken van de huidige instellingen
Create new brush from these settings Nieuw penseel maken van deze instellingen
Update the current brush with these settings Huidig penseel bijwerken met deze instellingen
Delete brush Penseel verwijderen
Drag the arrow to set the angle, the dots to set the roundness Sleep de pijl om de hoek in te stellen, de stippen om de rondheid in te stellen
Expand Uitvouwen

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

View File

@ -1918,6 +1918,7 @@
Collapse group ({key}-click: all groups) Zwiń grupę (kliknięcie z {key}: wszystkie grupy)
Create new brush from the current settings Utwórz nowy pędzel z bieżących ustawień
Create new brush from these settings Utwórz nowy pędzel z tych ustawień
Update the current brush with these settings Zaktualizuj bieżący pędzel tymi ustawieniami
Delete brush Usuń pędzel
Drag the arrow to set the angle, the dots to set the roundness Przeciągnij strzałkę, aby ustawić kąt, a punkty, aby ustawić zaokrąglenie
Expand Rozwiń

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

View File

@ -1890,6 +1890,7 @@
Collapse group ({key}-click: all groups) Recolher grupo ({key}-clique: todos os grupos)
Create new brush from the current settings Criar novo pincel a partir das configurações atuais
Create new brush from these settings Criar novo pincel a partir destas configurações
Update the current brush with these settings Atualizar o pincel atual com estas configurações
Delete brush Excluir pincel
Drag the arrow to set the angle, the dots to set the roundness Arraste a seta para definir o ângulo e os pontos para definir o arredondamento
Expand Expandir

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

View File

@ -958,6 +958,7 @@
Brush Preset Options Параметры наборов кистей
New Brush Новая кисть
Create new brush from these settings Создать новую кисть из этих параметров
Update the current brush with these settings Обновить текущую кисть этими параметрами
Reset the brush to the defaults Сбросить кисть к значениям по умолчанию
No brush presets Нет наборов кистей
Override Pressure Переопределить нажим

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

View File

@ -949,6 +949,7 @@
Brush Preset Options Параметри набору пензля
New Brush Новий пензель
Create new brush from these settings Створити новий пензель із цими параметрами
Update the current brush with these settings Оновити поточний пензель цими параметрами
Reset the brush to the defaults Скинути пензель до типових значень
No brush presets Немає наборів пензлів
Override Pressure Перевизначити натиск

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

View File

@ -1929,6 +1929,7 @@
Collapse group (⌥-click: all groups) 折叠组 (⌥-单击:所有组)
Create new brush from the current settings 根据当前设置创建新画笔
Create new brush from these settings 根据这些设置创建新画笔
Update the current brush with these settings 用这些设置更新当前画笔
Delete brush 删除画笔
Drag the arrow to set the angle, the dots to set the roundness 拖动箭头设置角度,拖动圆点设置圆度
Expand 展开

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

View File

@ -1922,6 +1922,7 @@
Collapse group ({key}-click: all groups) 收合群組 ({key}+按一下:全部群組)
Create new brush from the current settings 以目前設定建立新筆刷
Create new brush from these settings 以這些設定建立新筆刷
Update the current brush with these settings 以這些設定更新目前筆刷
Delete brush 刪除筆刷
Drag the arrow to set the angle, the dots to set the roundness 拖曳箭頭設定角度,拖曳圓點設定圓度
Expand 展開

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

View File

@ -34,6 +34,9 @@ pub fn sync_tool_brush(app: &mut PhotocraftApp) {
// A tool seen for the first time takes what the brush has, at Photoshop's smoothing.
None => BrushSettings { smoothing: first_smoothing(), ..current },
};
// The picked preset was the outgoing tool's brush: forget it, so Update Brush can't overwrite
// that preset with this tool's brush.
app.session.tools.current_preset = None;
app.ui.brush_tool = tool;
}
@ -155,7 +158,9 @@ pub fn show_picker(app: &mut PhotocraftApp, ctx: &egui::Context) {
let before = app.session.tools.brush.clone();
let mut b = before.clone();
let list = &mut app.ui.brush_picker_list;
let picks = egui::Frame::popup(ui.style()).show(ui, |ui| crate::brush_picker::body(ui, &mut b, &app.session.tools.presets, list)).inner;
let picks = egui::Frame::popup(ui.style())
.show(ui, |ui| crate::brush_picker::body(ui, &mut b, &app.session.tools.presets, app.session.tools.current_preset.as_deref(), list))
.inner;
crate::brush_panel::commit_gesture(app, ui.ctx(), &before, &b);
let closes = picks.iter().any(crate::brush_picker::Pick::closes);
crate::brush_picker::apply(app, ui.ctx(), picks);