Painting: right-click opens the brush picker (or erases), real Smoothing, eraser-on-Background tests (#86)

* Right-click opens the Brush Preset picker (or erases), eraser-on-Background tests, README comma

- #75: the right mouse button never paints with the tool. With a painting tool it opens the
  Brush Preset picker at the pointer (Photoshop). New preference Preferences › Tools ›
  Right-click with painting tools (tools.rightClickWithPaintingTools: brushPicker | erase,
  serde default brushPicker): with erase, a right drag with the Brush erases with the current
  brush (one paint.stroke, one undo step, pen pressure/tilt as usual). ui.pointer accepts
  "button": "secondary". New module ui-egui/src/paint_mouse.rs; picker body shared with the
  options-bar chip.
- #76: the engine already paints the background colour when erasing a transparency-locked
  layer (the Background); added tests at 8/16/32-bit in RGB/CMYK/Gray, with a selection, on a
  normal layer (erases to transparency), on a transparency-locked layer, and through the UI.
- paint.stroke/pencil refuse coordinates beyond ±1e6 (an f64::MAX point ran out of memory).
- #73: test that the canvas hands the live stroke over to the committed document with no
  frame lacking the stroke, refreshing only the stroke's rect.
- #69: README comma.

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

* Brush smoothing follows the options bar; the live stroke draws the catch-up tail (#73)

- The options-bar Smoothing % (pro and classic bars, Brush and Eraser) edits the session
  brush's smoothing, which the live stroke and the committed paint.stroke both use; the
  canvas no longer forces 0.3. The session brush starts at Photoshop's 10 %.
- With smoothing the brush lags the pointer and catches up when the stroke ends. LiveStroke
  now draws that catch-up tail on every step (StrokeRenderer::tail_preview renders only the
  tiles the tail touches; the previous step's tail is restored first), so the preview at the
  last move equals the commit and nothing pops in on release. At 0 % there is no lag.
- Path › Stroke Path ignores smoothing (it strokes the exact geometry).
- Tests: paint tail_preview = finished stroke; canvas: 0 % preview = commit, 50 % preview =
  commit with the end reaching the pointer, smoothing changes the stroke, commit equals
  paint.stroke with that smoothing, no stale tails after sharp turns.

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

* Smoothing is a per-tool option that brush presets keep

- Picking a brush preset (tools.setBrush / paint.* "preset", .abr import with select) keeps the
  current smoothing amount and mode instead of the preset's (BrushSettings::picked_over),
  as Photoshop's options bar does. An explicit smoothing alongside the preset still applies.
- The Brush and the Eraser each remember their smoothing (UiState smoothing_tool /
  tool_smoothing, synced on tool switch before the options bar, the canvas and each stroke);
  a tool seen for the first time starts at 10 %.
- Tests: engine presets keep 10 % / a user value with pulled-string mode; UI per-tool
  smoothing across Brush/Eraser/other tools and on the first stroke after a switch.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-04 23:31:17 -04:00 committed by GitHub
parent 8b0ceea374
commit 7e7864afae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 830 additions and 92 deletions

View File

@ -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

View File

@ -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<Vec<StrokePoint>> {
@ -71,6 +74,10 @@ pub fn parse_points(p: &Value, cmd: &str) -> Result<Vec<StrokePoint>> {
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<BrushSettings> {
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<photocraft_doc::LayerId>,
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<Rect> {
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<Value> {
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();

View File

@ -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(),

View File

@ -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,

View File

@ -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);
}

View File

@ -80,7 +80,7 @@ fn import_abr(s: &mut Session, p: &Value) -> Result<Value> {
&& 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 }))
}

View File

@ -842,6 +842,8 @@ fn path_stroke(s: &mut Session, p: &Value) -> Result<Value> {
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" => {}

View File

@ -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);
}

View File

@ -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<Value> {
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());
}

View File

@ -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 {

View File

@ -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<StrokePoint> = (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());
}

View File

@ -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<StrokeRenderer> {
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);

View File

@ -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<f64>]) -> 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<f64>]) -> 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<LiveStroke> {
fn begin_live_stroke(app: &PhotocraftApp) -> Option<LiveStroke> {
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<Document>, std::sync::Arc<Document>, 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());

View File

@ -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"));

View File

@ -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<canvas::Drag>,
/// Brush/Eraser stroke being drawn, rendered by the engine (see `canvas::LiveStroke`).
live_stroke: Option<canvas::LiveStroke>,
/// The next tool `Down` is a right-button drag that erases (see `paint_mouse`).
secondary_erase: bool,
control_rx: Option<Receiver<ControlRequest>>,
pending_screenshots: Vec<(u64, Option<String>, Sender<ControlResponse>)>,
/// 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(),

View File

@ -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<serde_json::Value> {
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);
}
}

View File

@ -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

View File

@ -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<Tool>,
#[serde(default)]
pub tool_smoothing: Vec<(Tool, photocraft_engine::paint::brush::Smoothing)>,
/// Pen path under construction.
#[serde(default)]
pub pen: Option<crate::vector_ui::PenPath>,
@ -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(),