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:
parent
8b0ceea374
commit
7e7864afae
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" => {}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue