diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 3de6bc84a1..bb795b10c8 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -644,13 +644,13 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result { mix: pct(p, "mix", bm.mix * 100.0), flow: pct(p, "flow", bm.flow * 100.0), sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers), + load_after_stroke: flag(p, "loadAfterStroke", bm.load_after_stroke), + clean_after_stroke: flag(p, "cleanAfterStroke", bm.clean_after_stroke), }; let sample_all = m.sample_all_layers; - let (clean, load_after) = (flag(p, "cleanAfterStroke", true), flag(p, "loadAfterStroke", true)); + let paint = color(p.get("color"), s.tools.foreground); let mut state = s.tools.mixer.clone(); - if load_after || state.reservoir.is_none() { - state.load(color(p.get("color"), s.tools.foreground)); - } + state.begin_stroke(paint); let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) }; let stroke = Stroke { brush, points: pts }; let dmg = s.edit("Mixer Brush", |doc, _| { @@ -672,13 +672,43 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result { let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; Ok(apply_mixer_stroke(surf, sample.as_ref(), &stroke, &m, &mut state, sel.as_ref(), lock)) })?; - if clean { - state.clean(); - } + state.end_stroke(paint, m.clean_after_stroke, m.load_after_stroke); s.tools.mixer = state; Ok(damage_json(s, dmg)) } +/// `brush.mixer.load`: Load Brush fills the Mixer Brush's reservoir with the color. The next stroke +/// keeps that paint unless a different foreground is chosen first. Returns the paint the brush now +/// carries. +fn mixer_load(s: &mut Session, p: &Value) -> Result { + let cmd = "brush.mixer.load"; + let paint = match p.get("color").filter(|v| !v.is_null()) { + None => Some(s.tools.foreground), + Some(Value::String(h)) => crate::commands::parse_hex(h), + Some(Value::Array(a)) => { + let c: Option> = a.iter().map(|x| x.as_f64().filter(|x| (0.0..=1.0).contains(x)).map(|x| x as f32)).collect(); + match c.as_deref() { + Some(&[r, g, b]) => Some([r, g, b, 1.0]), + Some(&[r, g, b, a]) => Some([r, g, b, a]), + _ => None, + } + } + Some(_) => None, + }; + let paint = paint.ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b, a?] in 0..1"))?; + s.tools.mixer.load(paint); + s.tools.mixer.color = Some(s.tools.foreground); + Ok(serde_json::to_value(&s.tools.mixer).unwrap_or_default()) +} + +/// `brush.mixer.clean`: Clean Brush empties the Mixer Brush. The next stroke starts empty unless +/// a different color is chosen first. Returns the paint the brush now carries. +fn mixer_clean(s: &mut Session, _: &Value) -> Result { + s.tools.mixer.clean(); + s.tools.mixer.color = Some(s.tools.foreground); + Ok(serde_json::to_value(&s.tools.mixer).unwrap_or_default()) +} + /// `paint.colorReplacement`'s stroke and settings. The replacement colour is the brush colour /// (the foreground unless `color` says otherwise). It paints layer pixels only: a mask, alpha /// channel or Quick Mask `target` is refused rather than recolouring the layer instead. @@ -1135,11 +1165,13 @@ pub fn specs() -> Vec { spec!( "paint.mixerBrush", "Mixer Brush", - r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##, + r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground (a color other than the one recorded by the last stroke, load or clean loads it first; a load or clean records the foreground),"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke (empty the brush after the stroke),"loadAfterStroke":bool=brush.mixer.loadAfterStroke (load the brush with `color` after the stroke),"seed":u64?}"##, has_paintable, mixer_brush, true ), + spec!("brush.mixer.load", "Load Mixer Brush", r##"{"color":"#rrggbb"|[r,g,b,a?] 0..1?=foreground}"##, always, mixer_load, true), + spec!("brush.mixer.clean", "Clean Mixer Brush", "{}", always, mixer_clean, true), spec!( "paint.colorReplacement", "Color Replacement", diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index cf9c761ce6..8d9efca083 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -814,6 +814,101 @@ fn mixer_brush_uses_the_brush_mixer_settings() { assert!(rgba(&s, 190, 5)[3] > 0.95); } +/// A red image with an empty layer above it, a blue foreground, and a Mixer Brush that samples all +/// layers, keeps its paint along the stroke (Load 100 %) and has the automatic Load off. +fn mixer_over_red() -> Session { + let mut s = session(200, 30); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#0000ff"})).unwrap(); + let mixer = json!({"load": 1, "sampleAllLayers": true, "loadAfterStroke": false}); + s.execute("tools.setBrush", json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": mixer}})).unwrap(); + s +} + +fn mixer_row(s: &mut Session, y: i32) -> [f32; 4] { + s.execute("paint.mixerBrush", json!({"points": [[5, y], [195, y]], "size": 6})).unwrap(); + rgba(s, 100, y) +} + +#[test] +fn clean_brush_keeps_the_foreground_out_of_the_next_stroke() { + let mut s = mixer_over_red(); + s.execute("brush.mixer.load", json!({})).unwrap(); + let state = s.execute("brush.mixer.clean", json!({})).unwrap(); + assert_eq!((state["reservoir"].clone(), state["pickup"].clone()), (Value::Null, Value::Null)); + let c = mixer_row(&mut s, 15); + assert!(c[3] > 0.1 && c[0] > 0.99 && c[2] < 0.01, "the clean brush lays down only the red it picks up: {c:?}"); +} + +#[test] +fn the_automatic_clean_empties_the_reservoir() { + let mut s = mixer_over_red(); + // The brush was never loaded, so the first stroke takes the foreground. + assert!(mixer_row(&mut s, 5)[2] > 0.2); + assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.pickup), (None, None)); + let c = mixer_row(&mut s, 15); + assert!(c[3] > 0.1 && c[2] < 0.01, "the cleaned brush lays down no blue: {c:?}"); + // With the automatic Load on as well (the default), the brush is cleaned and then refilled + // after the stroke, so the next one starts with a full reservoir and no pickup. + s.execute("tools.setBrush", json!({"brush": {"mixer": {"loadAfterStroke": true}}})).unwrap(); + mixer_row(&mut s, 25); + assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.amount, s.tools.mixer.pickup), (Some([0.0, 0.0, 1.0, 1.0]), 1.0, None)); +} + +#[test] +fn load_brush_after_a_clean_paints_the_foreground_again() { + let mut s = mixer_over_red(); + s.execute("brush.mixer.clean", json!({})).unwrap(); + assert!(mixer_row(&mut s, 5)[2] < 0.01); + let state = s.execute("brush.mixer.load", json!({})).unwrap(); + assert_eq!(state["reservoir"], json!([0.0, 0.0, 1.0, 1.0])); + assert!(mixer_row(&mut s, 15)[2] > 0.2, "the loaded brush paints blue"); + // A color param loads that color instead of the foreground, and the next stroke paints it. + s.execute("brush.mixer.load", json!({"color": [0.0, 1.0, 0.0]})).unwrap(); + assert_eq!((s.tools.mixer.reservoir, s.tools.mixer.amount), (Some([0.0, 1.0, 0.0, 1.0]), 1.0)); + assert_eq!(s.tools.mixer.next_load(s.tools.foreground), Some([0.0, 1.0, 0.0, 1.0])); + let c = mixer_row(&mut s, 25); + assert!(c[1] > 0.2 && c[2] < 0.01, "the stroke paints the loaded green, not the blue foreground: {c:?}"); +} + +#[test] +fn with_the_automatic_load_off_a_new_foreground_loads_at_the_next_stroke() { + let mut s = mixer_over_red(); + assert!(mixer_row(&mut s, 5)[2] > 0.2); + s.execute("tools.setColors", json!({"foreground": "#00ff00"})).unwrap(); + let c = mixer_row(&mut s, 15); + assert!(c[1] > 0.2, "the new foreground is loaded: {c:?}"); + // The automatic Clean emptied it again, and the foreground did not change. + let c = mixer_row(&mut s, 25); + assert!(c[3] > 0.1 && c[1] < 0.01 && c[2] < 0.01, "{c:?}"); +} + +#[test] +fn load_and_clean_brush_refuse_bad_params_and_keep_the_paint() { + let mut s = mixer_over_red(); + s.execute("brush.mixer.load", json!({"color": "#336699"})).unwrap(); + let before = s.tools.mixer.clone(); + for bad in [ + json!({"color": 5}), + json!({"color": "#zz0000"}), + json!({"color": "#ff"}), + json!({"color": [2, 0, 0]}), + json!({"color": [0, 0]}), + json!({"color": [0, 0, 0, 0, 0]}), + json!({"color": {"r": 1}}), + json!({"color": ["a", 0, 0]}), + ] { + assert!(s.execute("brush.mixer.load", bad.clone()).is_err(), "{bad}"); + assert_eq!(s.tools.mixer, before, "{bad}"); + } + // Clean Brush takes no params, so anything extra is ignored, without a document too. + let mut empty = Session::new(); + empty.execute("brush.mixer.clean", json!({"color": [], "junk": {"x": null}})).unwrap(); + empty.execute("brush.mixer.load", json!({"color": null})).unwrap(); + assert_eq!(empty.tools.mixer.reservoir, Some(empty.tools.foreground)); +} + #[test] fn coalesced_set_brush_calls_journal_once_per_gesture() { let mut s = session(20, 20); diff --git a/crates/paint/src/mixer.rs b/crates/paint/src/mixer.rs index afb8092cd2..49abb127a1 100644 --- a/crates/paint/src/mixer.rs +++ b/crates/paint/src/mixer.rs @@ -26,11 +26,15 @@ pub struct MixerSettings { pub flow: f32, /// Pick up colour from all visible layers instead of the target layer only. pub sample_all_layers: bool, + /// Load the brush with the stroke color after each stroke (the automatic Load option). + pub load_after_stroke: bool, + /// Empty the brush after each stroke (the automatic Clean option). + pub clean_after_stroke: bool, } impl Default for MixerSettings { fn default() -> Self { - Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false } + Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false, load_after_stroke: true, clean_after_stroke: true } } } @@ -44,18 +48,47 @@ pub struct MixerState { pub amount: f32, /// Colour picked up from the canvas (straight RGBA), None = clean. pub pickup: Option<[f32; 4]>, + /// The color recorded by the last stroke (its color) or the last Load Brush or Clean Brush + /// (the foreground). None = no stroke, load or clean yet. + pub color: Option<[f32; 4]>, } impl MixerState { - /// Load the reservoir with a colour (full). + /// Load Brush: fill the reservoir with a color (full). pub fn load(&mut self, color: [f32; 4]) { self.reservoir = Some(color); self.amount = 1.0; } - /// Clean the brush (drop the picked-up colour). + /// Clean Brush: empty the reservoir and the pickup well. pub fn clean(&mut self) { + self.reservoir = None; + self.amount = 0.0; self.pickup = None; } + /// Before a stroke in `color`: choosing a color other than [`MixerState::color`] loads the + /// reservoir with it. + pub fn begin_stroke(&mut self, color: [f32; 4]) { + if self.color != Some(color) { + self.load(color); + self.color = Some(color); + } + } + /// After a stroke in `color`: the automatic Clean empties the brush, then the automatic Load + /// fills the reservoir again. + pub fn end_stroke(&mut self, color: [f32; 4], clean: bool, load: bool) { + if clean { + self.clean(); + } + if load { + self.load(color); + } + self.color = Some(color); + } + /// The reservoir color the next stroke in `color` starts with (the Current Brush Load + /// swatch); None = a clean brush. + pub fn next_load(&self, color: [f32; 4]) -> Option<[f32; 4]> { + if self.color != Some(color) { Some(color) } else { self.reservoir } + } } /// Paint a Mixer Brush stroke. `sample` is the surface colour is picked up from (the layer itself @@ -209,6 +242,48 @@ mod tests { assert!(st.pickup.is_none()); } + #[test] + fn a_clean_brush_paints_only_the_canvas_color_it_picks_up() { + let mut st = MixerState::default(); + st.load([0.0, 0.0, 1.0, 1.0]); + st.pickup = Some([0.0, 1.0, 0.0, 1.0]); + st.clean(); + assert_eq!((st.reservoir, st.amount, st.pickup), (None, 0.0, None)); + // On red, the clean wet brush smears red and lays down none of the blue it was loaded with. + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 200, 20), &[1.0, 0.0, 0.0, 1.0]); + apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings::default(), &mut st, None, false); + let c = s.rgba(100, 10); + assert!(c[0] > 0.99 && c[1] < 0.01 && c[2] < 0.01, "{c:?}"); + } + + #[test] + fn a_new_color_loads_the_brush_and_the_automatic_options_run_after_the_stroke() { + let (blue, green) = ([0.0, 0.0, 1.0, 1.0], [0.0, 1.0, 0.0, 1.0]); + let mut st = MixerState::default(); + // A brush never loaded takes the stroke's color. + assert_eq!(st.next_load(blue), Some(blue)); + st.begin_stroke(blue); + assert_eq!((st.reservoir, st.amount), (Some(blue), 1.0)); + // Clean on, Load off: the next stroke in the same color starts empty. + st.end_stroke(blue, true, false); + assert_eq!(st.next_load(blue), None); + st.begin_stroke(blue); + assert_eq!((st.reservoir, st.pickup), (None, None)); + // Choosing another color loads it. + assert_eq!(st.next_load(green), Some(green)); + st.begin_stroke(green); + assert_eq!((st.reservoir, st.amount), (Some(green), 1.0)); + // Load on, Clean off: the reservoir is full again and the pickup stays. + st.amount = 0.1; + st.pickup = Some(blue); + st.end_stroke(green, false, true); + assert_eq!((st.reservoir, st.amount, st.pickup), (Some(green), 1.0, Some(blue))); + // Both on: a fresh load with no pickup. + st.end_stroke(green, true, true); + assert_eq!((st.reservoir, st.amount, st.pickup), (Some(green), 1.0, None)); + } + #[test] fn dry_empty_brush_paints_nothing() { let mut s = Surface::new(PixelFormat::RGBA8); diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 670f442be2..f1e9e50a35 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -97,7 +97,15 @@ fn edit_section(i: usize, b: &mut BrushSettings) { b.transfer.flow = dynamic(0.25, Control::Fade, 0.3); b.transfer.wetness = dynamic(0.35, Control::PenPressure, 0.1); b.transfer.mix = dynamic(0.45, Control::Fade, 0.2); - b.mixer = paint::MixerSettings { wet: 0.9, load: 0.15, mix: 0.7, flow: 0.8, sample_all_layers: true }; + b.mixer = paint::MixerSettings { + wet: 0.9, + load: 0.15, + mix: 0.7, + flow: 0.8, + sample_all_layers: true, + load_after_stroke: false, + clean_after_stroke: false, + }; } 7 => { let p = &mut b.pose; @@ -488,6 +496,56 @@ fn mixer_brush_options_update_the_persisted_mixer_settings() { assert_eq!(params["brush"]["mixer"]["sampleAllLayers"], true); } +#[test] +fn mixer_brush_clean_brush_and_the_automatic_load_keep_the_foreground_out() { + use egui_kittest::kittest::Queryable; + let mut h = options_bar_harness(crate::state::Tool::MixerBrush); + // A red image, an empty layer above it, a blue foreground, and a brush that samples all layers. + for (id, p) in [ + ("file.new", json!({"width": 200, "height": 40, "background": "transparent"})), + ("edit.fill", json!({"color": "#ff0000"})), + ("layer.new.layer", json!({})), + ("tools.setColors", json!({"foreground": "#0000ff"})), + ("tools.setBrush", json!({"brush": {"size": 8, "pressureSize": false, "mixer": {"sampleAllLayers": true}}})), + ] { + h.state_mut().run(id, p).unwrap(); + } + h.run_steps(3); + for label in ["Current Brush Load", "Load the brush after each stroke", "Clean the brush after each stroke"] { + assert!(h.get_by_label(label).rect().is_positive(), "missing Mixer Brush option {label}"); + } + + // Turn the automatic Load off: one tools.setBrush call. + h.get_by_label("Load the brush after each stroke").click(); + h.run_steps(3); + assert!(!h.state().session.tools.brush.mixer.load_after_stroke); + let (id, params) = last_journal(h.state()).unwrap(); + assert_eq!((id.as_str(), ¶ms["brush"]["mixer"]["loadAfterStroke"]), ("tools.setBrush", &json!(false))); + + // Current Brush Load › Clean Brush empties the brush. + h.get_by_label("Current Brush Load").click(); + h.run_steps(3); + h.get_by_label("Clean Brush").click(); + h.run_steps(3); + assert_eq!(last_journal(h.state()).map(|(id, _)| id), Some("brush.mixer.clean".to_string())); + assert_eq!(h.state().session.tools.mixer.reservoir, None); + + // The stroke the canvas commits on release lays down only the red it picks up. + crate::retouch_ui::finish_stroke(h.state_mut(), crate::state::Tool::MixerBrush, &[[5.0, 20.0, 1.0], [195.0, 20.0, 1.0]], egui::Modifiers::NONE); + let d = h.state().session.active().unwrap(); + let c = d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(100, 20); + assert!(c[3] > 0.1 && c[0] > 0.99 && c[2] < 0.01, "the stroke carries no foreground: {c:?}"); + // With the automatic Load off, the stroke did not refill the brush either. + assert_eq!(h.state().session.tools.mixer.reservoir, None); + + // Load Brush fills it with the foreground again. + h.get_by_label("Current Brush Load").click(); + h.run_steps(3); + h.get_by_label("Load Brush").click(); + h.run_steps(3); + assert_eq!(h.state().session.tools.mixer.reservoir, Some([0.0, 0.0, 1.0, 1.0])); +} + #[test] fn drop_targets_and_actions_reorder_presets() { use crate::brushes_tab::{Action, apply, drop_target, group_key}; diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index e7d3a16f56..9e66f9b0a4 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -565,6 +565,8 @@ fn mixer_options(ui: &mut egui::Ui, b: &mut BrushSettings) { pct(ui, tl!("Mix"), &mut m.mix, 1.0); pct(ui, tl!("Flow"), &mut m.flow, 1.0); widgets::checkbox(ui, &mut m.sample_all_layers, "Sample All Layers"); + widgets::checkbox(ui, &mut m.load_after_stroke, tl!("Load the brush after each stroke")); + widgets::checkbox(ui, &mut m.clean_after_stroke, tl!("Clean the brush after each stroke")); } /// Tilt is stored in degrees (±90, W3C Pointer Events); Photoshop shows it as ±100 %. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6789b75134..64c966deb1 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2171,6 +2171,11 @@ Color Replacement Tool Nástroj Nahrazení barvy Color Replacement Nahrazení barvy Wet Vlhkost + Current Brush Load Aktuální náplň štětce + Load Brush Naplnit štětec + Clean Brush Vyčistit štětec + Load the brush after each stroke Naplnit štětec po každém tahu + Clean the brush after each stroke Vyčistit štětec po každém tahu Mix Míchání Wetness Jitter Kolísání vlhkosti Mix Jitter Kolísání míchání diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 3aa7e7212b..b97a687ce1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2140,6 +2140,11 @@ Mixer Brush Tool Mischer-Pinsel-Werkzeug Color Replacement Tool Farbersatz-Werkzeug Wet Feucht + Current Brush Load Aktuelle Pinselladung + Load Brush Pinsel laden + Clean Brush Pinsel reinigen + Load the brush after each stroke Pinsel nach jedem Strich laden + Clean the brush after each stroke Pinsel nach jedem Strich reinigen Mix Mischen Wetness Jitter Feuchtigkeitsschwankung Mix Jitter Mischschwankung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7b653fbbf6..5d7e8a6272 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1984,6 +1984,11 @@ Color Replacement Tool Αντικατάσταση χρώματος (Color Replacement Tool) Color Replacement Αντικατάσταση χρώματος Wet Υγρασία + Current Brush Load Τρέχουσα φόρτωση πινέλου + Load Brush Φόρτωση πινέλου + Clean Brush Καθαρισμός πινέλου + Load the brush after each stroke Φόρτωση του πινέλου μετά από κάθε πινελιά + Clean the brush after each stroke Καθαρισμός του πινέλου μετά από κάθε πινελιά Load Φόρτωση Mix Ανάμειξη 8 bit 8 bit ανά κανάλι diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index eab6215dff..68dd9378bc 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2158,6 +2158,11 @@ Mixer Brush Tool Herramienta Pincel mezclador Color Replacement Tool Herramienta Sustitución de color Wet Húmedo + Current Brush Load Carga actual del pincel + Load Brush Cargar pincel + Clean Brush Limpiar pincel + Load the brush after each stroke Cargar el pincel después de cada trazo + Clean the brush after each stroke Limpiar el pincel después de cada trazo Mix Mezcla Wetness Jitter Variación de humedad Mix Jitter Variación de mezcla diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 80c7f110fe..76d63c44c0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2118,6 +2118,11 @@ Apply the pen's tilt and rotation to the tip shape Appliquer l'inclinaison et la rotation du stylet à la forme de la pointe Mixer Brush Pinceau mélangeur Wet Humidité + Current Brush Load Charge actuelle du pinceau + Load Brush Charger le pinceau + Clean Brush Nettoyer le pinceau + Load the brush after each stroke Charger le pinceau après chaque trait + Clean the brush after each stroke Nettoyer le pinceau après chaque trait Mix Mélange Wetness Jitter Variation de l'humidité Mix Jitter Variation du mélange diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index ca747637ce..23883435a6 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1407,6 +1407,11 @@ Warp: Hajlítás: Weight Vastagság Wet Nedvesség + Current Brush Load Az ecset jelenlegi töltete + Load Brush Ecset feltöltése + Clean Brush Ecset tisztítása + Load the brush after each stroke Az ecset feltöltése minden ecsetvonás után + Clean the brush after each stroke Az ecset tisztítása minden ecsetvonás után Wet Edges Nedves szélek Wetness Jitter Nedvesség ingadozása Width Szélesség diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 4fd06fd88f..aef0651c27 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2112,6 +2112,11 @@ Apply the pen's tilt and rotation to the tip shape Terapkan kemiringan dan rotasi pena ke bentuk ujung Mixer Brush Kuas Pencampur Wet Basah + Current Brush Load Muatan Kuas Saat Ini + Load Brush Muat Kuas + Clean Brush Bersihkan Kuas + Load the brush after each stroke Muat kuas setelah setiap goresan + Clean the brush after each stroke Bersihkan kuas setelah setiap goresan Mix Campur Wetness Jitter Jitter Kebasahan Mix Jitter Jitter Campuran diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1143b138f9..ee576bbd17 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2153,6 +2153,11 @@ Mixer Brush Tool Strumento Pennello miscelatore Color Replacement Tool Strumento Sostituzione colore Wet Bagnato + Current Brush Load Carico attuale del pennello + Load Brush Carica pennello + Clean Brush Pulisci pennello + Load the brush after each stroke Carica il pennello dopo ogni tratto + Clean the brush after each stroke Pulisci il pennello dopo ogni tratto Mix Miscela Wetness Jitter Variazione bagnato Mix Jitter Variazione miscela diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8564b9b308..990e39b84d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2153,6 +2153,11 @@ Mixer Brush Tool 混合ブラシツール Color Replacement Tool 色の置き換えツール Wet ウェット + Current Brush Load 現在のブラシの絵の具 + Load Brush ブラシに補充 + Clean Brush ブラシを洗浄 + Load the brush after each stroke ストロークごとにブラシに補充 + Clean the brush after each stroke ストロークごとにブラシを洗浄 Mix 混合 Wetness Jitter ウェットのジッター Mix Jitter 混合のジッター diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b99fde8236..d1a1758047 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2114,6 +2114,11 @@ Apply the pen's tilt and rotation to the tip shape 펜의 기울기와 회전을 브러시 끝 모양에 적용 Mixer Brush 혼합 브러시 Wet 젖음 + Current Brush Load 현재 브러시 물감 + Load Brush 브러시 채우기 + Clean Brush 브러시 세척 + Load the brush after each stroke 획마다 브러시 채우기 + Clean the brush after each stroke 획마다 브러시 세척 Mix 혼합 Wetness Jitter 젖음 지터 Mix Jitter 혼합 지터 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index c7a0d574b2..39eaaeed59 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -1272,7 +1272,20 @@ mod tests { #[test] fn mixer_brush_ui_strings_have_translations_in_every_registered_language() { - const STRINGS: &[&str] = &["Mixer Brush", "Mixer Brush Tool", "Wet", "Load", "Mix", "Flow", "Sample All Layers"]; + const STRINGS: &[&str] = &[ + "Mixer Brush", + "Mixer Brush Tool", + "Wet", + "Load", + "Mix", + "Flow", + "Sample All Layers", + "Current Brush Load", + "Load Brush", + "Clean Brush", + "Load the brush after each stroke", + "Clean the brush after each stroke", + ]; for lang in Lang::all() { for source in STRINGS { let translated = tr(lang, source); diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 791d173cb0..823d9c725b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2118,6 +2118,11 @@ Mixer Brush Tool Mengpenseelgereedschap Color Replacement Tool Kleurvervangingsgereedschap Wet Nat + Current Brush Load Huidige penseellading + Load Brush Penseel laden + Clean Brush Penseel reinigen + Load the brush after each stroke Penseel laden na elke streek + Clean the brush after each stroke Penseel reinigen na elke streek Mix Mengen Wetness Jitter Nattigheidsvariatie Mix Jitter Mengvariatie diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 23e253d62d..1b61450033 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2137,6 +2137,11 @@ Mixer Brush Tool Narzędzie Pędzel mieszający Color Replacement Tool Narzędzie Zamiana koloru Wet Wilgotność + Current Brush Load Bieżący ładunek pędzla + Load Brush Napełnij pędzel + Clean Brush Wyczyść pędzel + Load the brush after each stroke Napełniaj pędzel po każdym pociągnięciu + Clean the brush after each stroke Czyść pędzel po każdym pociągnięciu Mix Mieszanie Wetness Jitter Rozrzut wilgotności Mix Jitter Rozrzut mieszania diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 327533ad88..561f0d255a 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2112,6 +2112,11 @@ Apply the pen's tilt and rotation to the tip shape Aplicar a inclinação e a rotação da caneta à forma da ponta Mixer Brush Pincel misturador Wet Molhado + Current Brush Load Carga atual do pincel + Load Brush Carregar pincel + Clean Brush Limpar pincel + Load the brush after each stroke Carregar o pincel após cada traço + Clean the brush after each stroke Limpar o pincel após cada traço Mix Mistura Wetness Jitter Variação da umidade Mix Jitter Variação da mistura diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 6b2e368454..ed173a9e93 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1023,6 +1023,11 @@ Mix Смешивание Auto Clean Brush Автоочистка кисти Wet Влага + Current Brush Load Текущая загрузка кисти + Load Brush Загрузить кисть + Clean Brush Очистить кисть + Load the brush after each stroke Загружать кисть после каждого штриха + Clean the brush after each stroke Очищать кисть после каждого штриха Rate Скорость Airbrush: keeps depositing paint while the pointer rests. Аэрограф: продолжает наносить краску, пока курсор неподвижен. Pulled String Mode Режим натянутой нити diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 5530f81ce6..81fc321d55 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2095,6 +2095,11 @@ Apply the pen's tilt and rotation to the tip shape Primeni nagib i rotaciju olovke na oblik vrha Mixer Brush Četkica za mešanje Wet Vlažnost + Current Brush Load Trenutno punjenje četkice + Load Brush Napuni četkicu + Clean Brush Očisti četkicu + Load the brush after each stroke Napuni četkicu posle svakog poteza + Clean the brush after each stroke Očisti četkicu posle svakog poteza Mix Mešanje Wetness Jitter Kolebanje vlažnosti Mix Jitter Kolebanje mešanja diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2e6f96a0e7..581a9ec714 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2092,6 +2092,11 @@ Mixer Brush Tool Karıştırıcı Fırça Aracı Color Replacement Tool Renk Değiştirme Aracı Wet Islaklık + Current Brush Load Geçerli Fırça Yükü + Load Brush Fırçayı Yükle + Clean Brush Fırçayı Temizle + Load the brush after each stroke Her vuruştan sonra fırçayı yükle + Clean the brush after each stroke Her vuruştan sonra fırçayı temizle Mix Karışım Wetness Jitter Islaklık Titreme Mix Jitter Karıştırma Titreşimi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5280372cad..d88fd3ac38 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1000,6 +1000,11 @@ Mix Змішування Auto Clean Brush Автоматично очищувати пензель Wet Вологість + Current Brush Load Поточне завантаження пензля + Load Brush Завантажити пензель + Clean Brush Очистити пензель + Load the brush after each stroke Завантажувати пензель після кожного штриха + Clean the brush after each stroke Очищати пензель після кожного штриха Rate Частота Airbrush: keeps depositing paint while the pointer rests. Аерограф: продовжує наносити фарбу, коли вказівник нерухомий. Pulled String Mode Режим натягнутої нитки diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8f8e1c3443..18d026b867 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1296,6 +1296,11 @@ Mixer Brush Cọ trộn Mixer Brush Tool Công cụ Cọ trộn Wet Ướt + Current Brush Load Lượng sơn hiện tại của cọ + Load Brush Nạp sơn cho cọ + Clean Brush Làm sạch cọ + Load the brush after each stroke Nạp sơn cho cọ sau mỗi nét vẽ + Clean the brush after each stroke Làm sạch cọ sau mỗi nét vẽ Load Tải Mix Trộn Flow Lưu lượng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 5efbe28990..984a88fe87 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2221,6 +2221,11 @@ cameraRaw Blending 混合 Apply the pen's tilt and rotation to the tip shape 将笔的倾斜和旋转应用于笔尖形状 Mixer Brush 混合器画笔 Wet 湿润 + Current Brush Load 当前画笔载入 + Load Brush 载入画笔 + Clean Brush 清理画笔 + Load the brush after each stroke 每次描边后载入画笔 + Clean the brush after each stroke 每次描边后清理画笔 Mix 混合 Wetness Jitter 湿润度抖动 Mix Jitter 混合抖动 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ea0af79451..46319d21ef 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2160,6 +2160,11 @@ Color Replacement Tool 顏色取代工具 Color Replacement 顏色取代 Wet 濕度 + Current Brush Load 目前筆刷載入 + Load Brush 載入筆刷 + Clean Brush 清理筆刷 + Load the brush after each stroke 每次描邊後載入筆刷 + Clean the brush after each stroke 每次描邊後清理筆刷 Mix 混合 Wetness Jitter 濕度快速變換 Mix Jitter 混合快速變換 diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 982c13175e..18cef34c98 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1122,6 +1122,17 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { brush_preset_chip(ui, b, &mut app.ui); crate::brush_picker::settings_toggle(app, ui); widgets::vline(ui, 22.0); + mixer_load_swatch(app, ui); + let m = &mut b.mixer; + for (icon, on, tip) in [ + ("brush", &mut m.load_after_stroke, tl!("Load the brush after each stroke")), + ("brush-cleaning", &mut m.clean_after_stroke, tl!("Clean the brush after each stroke")), + ] { + if icons::button(ui, icon, 24.0, *on, tip).clicked() { + *on = !*on; + } + } + widgets::vline(ui, 22.0); for (label, value) in [ (tl!("Wet"), &mut b.mixer.wet), (tl!("Load"), &mut b.mixer.load), @@ -4124,6 +4135,47 @@ fn smoothing_options(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings }); } +/// Mixer Brush: the Current Brush Load swatch shows the paint the next stroke starts with (an +/// empty swatch when the brush is clean). Its menu holds Load Brush and Clean Brush. +fn mixer_load_swatch(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(40.0, 26.0), Sense::hover()); + let resp = ui.interact(r, ui.id().with("mixer-brush-load"), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, t.radius_sm, t.hover); + } + let chip = Rect::from_min_size(pos2(r.left() + 4.0, r.center().y - 8.0), vec2(16.0, 16.0)); + let p = ui.painter(); + match app.session.tools.mixer.next_load(app.session.tools.foreground) { + Some(c) => { + p.rect_filled(chip, 2.0, c32(c)); + } + None => { + p.rect_filled(chip, 2.0, t.field); + p.line_segment([chip.left_bottom(), chip.right_top()], Stroke::new(1.0, t.text_faint)); + } + } + p.rect_stroke(chip, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + icons::paint(ui, Rect::from_center_size(pos2(r.right() - 9.0, r.center().y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_faint); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Current Brush Load"))); + let resp = resp.on_hover_text(tl!("Current Brush Load")); + let mut run = None; + egui::Popup::menu(&resp).show(|ui| { + if ui.button(tl!("Load Brush")).clicked() { + run = Some("brush.mixer.load"); + } + if ui.button(tl!("Clean Brush")).clicked() { + run = Some("brush.mixer.clean"); + } + }); + if let Some(id) = run + && let Err(e) = app.run(id, json!({})) + { + app.ui.status = e; + app.ui.status_error = true; + } +} + /// Options-bar brush chip: a click shows or hides Photoshop's Brush Preset picker below it (size, /// hardness, the preset library), the one a right-click on the canvas opens. fn brush_preset_chip(ui: &mut egui::Ui, b: &photocraft_engine::BrushSettings, state: &mut crate::state::UiState) { diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 05697f9e6a..5c75a3e55f 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -91,7 +91,7 @@ tablets against Photoshop side by side. | Tool | Status | Notes | |---|---|---| | Brush, Pencil (aliased, Auto Erase), Eraser (Brush/Pencil/Block) | done | Pencil 1 px quality #2139 | -| Mixer Brush (Wet, Load, Mix, Flow, Sample All Layers) | partial | "incomplete" (#2247) | +| Mixer Brush (Wet, Load, Mix, Flow, Sample All Layers, Current Brush Load with Load Brush and Clean Brush, automatic Load and Clean) | partial | No live result while dragging, no Alt-click loading (#2247) | | Clone Stamp (opacity, flow, aligned, sample, overlay) | partial | Clone Source W/H link and Alt target cursor (TOOL-213-8) | | Pattern Stamp (aligned, impressionist) | done | | | History Brush | done | Art History Brush missing | diff --git a/docs/scorecard.md b/docs/scorecard.md index c4b53a20f8..7d84742699 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -133,7 +133,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | Id | Target | Status | Issue | Note | |---|---|---|---|---| | TOOL-213-1 | Pencil tool: aliased 1 px painting, square pixel-aligned cursor | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::Pencil (B group): live aliased strokes on the pixel grid, square cursor, Auto Erase; pencil_tests.rs | -| TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, command/control dispatch and selection-clipping tests | +| TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, the Current Brush Load swatch with Load Brush and Clean Brush (brush.mixer.load, brush.mixer.clean) and the automatic Load and Clean toggles; command/control dispatch and selection-clipping tests | | TOOL-213-3 | Patch tool with a live preview of the patched area | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.patch (retouch_cmds/patch.rs, source/destination) + Tool::Patch (J group: lasso, then drag the selection); the drag shows the heal live (patch_preview.rs, coarse colour fit: 1000x1000 in 52 ms) | | TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections | | TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 51f6b1cf33..efe2bb159e 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -17,7 +17,7 @@ id = "TOOL-213-2" target = "Mixer Brush tool on the toolbar" status = "done" issue = 213 -note = "Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, command/control dispatch and selection-clipping tests" +note = "Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, the Current Brush Load swatch with Load Brush and Clean Brush (brush.mixer.load, brush.mixer.clean) and the automatic Load and Clean toggles; command/control dispatch and selection-clipping tests" [[item]] id = "TOOL-213-3"