diff --git a/README.md b/README.md index 70ea0729..8e14ed6d 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs Installers for macOS, Windows, Linux and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published. > [!IMPORTANT] -> **Status:** PhotoCraft is in early alpha. The core editing workflow is here, and we're working toward full Photoshop parity milestone by milestone (see [`docs/roadmap.md`](docs/roadmap.md)). Progress is measured, not guessed: `cargo xtask parity` checks every item in Photoshop's menu tree against the live command registry and writes [`docs/parity.md`](docs/parity.md). Expect rough edges, and please file issues. You can also tell us what broke on [Discord](https://discord.gg/artcraft). +> **Status:** PhotoCraft is in early alpha. The core editing workflow is here, and we're working toward full Photoshop parity, milestone by milestone (see [`docs/roadmap.md`](docs/roadmap.md)). Progress is measured, not guessed: `cargo xtask parity` checks every item in Photoshop's menu tree against the live command registry and writes [`docs/parity.md`](docs/parity.md). Expect rough edges, and please file issues. You can also tell us what broke on [Discord](https://discord.gg/artcraft). ## Documentation diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index b0f5be8d..4aeb5226 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -47,6 +47,9 @@ fn always(_: &Session) -> std::result::Result<(), String> { Ok(()) } +/// Largest stroke coordinate accepted (a few times the largest document side, 300 000 px). +const MAX_COORD: f64 = 1_000_000.0; + /// Parse `points`: arrays `[x, y, pressure?, tiltX?, tiltY?, rotation?, timeMs?, wheel?]` or /// objects `{"x":…, "y":…, "pressure":…, "tiltX":…, …, "time":…}`. pub fn parse_points(p: &Value, cmd: &str) -> Result> { @@ -71,6 +74,10 @@ pub fn parse_points(p: &Value, cmd: &str) -> Result> { if pts.is_empty() { return Err(bad(cmd, "`points` is empty")); } + // A stroke runs dab by dab along its length: an absurd coordinate would mean billions of dabs. + if pts.iter().any(|q| !(q.x.abs() <= MAX_COORD && q.y.abs() <= MAX_COORD)) { + return Err(bad(cmd, format!("point coordinates must be finite and within ±{MAX_COORD}"))); + } Ok(pts) } @@ -107,7 +114,7 @@ fn find_preset<'a>(s: &'a Session, name: &str, cmd: &str) -> Result<&'a BrushPre pub fn resolve_brush(s: &Session, p: &Value, cmd: &str) -> Result { let mut b = s.tools.brush.clone(); if let Some(name) = p.get("preset").and_then(Value::as_str) { - b = find_preset(s, name, cmd)?.brush.clone().with_protected_texture(&s.tools.brush); + b = find_preset(s, name, cmd)?.brush.clone().picked_over(&s.tools.brush); } if let Some(patch) = p.get("brush").filter(|v| v.is_object()) { b = merge_brush(&b, patch, cmd)?; @@ -227,6 +234,8 @@ pub struct LiveStroke { lock: bool, layer: Option, params: Value, + /// Where the doc shows the stroke's end as finishing it would draw it (see `push`). + tail: Rect, } impl LiveStroke { @@ -243,21 +252,37 @@ impl LiveStroke { erase_locked(&mut brush, lock, s.tools.background); let renderer = StrokeRenderer::new(&brush, Some(surf.format()), zoom); let pre = surf.clone(); - let mut live = Self { doc: std::sync::Arc::new(doc), seed, renderer, pre, sel, lock, layer, params: p.clone() }; + let mut live = Self { doc: std::sync::Arc::new(doc), seed, renderer, pre, sel, lock, layer, params: p.clone(), tail: Rect::EMPTY }; live.push(&pts)?; Ok(live) } /// Everything the stroke has touched so far. pub fn bounds(&self) -> Rect { - self.renderer.bounds() + self.renderer.bounds().union(&self.tail) } - /// Render more points; returns the rectangle that changed. + /// Render more points; returns the rectangle that changed. The doc shows the stroke as + /// committing it now would: with smoothing, the brush lags behind the pointer and catches up + /// when the stroke ends, so that catch-up tail is drawn too (and redrawn on every step), and + /// nothing new appears on release. pub fn push(&mut self, pts: &[StrokePoint]) -> Result { self.renderer.push(pts); let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?; - Ok(self.renderer.composite(&self.pre, surf, self.sel.as_ref(), self.lock, false)) + let mut dmg = Rect::EMPTY; + let old = std::mem::replace(&mut self.tail, Rect::EMPTY); + if !old.is_empty() { + // Back to the stroke without the previous tail. + surf.write_region(old, &self.pre.read_region(old)); + self.renderer.mark_dirty(old); + dmg = old; + } + dmg = dmg.union(&self.renderer.composite(&self.pre, surf, self.sel.as_ref(), self.lock, false)); + if let Some(mut tail) = self.renderer.tail_preview() { + self.tail = tail.composite(&self.pre, surf, self.sel.as_ref(), self.lock, false); + dmg = dmg.union(&self.tail); + } + Ok(dmg) } } @@ -470,7 +495,7 @@ fn set_brush(s: &mut Session, p: &Value) -> Result { let cmd = "tools.setBrush"; let mut b = s.tools.brush.clone(); if let Some(name) = p.get("preset").and_then(Value::as_str) { - b = find_preset(s, name, cmd)?.brush.clone().with_protected_texture(&s.tools.brush); + b = find_preset(s, name, cmd)?.brush.clone().picked_over(&s.tools.brush); } if flag(p, "reset", false) { b = BrushSettings::default(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5c9b45eb..fc0a8a97 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -199,7 +199,11 @@ impl Default for ToolState { Self { foreground: [0.0, 0.0, 0.0, 1.0], background: [1.0, 1.0, 1.0, 1.0], - brush: Default::default(), + // Photoshop's Brush tool starts at 10 % Smoothing. + brush: photocraft_paint::BrushSettings { + smoothing: photocraft_paint::brush::Smoothing { amount: 0.1, ..Default::default() }, + ..Default::default() + }, presets: photocraft_paint::presets::builtin(), presets_rev: 0, mixer: Default::default(), diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index c4d858b1..d5a1cd62 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -71,6 +71,11 @@ choice!( /// opacity contour), Full Size Brush Tip (the whole diameter). PaintingCursor { Standard = "standard", Precise = "precise", NormalTip = "normalTip", FullSizeTip = "fullSizeTip" } default NormalTip ); +choice!( + /// Right mouse button on the canvas with a painting tool: open the Brush Preset picker at the + /// pointer (Photoshop), or erase with the current brush while dragging (Krita, Paint). + RightClickPaint { BrushPicker = "brushPicker", Erase = "erase" } default BrushPicker +); choice!(OtherCursor { Standard = "standard", Precise = "precise" } default Standard); choice!(CheckerSize { None = "none", Small = "small", Medium = "medium", Large = "large" } default Medium); choice!(CheckerColors { Light = "light", Medium = "medium", Dark = "dark", Red = "red", Orange = "orange", Green = "green", Blue = "blue", Purple = "purple", Custom = "custom" } default Light); @@ -254,6 +259,8 @@ pub struct Tools { pub show_transformation_values: bool, pub overscroll: bool, pub double_click_layer_mask_launches_select_and_mask: bool, + /// What the right mouse button does on the canvas with the Brush and other painting tools. + pub right_click_with_painting_tools: RightClickPaint, } impl Default for Tools { @@ -268,6 +275,7 @@ impl Default for Tools { show_transformation_values: true, overscroll: true, double_click_layer_mask_launches_select_and_mask: true, + right_click_with_painting_tools: RightClickPaint::BrushPicker, } } } @@ -758,6 +766,7 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> { "export.metadata" => ExportMetadata::NAMES, "cursors.painting" => PaintingCursor::NAMES, "cursors.other" => OtherCursor::NAMES, + "tools.rightClickWithPaintingTools" => RightClickPaint::NAMES, "transparencyAndGamut.gridSize" => CheckerSize::NAMES, "transparencyAndGamut.gridColors" => CheckerColors::NAMES, "unitsAndRulers.rulers" | "guidesGridAndSlices.gridUnit" => Unit::NAMES, diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 71d95554..f4de6d54 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -187,3 +187,20 @@ fn choice_and_range_tables_cover_enum_fields() { } } } + +#[test] +fn right_click_with_painting_tools_pref() { + let mut s = session(); + assert_eq!(s.prefs().tools.right_click_with_painting_tools, RightClickPaint::BrushPicker, "Photoshop: the Brush Preset picker"); + assert_eq!(s.execute("prefs.get", json!({"path": "tools.rightClickWithPaintingTools"})).unwrap(), json!("brushPicker")); + assert_eq!(choices("tools.rightClickWithPaintingTools"), Some(RightClickPaint::NAMES)); + s.execute("prefs.set", json!({"path": "tools.rightClickWithPaintingTools", "value": "erase"})).unwrap(); + assert_eq!(s.prefs().tools.right_click_with_painting_tools, RightClickPaint::Erase); + for bad in [json!("smudge"), json!(1), json!(null), json!(["erase"])] { + assert!(s.execute("prefs.set", json!({"path": "tools.rightClickWithPaintingTools", "value": bad})).is_err()); + } + assert_eq!(s.prefs().tools.right_click_with_painting_tools, RightClickPaint::Erase, "rejected values change nothing"); + // Preferences saved before the option existed load with the default. + let old: Tools = serde_json::from_value(json!({"showTooltips": false})).unwrap(); + assert_eq!(old.right_click_with_painting_tools, RightClickPaint::BrushPicker); +} diff --git a/crates/engine/src/preset_import_cmds.rs b/crates/engine/src/preset_import_cmds.rs index e65b310d..44a7f9a7 100644 --- a/crates/engine/src/preset_import_cmds.rs +++ b/crates/engine/src/preset_import_cmds.rs @@ -80,7 +80,7 @@ fn import_abr(s: &mut Session, p: &Value) -> Result { && let Some(first) = names.first() && let Some(pr) = photocraft_paint::presets::find(&s.tools.presets, first) { - s.tools.brush = pr.brush.clone(); + s.tools.brush = pr.brush.clone().picked_over(&s.tools.brush); } Ok(json!({ "group": group, "imported": names, "count": names.len(), "version": imp.version, "warnings": imp.warnings })) } diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 9cab6c21..5119c23f 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -842,6 +842,8 @@ fn path_stroke(s: &mut Session, p: &Value) -> Result { erase: tool == "eraser", ..base }; + // The path is the exact geometry: stroke smoothing (a hand-drawing aid) would cut its corners. + brush.smoothing.amount = 0.0; match tool { "pencil" => brush.hardness = 1.0, "brush" | "eraser" => {} diff --git a/crates/engine/tests/brush_smoothing.rs b/crates/engine/tests/brush_smoothing.rs new file mode 100644 index 00000000..bb624daa --- /dev/null +++ b/crates/engine/tests/brush_smoothing.rs @@ -0,0 +1,22 @@ +//! Smoothing is a tool option: the Brush starts at Photoshop's 10 %, and picking a brush preset +//! keeps the current smoothing (amount and mode) instead of taking the preset's. + +use photocraft_engine::Session; +use serde_json::json; + +#[test] +fn presets_keep_the_tool_smoothing() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + assert_eq!(s.tools.brush.smoothing.amount, 0.1, "Photoshop's default"); + let preset = s.tools.presets.iter().find(|p| p.brush.smoothing.amount == 0.0).map(|p| p.name.clone()).unwrap(); + let smoothed = s.tools.presets.iter().find(|p| p.brush.smoothing.amount > 0.2).map(|p| p.name.clone()).unwrap(); + s.execute("tools.setBrush", json!({"preset": preset})).unwrap(); + assert_eq!(s.tools.brush.smoothing.amount, 0.1); + s.execute("tools.setBrush", json!({"brush": {"smoothing": {"amount": 0.6, "pulledString": true}}})).unwrap(); + s.execute("tools.setBrush", json!({"preset": smoothed})).unwrap(); + assert_eq!((s.tools.brush.smoothing.amount, s.tools.brush.smoothing.pulled_string), (0.6, true)); + // An explicit smoothing alongside the preset still applies. + s.execute("tools.setBrush", json!({"preset": preset, "brush": {"smoothing": {"amount": 0.0}}})).unwrap(); + assert_eq!(s.tools.brush.smoothing.amount, 0.0); +} diff --git a/crates/engine/tests/eraser_locked.rs b/crates/engine/tests/eraser_locked.rs new file mode 100644 index 00000000..665d4d97 --- /dev/null +++ b/crates/engine/tests/eraser_locked.rs @@ -0,0 +1,110 @@ +//! Eraser on the Background (or any transparency-locked layer) paints the background colour +//! (Photoshop); on a normal layer it erases to transparency (#76). + +use photocraft_engine::Session; +use serde_json::{Value, json}; + +fn doc(mode: &str, depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "mode": mode, "depth": depth})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#000000", "background": "#ff0000"})).unwrap(); + s +} + +fn layer_px(s: &Session, x: i32, y: i32) -> [f32; 4] { + let st = s.active().unwrap(); + let l = st.active_layer.and_then(|id| st.doc.layer(id)).unwrap(); + l.surface().unwrap().rgba(x, y) +} + +fn erase(s: &mut Session, extra: Value) -> photocraft_engine::Result { + let mut p = json!({"points": [[2, 10], [38, 10]], "size": 8, "hardness": 1.0, "erase": true}); + if let (Some(o), Some(e)) = (p.as_object_mut(), extra.as_object()) { + o.extend(e.clone()); + } + s.execute("paint.stroke", p) +} + +fn close(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.02) +} + +fn undo_depth(s: &Session) -> usize { + s.active().unwrap().history.past_len() +} + +#[test] +fn eraser_on_background_paints_background_colour_at_every_depth_and_mode() { + for mode in ["rgb", "cmyk", "gray"] { + for depth in [8, 16, 32] { + let mut s = doc(mode, depth); + let before = layer_px(&s, 20, 25); + let undo = undo_depth(&s); + erase(&mut s, json!({})).unwrap_or_else(|e| panic!("{mode}/{depth}: {e}")); + let got = layer_px(&s, 20, 10); + assert_eq!(got[3], 1.0, "{mode}/{depth}: the Background stays opaque"); + // Exactly what the Brush paints with the background colour (CMYK and Gray documents + // hold the colour converted into their mode). + let mut brush = doc(mode, depth); + brush.execute("paint.stroke", json!({"points": [[2, 10], [38, 10]], "size": 8, "hardness": 1.0, "color": "#ff0000"})).unwrap(); + assert!(close(got, layer_px(&brush, 20, 10)), "{mode}/{depth}: got {got:?}"); + assert!(!close(got, before), "{mode}/{depth}: the stroke shows"); + if mode == "rgb" { + assert!(close(got, [1.0, 0.0, 0.0, 1.0]), "{mode}/{depth}: got {got:?}"); + } + assert!(close(layer_px(&s, 20, 25), before), "{mode}/{depth}: outside the stroke untouched"); + assert_eq!(undo_depth(&s), undo + 1, "{mode}/{depth}: one undo step"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(close(layer_px(&s, 20, 10), before), "{mode}/{depth}: undo restores"); + } + } +} + +#[test] +fn eraser_on_background_respects_the_selection() { + let mut s = doc("rgb", 8); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap(); + erase(&mut s, json!({})).unwrap(); + assert!(close(layer_px(&s, 10, 10), [1.0, 0.0, 0.0, 1.0])); + assert!(close(layer_px(&s, 30, 10), [1.0, 1.0, 1.0, 1.0]), "outside the selection stays white"); +} + +#[test] +fn eraser_on_normal_layer_erases_to_transparency() { + for depth in [8, 16, 32] { + let mut s = doc("rgb", depth); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("paint.stroke", json!({"points": [[2, 10], [38, 10]], "size": 12, "hardness": 1.0, "color": "#00ff00"})).unwrap(); + assert_eq!(layer_px(&s, 20, 10)[3], 1.0); + erase(&mut s, json!({})).unwrap(); + assert_eq!(layer_px(&s, 20, 10)[3], 0.0, "{depth}: erased to transparency"); + } +} + +#[test] +fn eraser_on_transparency_locked_layer_paints_background_colour() { + let mut s = doc("rgb", 16); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("paint.stroke", json!({"points": [[2, 10], [38, 10]], "size": 12, "hardness": 1.0, "color": "#00ff00"})).unwrap(); + s.execute("layer.lockLayers", json!({"transparency": true})).unwrap(); + erase(&mut s, json!({})).unwrap(); + assert!(close(layer_px(&s, 20, 10), [1.0, 0.0, 0.0, 1.0]), "{:?}", layer_px(&s, 20, 10)); + assert_eq!(layer_px(&s, 20, 25)[3], 0.0, "transparent pixels stay transparent"); +} + +#[test] +fn eraser_bad_params_fail_gracefully() { + let mut s = doc("rgb", 8); + assert!(s.execute("paint.stroke", json!({"erase": true})).is_err()); + assert!(s.execute("paint.stroke", json!({"erase": true, "points": []})).is_err()); + assert!(s.execute("paint.stroke", json!({"erase": true, "points": "x"})).is_err()); + assert!(erase(&mut s, json!({"layer": 99999})).is_err()); + assert!(erase(&mut s, json!({"target": "mask"})).is_err(), "no mask"); + // Absurd coordinates are refused up front (a stroke that long would run billions of dabs). + assert!(erase(&mut s, json!({"points": [[0, 0], [f64::MAX, 0.0]]})).is_err()); + assert!(erase(&mut s, json!({"points": [[1e30, -1e30]]})).is_err()); + assert!(s.execute("paint.pencil", json!({"erase": true, "points": [[0, 0], [1e12, 0]]})).is_err()); + // Degenerate brushes must not panic. + let _ = erase(&mut s, json!({"size": -5})); + assert!(erase(&mut s, json!({"brush": {"size": "big"}})).is_err()); +} diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index 10bc9886..aca84757 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -417,6 +417,14 @@ impl Default for BrushSettings { } impl BrushSettings { + /// This brush (a preset) picked while `current` is the tool's brush: Smoothing is a tool + /// option, so it stays the tool's (Photoshop), and a protected texture stays too. + pub fn picked_over(self, current: &BrushSettings) -> Self { + let mut b = self.with_protected_texture(current); + b.smoothing = current.smoothing.clone(); + b + } + /// Apply Protect Texture: if `current` protects its texture, a newly chosen brush with a texture /// keeps `current`'s pattern and scale. pub fn with_protected_texture(mut self, current: &BrushSettings) -> Self { diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index d591c281..f79fda87 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -615,3 +615,26 @@ fn protect_texture_keeps_pattern() { assert_eq!(next.texture.mode, canvas.texture.mode); assert_eq!(canvas.clone().with_protected_texture(&chalk).texture.pattern, canvas.texture.pattern); } + +#[test] +fn tail_preview_shows_the_stroke_as_finishing_it_would() { + // Live previews draw the smoothing catch-up tail before the stroke ends: compositing the + // tail preview over the incremental composite gives exactly the finished stroke. + let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U16, true); + let b = BrushSettings { size: 9.0, smoothing: Smoothing { amount: 0.7, ..Default::default() }, ..brush() }; + let pts: Vec = (0..12).map(|i| StrokePoint::new(5.0 + i as f64 * 9.0, 20.0 + (i % 3) as f64 * 7.0, 1.0)).collect(); + let pre = Surface::new(fmt); + let mut live = pre.clone(); + let mut r = StrokeRenderer::new(&b, Some(fmt), 1.0); + r.push(&pts); + r.composite(&pre, &mut live, None, false, false); + let mut tail = r.tail_preview().expect("the brush lags: there is a tail"); + tail.composite(&pre, &mut live, None, false, false); + let mut done = pre.clone(); + render_stroke(&mut done, &b, &pts, None, false, 1.0); + assert!((0..60).all(|y| (0..120).all(|x| live.rgba(x, y) == done.rgba(x, y)))); + // Without smoothing (and past the first dab) finishing adds nothing. + let mut r = StrokeRenderer::new(&brush(), Some(fmt), 1.0); + r.push(&pts); + assert!(r.tail_preview().is_none()); +} diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 588836cc..3cee715d 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -441,6 +441,67 @@ impl StrokeRenderer { self.raster_pending(); } + /// What finishing the stroke now would add (the smoothing catch-up tail to the last point, or + /// a lone first dab), for live previews: a renderer holding copies of the coverage tiles the + /// tail touches with the tail rendered in, so its `composite` draws exactly those tiles as + /// [`finish`](Self::finish) would leave them. `None` when finishing adds nothing. + pub fn tail_preview(&self) -> Option { + let mut generator = self.generator.clone(); + let (mut dabs, mut duals) = (Vec::new(), Vec::new()); + generator.finish(&mut dabs, &mut duals); + if dabs.is_empty() && duals.is_empty() { + return None; + } + let mut keys = HashSet::new(); + for r in dabs.iter().map(|d| self.ctx.dab_rect(d, false)).chain(duals.iter().map(|d| self.ctx.dab_rect(d, true))) { + if r.is_empty() { + continue; + } + for ty in r.y0.div_euclid(COV_TILE)..=(r.y1 - 1).div_euclid(COV_TILE) { + for tx in r.x0.div_euclid(COV_TILE)..=(r.x1 - 1).div_euclid(COV_TILE) { + keys.insert((tx, ty)); + } + } + } + let subset = |m: &CoverageMap| CoverageMap { + tiles: keys.iter().filter_map(|k| m.tiles.get(k).map(|t| (*k, t.clone()))).collect(), + nc: m.nc, + bounds: m.bounds, + dirty: HashSet::new(), + }; + let mut t = StrokeRenderer { + ctx: self.ctx.clone(), + generator, + cov: subset(&self.cov), + dual: self.dual.as_ref().map(subset), + fmt: self.fmt, + per_dab_color: self.per_dab_color, + dabs_done: self.dabs_done, + scratch: Vec::new(), + dab_buf: dabs, + dual_buf: duals, + all_dabs: None, + }; + t.raster_pending(); + // Tiles whose coverage the tail doesn't change still composite the same: redraw them all. + t.cov.dirty.extend(t.cov.tiles.keys().copied()); + Some(t) + } + + /// Mark the coverage tiles over `r` for the next `composite` (e.g. to redraw over a preview). + pub fn mark_dirty(&mut self, r: Rect) { + if r.is_empty() { + return; + } + for ty in r.y0.div_euclid(COV_TILE)..=(r.y1 - 1).div_euclid(COV_TILE) { + for tx in r.x0.div_euclid(COV_TILE)..=(r.x1 - 1).div_euclid(COV_TILE) { + if self.cov.tiles.contains_key(&(tx, ty)) { + self.cov.dirty.insert((tx, ty)); + } + } + } + } + /// Final stroke coverage at a pixel (stroke-level masks applied, before opacity/selection). pub fn coverage_at(&self, x: i32, y: i32) -> f32 { let c = self.cov.get(x, y); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 614748a7..a68019da 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -37,6 +37,9 @@ pub struct Drag { pub start: [f64; 2], pub points: Vec<[f64; 3]>, pub modifiers: egui::Modifiers, + /// A Brush/Eraser stroke that erases: the Eraser, or a right-button Brush drag with + /// Preferences › Tools › Right-click with painting tools set to Erase. + pub erase: bool, } /// A Brush/Eraser stroke shown while it is drawn: the engine renders the real dabs onto a copy of @@ -71,16 +74,17 @@ fn live_stroke(app: &PhotocraftApp, idx: usize) -> Option<&LiveStroke> { app.live_stroke.as_ref().filter(|l| app.drag.is_some() && l.doc == st.doc.id && l.revision == st.revision) } -/// `paint.stroke` params for a Brush/Eraser drag (shared by the live preview and the commit). -fn stroke_params(app: &PhotocraftApp, tool: Tool, points: &[Vec]) -> serde_json::Value { - json!({ "points": points, "erase": tool == Tool::Eraser, "smoothing": 0.3, "target": paint_target(app) }) +/// `paint.stroke` params for a Brush/Eraser drag (shared by the live preview and the commit). The +/// stroke smoothing is the session brush's (the options bar's Smoothing %). +fn stroke_params(app: &PhotocraftApp, erase: bool, points: &[Vec]) -> serde_json::Value { + json!({ "points": points, "erase": erase, "zoom": app.current_zoom(), "target": paint_target(app) }) } -fn begin_live_stroke(app: &PhotocraftApp, tool: Tool) -> Option { +fn begin_live_stroke(app: &PhotocraftApp) -> Option { static STROKES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let st = app.session.active()?; let d = app.drag.as_ref()?; - let stroke = photocraft_engine::brush_cmds::LiveStroke::begin(&app.session, &stroke_params(app, tool, &app.stylus.stroke_points(&d.points))).ok()?; + let stroke = photocraft_engine::brush_cmds::LiveStroke::begin(&app.session, &stroke_params(app, d.erase, &app.stylus.stroke_points(&d.points))).ok()?; let n = STROKES.fetch_add(1, std::sync::atomic::Ordering::Relaxed) & 0xff_ffff; let damage = vec![stroke.bounds()]; Some(LiveStroke { stroke, doc: st.doc.id, revision: st.revision, key: (1 << 44) | (n << 20), damage, fed: d.points.len() }) @@ -1107,25 +1111,29 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: view.center[1] -= d.y / view.zoom; } else if primary { let mods = ui.input(|i| i.modifiers); - if response.drag_started() + // Tools follow the left button; the right one opens the Brush Preset picker or erases + // (Preferences › Tools, `paint_mouse`). + crate::paint_mouse::sync_tool_smoothing(app); + let buttons = crate::paint_mouse::canvas_buttons(app, &response, tool); + if buttons.started && let Some(p) = response.interact_pointer_pos() { let d = xf.to_doc(p); tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, mods); } - if response.dragged() + if buttons.dragged && let Some(p) = response.interact_pointer_pos() { let d = xf.to_doc(p); tool_event(app, ToolEvent::Move { x: d[0], y: d[1], pressure: app.stylus.pressure() }, mods); } - if response.drag_stopped() { + if buttons.stopped { let p = response.interact_pointer_pos().map(|p| xf.to_doc(p)).or_else(|| app.drag.as_ref().and_then(|d| d.points.last().map(|q| [q[0], q[1]]))); if let Some(d) = p { tool_event(app, ToolEvent::Up { x: d[0], y: d[1] }, mods); } } - if response.clicked() + if buttons.clicked && let Some(p) = response.interact_pointer_pos() { let d = xf.to_doc(p); @@ -1154,6 +1162,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } draw_drag_preview(app, &painter, &xf); draw_transform_controls(app, &painter, &xf); + crate::paint_mouse::show_picker(app, &ctx); crate::snap_ui::draw(app, &painter, &xf); if border == photocraft_engine::prefs::CanvasBorder::Line { painter.rect_stroke(img_rect, 0.0, Stroke::new(1.0, Color32::from_gray(20)), egui::StrokeKind::Outside); @@ -1571,9 +1580,11 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) Tool::Type if crate::type_tool::pointer_down(app, x, y, mods.shift) => return, _ => {} } - app.drag = Some(Drag { tool, start: [x, y], points: vec![[x, y, pressure as f64]], modifiers: mods }); + crate::paint_mouse::sync_tool_smoothing(app); + let erase = tool == Tool::Eraser || std::mem::take(&mut app.secondary_erase); + app.drag = Some(Drag { tool, start: [x, y], points: vec![[x, y, pressure as f64]], modifiers: mods, erase }); app.stylus.begin_stroke(); - app.live_stroke = if matches!(tool, Tool::Brush | Tool::Eraser) { begin_live_stroke(app, tool) } else { None }; + app.live_stroke = if matches!(tool, Tool::Brush | Tool::Eraser) { begin_live_stroke(app) } else { None }; } ToolEvent::Move { x, y, pressure } => { if tool == Tool::Type && app.drag.is_none() { @@ -1621,7 +1632,7 @@ fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { Tool::Type => crate::type_tool::pointer_up(app, d.start, [end[0], end[1]]), Tool::Brush | Tool::Eraser => { let live = app.live_stroke.take(); - let mut p = stroke_params(app, d.tool, &app.stylus.stroke_points(&d.points)); + let mut p = stroke_params(app, d.erase, &app.stylus.stroke_points(&d.points)); if let Some(l) = &live { p["seed"] = json!(l.stroke.seed); } @@ -1879,6 +1890,94 @@ mod tests { assert!((0..90).all(|y| (0..700).all(|x| a.rgba(x, y) == b.rgba(x, y))), "commit matches the preview"); } + /// Brush drag along y = 40 with one canvas frame per pointer move; returns the document the + /// canvas showed at the last move, the committed one, and whether the release refreshed only + /// the stroke's rectangle. + fn drag_frames(smoothing: f32, xs: &[f64]) -> (std::sync::Arc, std::sync::Arc, bool, PhotocraftApp) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 200, "height": 80, "background": "transparent"})).unwrap(); + app.run("tools.setBrush", json!({"brush": {"size": 16, "hardness": 0.5}})).unwrap(); + // What the options bar's Smoothing field writes. + app.session.tools.brush.smoothing.amount = smoothing; + app.ui.tool = Tool::Brush; + ensure_texture(&mut app, &ctx, 0); + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 40.0, pressure: 1.0 }, m); + for &x in xs { + tool_event(&mut app, ToolEvent::Move { x, y: 40.0 + (x / 7.0).sin() * 8.0, pressure: 1.0 }, m); + ensure_texture(&mut app, &ctx, 0); + } + let live = display_doc(&mut app, 0).0; + let last = *xs.last().unwrap(); + tool_event(&mut app, ToolEvent::Up { x: last, y: 40.0 + (last / 7.0).sin() * 8.0 }, m); + let (next, key) = display_doc(&mut app, 0); + assert_eq!(key, 0, "the frame after release shows the committed document"); + ensure_texture(&mut app, &ctx, 0); + let partial = app.perf.last_refresh == "rect"; + (live, next, partial, app) + } + + fn same_pixels(a: &Document, b: &Document) -> bool { + let (a, b) = (a.layers[0].surface().unwrap(), b.layers[0].surface().unwrap()); + (0..80).all(|y| (0..200).all(|x| a.rgba(x, y) == b.rgba(x, y))) + } + + #[test] + fn release_shows_nothing_new_without_smoothing() { + // #73: at 0 % the stroke follows the pointer; the last frame drawn while dragging is + // exactly the committed stroke, so release changes nothing on screen. + let (live, done, partial, app) = drag_frames(0.0, &[40.0, 70.0, 100.0, 130.0, 160.0]); + assert!(same_pixels(&live, &done), "preview at the last move = committed stroke"); + assert!(partial, "the handover refreshes only the stroke"); + let p = app.session.journal.iter().rev().find(|(id, _)| id == "paint.stroke").map(|(_, p)| p.clone()).unwrap(); + assert!(p.get("smoothing").is_none(), "the commit uses the session brush's smoothing, not a hard-coded one"); + } + + #[test] + fn smoothed_stroke_shows_its_catch_up_tail_while_drawing() { + // #73: with smoothing the brush lags behind the pointer and catches up at the end; the + // preview draws that tail live, so no frame after release is missing the end. + let xs = [30.0, 50.0, 70.0, 90.0, 110.0, 130.0, 150.0, 170.0]; + let (live, done, partial, _) = drag_frames(0.5, &xs); + assert!(same_pixels(&live, &done), "preview at the last move = committed stroke, tail included"); + assert!(partial); + let end = |d: &Document| (0..200).rev().find(|&x| (0..80).any(|y| d.layers[0].surface().unwrap().rgba(x, y)[3] > 0.0)).unwrap(); + assert!(end(&live) >= 170, "the end reaches the pointer while drawing ({})", end(&live)); + // The options-bar value reaches the stroke: 50 % smooths the wiggle, 0 % doesn't. + let (_, rough, _, _) = drag_frames(0.0, &xs); + assert!(!same_pixels(&rough, &done), "smoothing changes the stroke"); + // And the commit is what `paint.stroke` gives with that smoothing. + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 200, "height": 80, "background": "transparent"})).unwrap(); + s.execute("tools.setBrush", json!({"brush": {"size": 16, "hardness": 0.5, "smoothing": {"amount": 0.5}}})).unwrap(); + let mut pts = vec![json!([10.0, 40.0, 1.0])]; + pts.extend(xs.iter().map(|&x| json!([x, 40.0 + (x / 7.0).sin() * 8.0, 1.0]))); + // (Round tips without dynamics draw the same for any seed.) + s.execute("paint.stroke", json!({"points": pts, "seed": 0})).unwrap(); + assert!(same_pixels(&s.active().unwrap().doc, &done)); + } + + #[test] + fn smoothing_preview_redraws_the_tail_each_step() { + // The tail drawn at one step must not linger once the brush moves on: a sharp turn would + // leave a stale tail behind if it weren't restored. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 120, "background": "transparent"})).unwrap(); + app.run("tools.setBrush", json!({"brush": {"size": 10, "hardness": 1.0, "smoothing": {"amount": 0.6}}})).unwrap(); + app.ui.tool = Tool::Brush; + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 20.0, pressure: 1.0 }, m); + for (x, y) in [(60.0, 20.0), (110.0, 20.0), (110.0, 70.0), (110.0, 110.0), (60.0, 110.0)] { + tool_event(&mut app, ToolEvent::Move { x, y, pressure: 1.0 }, m); + } + let live = display_doc(&mut app, 0).0; + tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 110.0 }, m); + let done = app.session.documents()[0].doc.clone(); + let (a, b) = (live.layers[0].surface().unwrap(), done.layers[0].surface().unwrap()); + assert!((0..120).all(|y| (0..200).all(|x| a.rgba(x, y) == b.rgba(x, y))), "no stale tails in the preview"); + } + #[test] fn layer_style_dialog_previews_live_and_cancel_restores() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8c612df5..66b09c90 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -11,7 +11,7 @@ //! - `ui.menu.invoke {id}` / `ui.menu.list`: activate a menu item by id; list the menu tree //! - `ui.dialog.open {kind, fields?}` (kinds: newDocument, about, layerStyle {effect?}, colorPicker {target: foreground|background}, command {command}) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog}` / `ui.dialog.cancel {dialog}` //! - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows -//! - `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], modifiers?}`: drive the active tool in document coordinates +//! - `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], modifiers?, button?}`: drive the active tool in document coordinates (`button: "secondary"` = the right button: opens the Brush Preset picker, or erases with Preferences › Tools › Right-click with painting tools = erase) //! - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points //! - `ui.key {key, command?, shift?, alt?, ctrl?}` / `ui.type {text}`: synthetic keyboard input //! - `ui.resize {width, height}`: resize the main window @@ -268,6 +268,9 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest "up" => ToolEvent::Up { x, y }, _ => ToolEvent::Move { x, y, pressure: pr }, }; + if matches!(s("button"), Some("secondary" | "right")) && !crate::paint_mouse::pointer_secondary(app, matches!(ev, ToolEvent::Down { .. })) { + continue; + } // A simulated pen: tilt/rotation reach the stroke like a real stylus's (see `stylus`). let tilt = |k: &str| e.get(k).and_then(Value::as_f64).map(|v| v as f32); let pen = (tilt("tiltX"), tilt("tiltY"), tilt("rotation")); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9ed7d808..2a4e5683 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -48,6 +48,7 @@ pub mod menus; pub mod new_doc_ui; pub mod notices; pub mod outline; +pub mod paint_mouse; pub mod palette; pub mod panels; pub mod parity; @@ -190,6 +191,8 @@ pub struct PhotocraftApp { drag: Option, /// Brush/Eraser stroke being drawn, rendered by the engine (see `canvas::LiveStroke`). live_stroke: Option, + /// The next tool `Down` is a right-button drag that erases (see `paint_mouse`). + secondary_erase: bool, control_rx: Option>, pending_screenshots: Vec<(u64, Option, Sender)>, /// Screenshots not yet requested from the viewport: (token, earliest time in ms, frames seen). @@ -285,6 +288,7 @@ impl PhotocraftApp { checker: None, drag: None, live_stroke: None, + secondary_erase: false, control_rx: None, pending_screenshots: Vec::new(), queued_screenshots: Vec::new(), diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs new file mode 100644 index 00000000..76ede892 --- /dev/null +++ b/crates/ui-egui/src/paint_mouse.rs @@ -0,0 +1,336 @@ +//! Mouse buttons on the canvas. Tools follow the left button. The right button never paints with +//! the tool: with a painting tool it opens the Brush Preset picker at the pointer (Photoshop), or, +//! with Preferences › Tools › Right-click with painting tools set to Erase, a right drag with the +//! Brush erases with the current brush (Krita, Paint). Each right stroke is one `paint.stroke` +//! with `"erase": true`, so one undo step, with the pen pressure and tilt of a normal stroke. + +use egui::{PointerButton, Response}; +use photocraft_engine::prefs::RightClickPaint; + +use crate::PhotocraftApp; +use crate::state::Tool; + +/// Smoothing is a tool option, kept per tool like Photoshop's options bar: switching between the +/// Brush and the Eraser saves the session brush's smoothing for the old tool and loads the new +/// tool's (Photoshop's 10 % the first time). Other tools leave it alone. +pub fn sync_tool_smoothing(app: &mut PhotocraftApp) { + let tool = app.ui.tool; + if !matches!(tool, Tool::Brush | Tool::Eraser) || app.ui.smoothing_tool == Some(tool) { + return; + } + let current = app.session.tools.brush.smoothing.clone(); + let next = match app.ui.smoothing_tool { + // The first smoothing tool takes what the brush has. + None => current, + Some(prev) => { + let saved = &mut app.ui.tool_smoothing; + match saved.iter_mut().find(|(t, _)| *t == prev) { + Some(e) => e.1 = current, + None => saved.push((prev, current)), + } + saved + .iter() + .find(|(t, _)| *t == tool) + .map(|(_, s)| s.clone()) + .unwrap_or_else(|| photocraft_engine::paint::brush::Smoothing { amount: 0.1, ..Default::default() }) + } + }; + app.session.tools.brush.smoothing = next; + app.ui.smoothing_tool = Some(tool); +} + +/// Which tool events this frame's canvas response produces. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Buttons { + pub started: bool, + pub dragged: bool, + pub stopped: bool, + pub clicked: bool, +} + +/// Tools whose right-click opens the Brush Preset picker (those with the options-bar brush chip). +pub fn has_brush_picker(tool: Tool) -> bool { + tool.is_brushlike() || tool == Tool::QuickSelection +} + +/// Does a right-button drag with `tool` erase (rather than open the picker)? +pub fn right_erases(app: &PhotocraftApp, tool: Tool) -> bool { + matches!(tool, Tool::Brush | Tool::Eraser) && app.session.prefs().tools.right_click_with_painting_tools == RightClickPaint::Erase +} + +/// Route the canvas response's buttons: the left one drives the tool; the right one erases (Erase +/// preference) or opens the Brush Preset picker. Arms `secondary_erase` for this frame's `Down`. +pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool) -> Buttons { + let erase = right_erases(app, tool); + let right_stroke = erase && app.drag.is_some(); + let right_start = erase && response.drag_started_by(PointerButton::Secondary); + let right_click = response.secondary_clicked(); + if right_click + && !erase + && has_brush_picker(tool) + && let Some(p) = response.interact_pointer_pos() + { + app.ui.brush_picker = Some([p.x, p.y]); + } + let erase_click = erase && right_click; + app.secondary_erase = right_start || erase_click; + Buttons { + started: response.drag_started_by(PointerButton::Primary) || right_start, + dragged: response.dragged_by(PointerButton::Primary) || (right_stroke && response.dragged_by(PointerButton::Secondary)), + stopped: response.drag_stopped_by(PointerButton::Primary) || (right_stroke && response.drag_stopped_by(PointerButton::Secondary)), + clicked: response.clicked() || erase_click, + } +} + +/// `ui.pointer` with `"button": "secondary"`: true when its events should reach the tool (an +/// erasing right stroke; `secondary_erase` is armed for its `Down`). Otherwise a right-click with +/// a painting tool opens the Brush Preset picker over the canvas, and nothing paints. +pub fn pointer_secondary(app: &mut PhotocraftApp, down: bool) -> bool { + let tool = app.ui.tool; + if right_erases(app, tool) { + app.secondary_erase = down; + return true; + } + if down && has_brush_picker(tool) { + let c = app.last_canvas_rect.center(); + app.ui.brush_picker = Some([c.x, c.y]); + } + false +} + +/// The Brush Preset picker a right-click opened, at the pointer. It edits the session brush like +/// the options-bar chip's; a click outside, Escape or Enter closes it. +pub fn show_picker(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some([x, y]) = app.ui.brush_picker else { return }; + if !has_brush_picker(app.ui.tool) || ctx.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Enter)) { + app.ui.brush_picker = None; + return; + } + let screen = ctx.content_rect(); + // Keep the whole picker on screen (it is about 280 × 300 points). + let pos = egui::pos2(x.min(screen.right() - 284.0).max(screen.left()), y.min(screen.bottom() - 310.0).max(screen.top())); + let area = egui::Area::new(egui::Id::new("canvas-brush-picker")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| crate::panels::brush_picker_body(ui, &mut app.session.tools.brush)); + }); + let outside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !area.response.rect.contains(p))); + if outside { + app.ui.brush_picker = None; + } +} + +#[cfg(test)] +mod tests { + use egui::{Modifiers, PointerButton, Pos2, vec2}; + use egui_kittest::Harness; + use serde_json::json; + + use super::*; + use crate::canvas::{ToolEvent, tool_event}; + + fn harness(prefs: Option<&str>) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("tools.setColors", json!({"foreground": "#0000ff"})).unwrap(); + app.run("tools.setBrush", json!({"brush": {"size": 20, "hardness": 1.0}})).unwrap(); + if let Some(v) = prefs { + app.run("prefs.set", json!({"path": "tools.rightClickWithPaintingTools", "value": v})).unwrap(); + } + app.ui.tool = Tool::Brush; + app.sync_views(); + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + h + } + + fn press(h: &mut Harness<'static, PhotocraftApp>, pos: Pos2, button: PointerButton, pressed: bool) { + h.event(egui::Event::PointerButton { pos, button, pressed, modifiers: Modifiers::NONE }); + h.run_steps(2); + } + + /// Drag across the canvas centre with `button`; returns the document points it covered. + fn drag(h: &mut Harness<'static, PhotocraftApp>, button: PointerButton) -> (Pos2, Pos2) { + let c = h.state().last_canvas_rect.center(); + let (a, b) = (c - vec2(80.0, 0.0), c + vec2(80.0, 0.0)); + h.event(egui::Event::PointerMoved(a)); + h.run_steps(1); + press(h, a, button, true); + for i in 1..=8 { + h.event(egui::Event::PointerMoved(a + (b - a) * (i as f32 / 8.0))); + h.run_steps(1); + } + press(h, b, button, false); + (a, b) + } + + fn alpha_at(h: &Harness<'static, PhotocraftApp>, screen: Pos2) -> f32 { + let app = h.state(); + let v = app.ui.views[0].clone(); + let r = app.last_canvas_rect; + let d = (screen - r.center()) / v.zoom; + let (x, y) = ((d.x + v.center[0]) as i32, (d.y + v.center[1]) as i32); + let st = app.session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y)[3] + } + + fn strokes(h: &Harness<'static, PhotocraftApp>) -> Vec { + h.state().session.journal.iter().filter(|(id, _)| id == "paint.stroke").map(|(_, p)| p.clone()).collect() + } + + #[test] + fn left_drag_paints() { + let mut h = harness(None); + let (a, b) = drag(&mut h, PointerButton::Primary); + assert_eq!(strokes(&h).len(), 1); + assert!(alpha_at(&h, a + (b - a) * 0.5) > 0.9); + } + + #[test] + fn right_click_opens_the_brush_picker_and_never_paints() { + let mut h = harness(None); + let undo = h.state().session.active().unwrap().history.past_len(); + let c = h.state().last_canvas_rect.center(); + h.event(egui::Event::PointerMoved(c)); + h.run_steps(1); + press(&mut h, c, PointerButton::Secondary, true); + press(&mut h, c, PointerButton::Secondary, false); + assert_eq!(h.state().ui.brush_picker, Some([c.x, c.y]), "opened at the pointer"); + assert!(strokes(&h).is_empty()); + // The picker shows over the canvas, at the pointer. + h.run_steps(2); + let shown = h.ctx.memory(|m| m.area_rect(egui::Id::new("canvas-brush-picker"))).expect("picker shown"); + assert!(shown.min.distance(c) < 1.0 && shown.width() > 250.0 && shown.height() > 150.0, "{shown:?}"); + // A right drag doesn't paint either. + drag(&mut h, PointerButton::Secondary); + assert!(strokes(&h).is_empty(), "right-drag never paints with the brush picker preference"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), undo, "no edit"); + // Escape closes it. + h.state_mut().ui.brush_picker = Some([c.x, c.y]); + h.run_steps(1); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().ui.brush_picker, None); + } + + #[test] + fn click_outside_closes_the_picker() { + let mut h = harness(None); + let c = h.state().last_canvas_rect.center(); + h.state_mut().ui.brush_picker = Some([c.x, c.y]); + h.run_steps(2); + let far = c - vec2(300.0, 200.0); + h.event(egui::Event::PointerMoved(far)); + press(&mut h, far, PointerButton::Primary, true); + press(&mut h, far, PointerButton::Primary, false); + assert_eq!(h.state().ui.brush_picker, None); + } + + #[test] + fn right_drag_erases_with_the_erase_preference() { + let mut h = harness(Some("erase")); + let (a, b) = drag(&mut h, PointerButton::Primary); + let mid = a + (b - a) * 0.5; + assert!(alpha_at(&h, mid) > 0.9); + let undo = h.state().session.active().unwrap().history.past_len(); + drag(&mut h, PointerButton::Secondary); + let s = strokes(&h); + assert_eq!(s.len(), 2); + assert_eq!(s[1]["erase"], json!(true), "the right stroke erases"); + assert_eq!(alpha_at(&h, mid), 0.0, "erased to transparency"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), undo + 1, "one undo step"); + assert_eq!(h.state().ui.brush_picker, None, "no picker in erase mode"); + // The next left stroke paints again. + drag(&mut h, PointerButton::Primary); + assert_eq!(strokes(&h)[2]["erase"], json!(false)); + assert!(alpha_at(&h, mid) > 0.9); + } + + #[test] + fn right_stroke_erases_with_pen_pressure_through_the_control_channel_path() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 120, "height": 80})).unwrap(); + app.run("prefs.set", json!({"path": "tools.rightClickWithPaintingTools", "value": "erase"})).unwrap(); + app.ui.tool = Tool::Brush; + let m = Modifiers::NONE; + assert!(pointer_secondary(&mut app, true)); + tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 40.0, pressure: 0.4 }, m); + assert!(!app.secondary_erase, "armed for one stroke only"); + tool_event(&mut app, ToolEvent::Move { x: 100.0, y: 40.0, pressure: 0.8 }, m); + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 40.0 }, m); + let p = app.session.journal.iter().rev().find(|(id, _)| id == "paint.stroke").map(|(_, p)| p.clone()).unwrap(); + assert_eq!(p["erase"], json!(true)); + assert_eq!(p["points"][0], json!([10.0, 40.0, 0.4f32 as f64]), "pressure reaches the erasing stroke"); + // On the Background the erasing stroke paints the background colour (#76). + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layers[0].surface().unwrap().rgba(50, 40), [1.0, 1.0, 1.0, 1.0]); + // With the default preference the right button opens the picker and nothing paints. + app.run("prefs.set", json!({"path": "tools.rightClickWithPaintingTools", "value": "brushPicker"})).unwrap(); + assert!(!pointer_secondary(&mut app, true)); + assert!(app.ui.brush_picker.is_some() && !app.secondary_erase); + } + + #[test] + fn smoothing_is_a_per_tool_option_that_presets_keep() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + let amount = |app: &PhotocraftApp| app.session.tools.brush.smoothing.amount; + app.ui.tool = Tool::Brush; + sync_tool_smoothing(&mut app); + assert_eq!(amount(&app), 0.1, "Photoshop's default"); + // Picking a preset (whose own smoothing is 0) keeps the tool's smoothing. + let preset = app.session.tools.presets.iter().find(|p| p.brush.smoothing.amount == 0.0).map(|p| p.name.clone()).unwrap(); + app.run("tools.setBrush", json!({"preset": preset})).unwrap(); + assert_eq!(amount(&app), 0.1); + // A user value (the options-bar field) survives a preset too, mode included. + app.session.tools.brush.smoothing.amount = 0.42; + app.session.tools.brush.smoothing.pulled_string = true; + app.run("tools.setBrush", json!({"preset": preset})).unwrap(); + assert_eq!((amount(&app), app.session.tools.brush.smoothing.pulled_string), (0.42, true)); + // The Eraser has its own (10 % at first); switching back restores the Brush's. + app.ui.tool = Tool::Eraser; + sync_tool_smoothing(&mut app); + assert_eq!(amount(&app), 0.1); + app.session.tools.brush.smoothing.amount = 0.0; + app.ui.tool = Tool::Brush; + sync_tool_smoothing(&mut app); + assert_eq!((amount(&app), app.session.tools.brush.smoothing.pulled_string), (0.42, true)); + // Other tools leave it alone; the Eraser kept its 0 %. + app.ui.tool = Tool::Move; + sync_tool_smoothing(&mut app); + assert_eq!(amount(&app), 0.42); + app.ui.tool = Tool::Eraser; + // A stroke picks up the tool's smoothing even without a frame in between. + tool_event(&mut app, ToolEvent::Down { x: 5.0, y: 5.0, pressure: 1.0 }, Modifiers::NONE); + assert_eq!(amount(&app), 0.0); + tool_event(&mut app, ToolEvent::Up { x: 30.0, y: 5.0 }, Modifiers::NONE); + } + + #[test] + fn eraser_tool_on_the_background_paints_the_background_colour() { + // #76: the Eraser on the Background (locked transparency) paints the background colour. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 120, "height": 80})).unwrap(); + app.run("tools.setColors", json!({"background": "#ff0000"})).unwrap(); + app.run("tools.setBrush", json!({"brush": {"size": 12, "hardness": 1.0}})).unwrap(); + app.ui.tool = Tool::Eraser; + let m = Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 40.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 100.0, y: 40.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 40.0 }, m); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layers[0].name, "Background"); + assert_eq!(st.doc.layers[0].surface().unwrap().rgba(50, 40), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(st.history.undo_label(), Some("Eraser")); + assert!(!app.ui.status_error, "{}", app.ui.status); + } +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 0571f171..3554489d 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -372,6 +372,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { { return; } + crate::paint_mouse::sync_tool_smoothing(app); let b = &mut app.session.tools.brush; match app.ui.tool { Tool::Brush | Tool::Eraser if t.pro => { @@ -398,8 +399,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let _ = icons::button(ui, "sparkles", 24.0, false, "Enable airbrush-style build-up effects"); opt_label(ui, "Smoothing"); - let mut sm = 10.0f32; - widgets::value_field(ui, &mut sm, 0.0..=100.0, "%", 58.0); + smoothing_field(ui, b, 58.0); let _ = icons::button(ui, "settings", 24.0, false, "Set additional smoothing options"); widgets::vline(ui, 22.0); if icons::button(ui, "circle-dot", 24.0, b.pressure_size, "Always use pressure for size").clicked() { @@ -426,6 +426,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if widgets::value_field(ui, &mut f, 1.0..=100.0, "%", 66.0).changed() { b.flow = f / 100.0; } + opt_label(ui, "Smoothing"); + smoothing_field(ui, b, 66.0); widgets::vline(ui, 22.0); widgets::toggle(ui, &mut b.pressure_size, "Pressure for Size"); widgets::toggle(ui, &mut b.pressure_opacity, "Pressure for Opacity"); @@ -1953,6 +1955,14 @@ fn brush_tip(p: &egui::Painter, c: egui::Pos2, rad: f32, hardness: f32, color: C } } +/// Options-bar Smoothing % (the brush's stroke smoothing; the live stroke and the commit use it). +fn smoothing_field(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings, width: f32) { + let mut sm = (b.smoothing.amount * 100.0).round(); + if widgets::value_field(ui, &mut sm, 0.0..=100.0, "%", width).changed() { + b.smoothing.amount = (sm / 100.0).clamp(0.0, 1.0); + } +} + /// Options-bar brush chip; opens Photoshop's Brush Preset Picker (size, hardness, preset tips). fn brush_preset_chip(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings) { let t = Tokens::get(ui.ctx()); @@ -1965,77 +1975,70 @@ fn brush_preset_chip(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings ui.painter().text(pos2(c.x, r.bottom() - 5.0), Align2::CENTER_CENTER, format!("{}", b.size.round() as i64), egui::FontId::proportional(9.5), t.text_dim); icons::paint(ui, Rect::from_center_size(pos2(r.right() - 9.0, c.y), vec2(10.0, 10.0)), "chevron-down", 9.0, t.text_faint); let resp = resp.on_hover_text("Brush Preset picker"); - egui::Popup::from_toggle_button_response(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - ui.set_width(260.0); - // Size: value field plus a logarithmic slider (small sizes get most of the travel). - ui.horizontal(|ui| { - ui.label(RichText::new("Size").color(t.text_dim)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let mut size = b.size; - if widgets::value_field(ui, &mut size, 1.0..=5000.0, "px", 72.0).changed() { - b.size = size.round().clamp(1.0, 5000.0); - } - }); - }); - let mut lv = b.size.max(1.0).ln(); - if widgets::slider(ui, &mut lv, 0.0..=5000f32.ln(), None).changed() { - b.size = lv.exp().round().clamp(1.0, 5000.0); - } - let mut hard = b.hardness * 100.0; - if widgets::slider_row(ui, "Hardness", &mut hard, 0.0..=100.0, "%", None).changed() { - b.hardness = (hard / 100.0).clamp(0.0, 1.0); - } - ui.add_space(6.0); - widgets::hairline(ui); - ui.add_space(6.0); - ui.label(RichText::new("General Brushes").color(t.text_dim).size(11.5)); - // Photoshop's default round presets: soft and hard at common sizes. - let presets: [(f32, f32); 12] = [ - (1.0, 1.0), - (3.0, 1.0), - (5.0, 1.0), - (9.0, 1.0), - (13.0, 1.0), - (19.0, 1.0), - (5.0, 0.0), - (9.0, 0.0), - (13.0, 0.0), - (17.0, 0.0), - (45.0, 0.0), - (65.0, 0.0), - ]; - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(4.0, 4.0); - for (sz, hd) in presets { - let (cell, cr) = ui.allocate_exact_size(vec2(38.0, 44.0), Sense::click()); - let on = (b.size - sz).abs() < 0.5 && (b.hardness - hd).abs() < 0.01; - ui.painter().rect_filled( - cell, - 3.0, - if on { - t.accent_soft - } else if cr.hovered() { - t.hover - } else { - t.field - }, - ); - let rad = (sz / 2.0).clamp(1.0, 13.0); - brush_tip(ui.painter(), pos2(cell.center().x, cell.top() + 17.0), rad, hd, t.text); - ui.painter().text( - pos2(cell.center().x, cell.bottom() - 7.0), - Align2::CENTER_CENTER, - format!("{}", sz as i64), - egui::FontId::proportional(9.5), - t.text_dim, - ); - if cr.on_hover_text(format!("{} Round {sz:.0} px", if hd >= 1.0 { "Hard" } else { "Soft" })).clicked() { - b.size = sz; - b.hardness = hd; - } + egui::Popup::from_toggle_button_response(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| brush_picker_body(ui, b)); +} + +/// The Brush Preset picker's contents (size, hardness, preset tips): the options-bar chip's +/// popup, and the picker a right-click on the canvas opens (`paint_mouse`). +pub(crate) fn brush_picker_body(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings) { + let t = Tokens::get(ui.ctx()); + ui.set_width(260.0); + // Size: value field plus a logarithmic slider (small sizes get most of the travel). + ui.horizontal(|ui| { + ui.label(RichText::new("Size").color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut size = b.size; + if widgets::value_field(ui, &mut size, 1.0..=5000.0, "px", 72.0).changed() { + b.size = size.round().clamp(1.0, 5000.0); } }); }); + let mut lv = b.size.max(1.0).ln(); + if widgets::slider(ui, &mut lv, 0.0..=5000f32.ln(), None).changed() { + b.size = lv.exp().round().clamp(1.0, 5000.0); + } + let mut hard = b.hardness * 100.0; + if widgets::slider_row(ui, "Hardness", &mut hard, 0.0..=100.0, "%", None).changed() { + b.hardness = (hard / 100.0).clamp(0.0, 1.0); + } + ui.add_space(6.0); + widgets::hairline(ui); + ui.add_space(6.0); + ui.label(RichText::new("General Brushes").color(t.text_dim).size(11.5)); + // Photoshop's default round presets: soft and hard at common sizes. + let presets: [(f32, f32); 12] = + [(1.0, 1.0), (3.0, 1.0), (5.0, 1.0), (9.0, 1.0), (13.0, 1.0), (19.0, 1.0), (5.0, 0.0), (9.0, 0.0), (13.0, 0.0), (17.0, 0.0), (45.0, 0.0), (65.0, 0.0)]; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 4.0); + for (sz, hd) in presets { + let (cell, cr) = ui.allocate_exact_size(vec2(38.0, 44.0), Sense::click()); + let on = (b.size - sz).abs() < 0.5 && (b.hardness - hd).abs() < 0.01; + ui.painter().rect_filled( + cell, + 3.0, + if on { + t.accent_soft + } else if cr.hovered() { + t.hover + } else { + t.field + }, + ); + let rad = (sz / 2.0).clamp(1.0, 13.0); + brush_tip(ui.painter(), pos2(cell.center().x, cell.top() + 17.0), rad, hd, t.text); + ui.painter().text( + pos2(cell.center().x, cell.bottom() - 7.0), + Align2::CENTER_CENTER, + format!("{}", sz as i64), + egui::FontId::proportional(9.5), + t.text_dim, + ); + if cr.on_hover_text(format!("{} Round {sz:.0} px", if hd >= 1.0 { "Hard" } else { "Soft" })).clicked() { + b.size = sz; + b.hardness = hd; + } + } + }); } /// Drag a layer row to reorder: drop on the upper/lower half to place above/below, or on the middle diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4a402ece..8d84f900 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -528,6 +528,15 @@ pub struct UiState { /// Painting targets the active layer's mask instead of its pixels. #[serde(default)] pub mask_target: bool, + /// Brush Preset picker opened by a right-click on the canvas: its screen position (points). + #[serde(default)] + pub brush_picker: Option<[f32; 2]>, + /// Smoothing is a per-tool option (Brush and Eraser each keep theirs): the tool whose + /// smoothing the session brush holds, and the other tools' saved values. + #[serde(default)] + pub smoothing_tool: Option, + #[serde(default)] + pub tool_smoothing: Vec<(Tool, photocraft_engine::paint::brush::Smoothing)>, /// Pen path under construction. #[serde(default)] pub pen: Option, @@ -612,6 +621,9 @@ impl Default for UiState { text_edit: None, transform: None, mask_target: false, + brush_picker: None, + smoothing_tool: None, + tool_smoothing: Vec::new(), clone_source: None, clone_offset: None, extras: Extras::default(),