Compare commits

...
33 changed files with 1725 additions and 107 deletions

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@ next to them.
|---|---|---|---|---|
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg` and `eraser-magic.svg` (108 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg` and `eraser-magic.svg` (109 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,lock,minus,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
<path d="m15 5 4 4" />
</svg>

After

Width:  |  Height:  |  Size: 377 B

+43 -13
View File
@@ -222,17 +222,37 @@ fn stroke_with(s: &mut Session, p: &Value, label: &str, brush: BrushSettings, pt
let mut brush = brush;
erase_locked(&mut brush, lock, bg);
if auto_erase {
// Pencil Auto Erase: starting on foreground-coloured pixels paints the background colour.
let c = surf.rgba(pts[0].x.floor() as i32, pts[0].y.floor() as i32);
if c[3] > 0.0 && (0..3).all(|i| (c[i] - fg[i]).abs() < 1.5 / 255.0) {
brush.color = bg;
}
apply_auto_erase(&mut brush, surf, pts.first(), fg, bg);
}
Ok(render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom))
})?;
Ok(damage_json(s, dmg))
}
/// Pencil Auto Erase: a stroke that starts on a pixel of the foreground colour paints the
/// background colour instead (Photoshop).
fn apply_auto_erase(brush: &mut BrushSettings, surf: &Surface, start: Option<&StrokePoint>, fg: [f32; 4], bg: [f32; 4]) {
let Some(p0) = start else { return };
let c = surf.rgba(p0.x.floor() as i32, p0.y.floor() as i32);
if c[3] > 0.0 && (0..3).all(|i| (c[i] - fg[i]).abs() < 1.5 / 255.0) {
brush.color = bg;
}
}
/// The Pencil's brush: the session brush made aliased (every pixel fully painted or untouched,
/// dabs on the pixel grid), hard and at full flow, with the options-bar mode.
fn pencil_brush(s: &Session, p: &Value) -> Result<BrushSettings> {
let mut brush = with_blend_mode(resolve_brush(s, p, "paint.pencil")?, p);
brush.aliased = true;
if num(p, "hardness").is_none() {
brush.hardness = 1.0;
}
if num(p, "flow").is_none() {
brush.flow = 1.0;
}
Ok(brush)
}
/// Applies the options-bar blend `mode` to a brush. `"mode"` accepts any blend-mode name
/// (normal|multiply|screen|…). The Eraser has no blend mode in Photoshop, so it is forced to Normal.
fn with_blend_mode(mut b: BrushSettings, p: &Value) -> BrushSettings {
@@ -273,15 +293,29 @@ pub struct LiveStroke {
impl LiveStroke {
/// Start from `paint.stroke` params; their `points` are rendered.
pub fn begin(s: &Session, p: &Value) -> Result<Self> {
Self::begin_with(s, "paint.stroke", p)
}
/// Start a live stroke of `cmd`: `paint.stroke` (the Brush and Eraser) or `paint.pencil`.
/// Committing `cmd` with the same params and `"seed": live.seed` gives the same pixels.
pub fn begin_with(s: &Session, cmd: &str, p: &Value) -> Result<Self> {
has_paintable(s).map_err(EngineError::Other)?;
let pts = parse_points(p, "paint.stroke")?;
let brush = with_blend_mode(resolve_brush(s, p, "paint.stroke")?, p);
let seed = brush.seed;
let pts = parse_points(p, cmd)?;
let pencil = match cmd {
"paint.stroke" => false,
"paint.pencil" => true,
other => return Err(bad(other, "live strokes are `paint.stroke` or `paint.pencil`")),
};
let brush = if pencil { pencil_brush(s, p)? } else { with_blend_mode(resolve_brush(s, p, cmd)?, p) };
let (seed, fg) = (brush.seed, brush.color);
let (layer, mut brush, zoom) = stroke_target(s, p, brush)?;
let mut doc = (*s.active().ok_or(EngineError::NoDocument)?.doc).clone();
let sel = doc.selection.clone();
let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, layer, p)?;
erase_locked(&mut brush, lock, s.tools.background);
if pencil && flag(p, "autoErase", false) {
apply_auto_erase(&mut brush, surf, pts.first(), fg, 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(), tail: Rect::EMPTY };
@@ -320,11 +354,7 @@ impl LiveStroke {
fn pencil(s: &mut Session, p: &Value) -> Result<Value> {
let pts = parse_points(p, "paint.pencil")?;
let mut brush = with_blend_mode(resolve_brush(s, p, "paint.pencil")?, p);
brush.aliased = true;
if num(p, "hardness").is_none() {
brush.hardness = 1.0;
}
let brush = pencil_brush(s, p)?;
let label = if brush.erase { "Eraser" } else { "Pencil" };
stroke_with(s, p, label, brush, pts, flag(p, "autoErase", false))
}
+71
View File
@@ -312,6 +312,77 @@ fn live_stroke_matches_the_committed_stroke() {
assert!(LiveStroke::begin(&Session::new(), &json!({"points": [[1, 1]]})).is_err());
}
#[test]
fn live_pencil_matches_the_committed_pencil_and_auto_erases() {
let mut s = session(80, 40);
s.execute("tools.setColors", json!({"foreground": "#000000", "background": "#ffffff"})).unwrap();
// A soft, low-flow session brush: the Pencil is still hard, aliased and full flow.
s.execute("tools.setBrush", json!({"brush": {"size": 3, "hardness": 0.0, "flow": 0.3}})).unwrap();
let pts = [[5.0, 5.0, 1.0], [30.2, 17.7, 0.6], [70.0, 33.0, 1.0]];
for (auto, start) in [(false, pts[0]), (true, [30.4, 17.2, 1.0])] {
let p = json!({"points": [start], "autoErase": auto, "smoothing": 0.0});
let mut live = LiveStroke::begin_with(&s, "paint.pencil", &p).unwrap();
let rest: Vec<StrokePoint> = pts.iter().map(|q| StrokePoint::new(q[0], q[1], q[2] as f32)).collect();
live.push(&rest).unwrap();
let mut commit = p.clone();
let mut all = vec![start];
all.extend(pts);
commit["points"] = json!(all);
commit["seed"] = json!(live.seed);
s.execute("paint.pencil", commit).unwrap();
let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().clone();
assert!(same_pixels(&shown, &surface(&s), Rect::new(0, 0, 80, 40)), "auto erase {auto}");
for y in 0..40 {
for x in 0..80 {
let a = shown.rgba(x, y)[3];
assert!(a == 0.0 || a == 1.0, "partial alpha {a} at ({x},{y})");
}
}
}
// The second stroke started on the first one's black: Auto Erase painted white.
assert_eq!(rgba(&s, 70, 33), [1.0, 1.0, 1.0, 1.0]);
// Only the Brush and the Pencil stroke live.
assert!(LiveStroke::begin_with(&s, "paint.mixerBrush", &json!({"points": [[1, 1]]})).is_err());
assert!(LiveStroke::begin_with(&s, "paint.pencil", &json!({"points": "none"})).is_err());
}
/// Pencil latency on a 24 MP document: the time to start a live stroke, per pointer move, and
/// to commit. `cargo test -p photocraft-engine --release --lib pencil_latency -- --ignored --nocapture`
#[test]
#[ignore]
fn pencil_latency_24mp() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 6000, "height": 4000, "background": "white"})).unwrap();
for size in [1.0f64, 9.0, 40.0] {
let p = json!({"points": [[100.0, 100.0]], "size": size, "smoothing": 0.0});
let t = std::time::Instant::now();
let mut live = LiveStroke::begin_with(&s, "paint.pencil", &p).unwrap();
let begin = t.elapsed().as_secs_f64() * 1000.0;
let mut steps = Vec::new();
let mut all = vec![[100.0, 100.0]];
for i in 1..=300 {
let q = [100.0 + i as f64 * 15.0, 100.0 + (i as f64 * 0.07).sin() * 900.0 + 1000.0];
all.push(q);
let t = std::time::Instant::now();
live.push(&[StrokePoint::new(q[0], q[1], 1.0)]).unwrap();
steps.push(t.elapsed().as_secs_f64() * 1000.0);
}
let mut commit = p.clone();
commit["points"] = json!(all);
commit["seed"] = json!(live.seed);
let t = std::time::Instant::now();
s.execute("paint.pencil", commit).unwrap();
let done = t.elapsed().as_secs_f64() * 1000.0;
steps.sort_by(f64::total_cmp);
println!(
"pencil {size:>4} px: begin {begin:.2} ms, per move p50 {:.3} ms p95 {:.3} ms max {:.3} ms, commit {done:.1} ms",
steps[steps.len() / 2],
steps[steps.len() * 95 / 100],
steps[steps.len() - 1]
);
}
}
#[test]
fn live_stroke_equals_the_commit_at_every_zoom_with_smoothing() {
// #189: the preview while dragging must be the committed stroke (within 1/255), with a soft
+2 -19
View File
@@ -272,26 +272,9 @@ fn build() -> Vec<CommandSpec> {
"Fill…",
["Edit"],
Some("Shift+F5"),
r##"{"contents":"color|pattern"="color","color":"#rrggbb|[r,g,b,a]"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":%=100,"preserveTransparency":bool=false,"target":"pixels"|{"channel":i}|"quickMask"?}"##,
r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":index? (contents=history; default the oldest state),"colorAdaptation":bool=true (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|{"channel":i}|"quickMask"?}"##,
has_pixel_or_channel,
|s, p| {
if p.get("contents").and_then(Value::as_str) == Some("pattern") {
return crate::pattern_cmds::fill_with_pattern(s, p);
}
let color = color_param(p, "color", s.tools.foreground);
let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_param(s, p)?) };
// Fill › Preserve Transparency (⇧⌥⌫ / ⇧⌘⌫): only where the layer already has pixels.
let preserve = p.get("preserveTransparency").and_then(Value::as_bool).unwrap_or(false);
s.edit("Fill", |doc, _| {
let sel = doc.selection.clone();
let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc.bounds());
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
let lock = lock || (preserve && !crate::channel_cmds::is_channel_target(p));
pixels::fill_surface(surf, area, color, sel.as_ref(), lock);
Ok(())
})?;
Ok(Value::Null)
}
crate::fill_cmds::fill
),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{}", has_pixel_layer, |s, p| {
let id = layer_param(s, p)?;
+495
View File
@@ -0,0 +1,495 @@
//! Edit › Fill… (`edit.fill`): Photoshop's Fill dialog as one command.
//!
//! Contents: Foreground / Background / Color / Content-Aware / Pattern / History / Black /
//! 50% Gray / White, blended with `mode` at `opacity`, optionally preserving the target's
//! transparency. The selection (or the whole canvas) is filled, as one history step ("Fill").
//! Every parameter is validated: a bad one is an error, never a panic.
use photocraft_color::BlendMode;
use photocraft_geom::{Rect, TILE_SIZE};
use photocraft_raster::{Surface, from_rgba_into, to_rgba};
use serde_json::{Value, json};
use crate::commands::{blend_from_str, color_param, layer_param};
use crate::{EngineError, Result, Session};
const CMD: &str = "edit.fill";
/// Every `contents` value, in the dialog's order.
pub const CONTENTS: [&str; 9] = ["foreground", "background", "color", "contentAware", "pattern", "history", "black", "gray", "white"];
fn bad(msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: CMD.into(), msg: msg.into() }
}
/// What a pixel of the filled area gets before blending.
enum Source {
Color([f32; 4]),
/// Straight RGBA per pixel of `rect` (row-major).
Pixels(Rect, Vec<[f32; 4]>),
}
impl Source {
fn at(&self, x: i32, y: i32) -> [f32; 4] {
match self {
Source::Color(c) => *c,
Source::Pixels(r, px) => {
if !r.contains(x, y) {
return [0.0; 4];
}
let i = (y - r.y0) as usize * r.width() as usize + (x - r.x0) as usize;
px.get(i).copied().unwrap_or([0.0; 4])
}
}
}
}
/// The `contents` param; without one, `color` (the old `{"color": …}` form, foreground by default).
fn contents(p: &Value) -> Result<&str> {
match p.get("contents") {
None | Some(Value::Null) => Ok("color"),
Some(Value::String(c)) => {
CONTENTS.iter().find(|k| k.eq_ignore_ascii_case(c)).copied().ok_or_else(|| bad(format!("unknown contents `{c}` ({})", CONTENTS.join("|"))))
}
Some(v) => Err(bad(format!("`contents` must be a string, not {v}"))),
}
}
fn mode(p: &Value) -> Result<BlendMode> {
match p.get("mode") {
None | Some(Value::Null) => Ok(BlendMode::Normal),
Some(Value::String(m)) => blend_from_str(m).filter(|m| *m != BlendMode::PassThrough).ok_or_else(|| bad(format!("unknown blend mode `{m}`"))),
Some(v) => Err(bad(format!("`mode` must be a blend mode name, not {v}"))),
}
}
/// Opacity in percent (0–100) → 0..1.
fn opacity(p: &Value) -> Result<f32> {
match p.get("opacity") {
None | Some(Value::Null) => Ok(1.0),
Some(v) => {
let o = v.as_f64().filter(|o| o.is_finite()).ok_or_else(|| bad(format!("`opacity` must be a number 0..100, not {v}")))?;
Ok((o.clamp(0.0, 100.0) / 100.0) as f32)
}
}
}
fn flag(p: &Value, k: &str, d: bool) -> Result<bool> {
match p.get(k) {
None | Some(Value::Null) => Ok(d),
Some(Value::Bool(b)) => Ok(*b),
Some(v) => Err(bad(format!("`{k}` must be true or false, not {v}"))),
}
}
/// Edit › Fill.
pub fn fill(s: &mut Session, p: &Value) -> Result<Value> {
let contents = contents(p)?;
let mode = mode(p)?;
let opacity = opacity(p)?;
let preserve = flag(p, "preserveTransparency", false)?;
let channel = crate::channel_cmds::is_channel_target(p);
let id = if channel { None } else { Some(layer_param(s, p)?) };
let st = s.active().ok_or(EngineError::NoDocument)?;
let doc_bounds = st.doc.bounds();
let sel = st.doc.selection.clone();
let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc_bounds);
let mut out = json!({ "contents": contents });
let restore = contents == "history";
let source = match contents {
"foreground" => Source::Color(s.tools.foreground),
"background" => Source::Color(s.tools.background),
"color" => Source::Color(color_param(p, "color", s.tools.foreground)),
"black" => Source::Color([0.0, 0.0, 0.0, 1.0]),
"gray" => Source::Color([128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]),
"white" => Source::Color([1.0; 4]),
"pattern" => {
let pat = crate::pattern_cmds::resolve_param(s, CMD, p)?;
let (scale, angle, _, phase) = crate::pattern_cmds::placement(p);
let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| bad("the pattern is empty"))?;
// Photoshop's Fill uses the canvas origin.
let place = photocraft_compose::pattern::Placement::new(Rect::EMPTY, false, phase, scale, angle);
out["pattern"] = json!(pat.id);
Source::Pixels(area, photocraft_compose::pattern::render(&tile, &place, area))
}
"history" => {
let explicit = match p.get("state") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_u64().ok_or_else(|| bad(format!("`state` must be a history state index, not {v}")))? as usize),
};
let past = st.history.past_len();
let source_doc = match explicit {
Some(i) if i == past => st.doc.clone(),
Some(i) => st.history.state(i).ok_or_else(|| bad(format!("no history state {i} (0..={past})")))?,
// The History Brush's default source: the oldest state still held.
None => st.history.state(0).unwrap_or_else(|| st.doc.clone()),
};
let mut source_doc = (*source_doc).clone();
let (src, _) = crate::channel_cmds::target_surface(&mut source_doc, id, p)
.map_err(|_| EngineError::Other("the target did not exist (as pixels, a mask or a channel) in that history state".into()))?;
Source::Pixels(area, read_rgba(src, area))
}
"contentAware" => {
let sel = sel.as_ref().ok_or_else(|| EngineError::Other("Content-Aware fill needs a selection".into()))?;
if channel || crate::commands::is_mask_target(p) {
return Err(EngineError::Other("Content-Aware fill works on layer pixels only".into()));
}
let adapt = flag(p, "colorAdaptation", true)?;
let lid = id.ok_or_else(|| EngineError::Other("no active layer".into()))?;
let surf = st.doc.layer(lid).and_then(|l| l.surface()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?;
let seed = p.get("seed").and_then(Value::as_u64).unwrap_or(1);
content_aware(surf, sel, doc_bounds, adapt, seed)?
}
_ => return Err(bad(format!("unknown contents `{contents}`"))),
};
let area = area.intersect(&doc_bounds);
if area.is_empty() {
return Ok(out);
}
s.edit("Fill", |doc, _| {
let sel = doc.selection.clone();
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
blend_into(surf, area, &source, sel.as_ref(), Blend { mode, opacity, keep_alpha: preserve || lock, restore });
Ok(())
})?;
Ok(out)
}
fn read_rgba(src: &Surface, area: Rect) -> Vec<[f32; 4]> {
let mut px = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize];
src.read_rgba_into(area, &mut px);
px
}
/// The selection filled from its surroundings (`photocraft_algo::content_aware`), as straight
/// RGBA over the sampling window. Only the selected pixels are used.
fn content_aware(surf: &Surface, sel: &Surface, canvas: Rect, color_adaptation: bool, seed: u64) -> Result<Source> {
use photocraft_algo::content_aware::{FillOptions, color_level, fill, rotation_level};
let hole_bounds = sel.content_bounds().intersect(&canvas);
if hole_bounds.is_empty() {
return Err(EngineError::Other("the selection is outside the canvas".into()));
}
let ext = hole_bounds.width().max(hole_bounds.height());
let window = hole_bounds.inflate(i32::try_from(ext * 3 / 4).unwrap_or(i32::MAX).max(32)).intersect(&canvas);
let fmt = surf.format();
let n = fmt.channels();
let (w, h) = (window.width() as usize, window.height() as usize);
// Same cap as other whole-image operations: refuse absurd windows instead of allocating.
if w.saturating_mul(h) > 400_000_000 {
return Err(EngineError::Other("the selection is too large for Content-Aware fill".into()));
}
let img = surf.read_region(window);
let mut hole = vec![false; w * h];
for (i, hv) in hole.iter_mut().enumerate() {
*hv = sel.sample_channel(window.x0 + (i % w) as i32, window.y0 + (i / w) as i32, 0) > 0.0;
}
if hole.iter().all(|h| *h) {
return Err(EngineError::Other("Content-Aware fill needs pixels outside the selection to sample from".into()));
}
let source = vec![true; w * h];
let opts = FillOptions {
color_adaptation: color_level(if color_adaptation { "default" } else { "none" }),
rotations: rotation_level("none"),
scale: false,
mirror: false,
seed,
};
if img.len() != w * h * n {
return Err(EngineError::Other("could not read the layer pixels".into()));
}
let filled = fill(w, h, n, &img, &hole, &source, &opts);
let px = filled.chunks_exact(n.max(1)).map(|c| to_rgba(&fmt, c)).collect();
Ok(Source::Pixels(window, px))
}
/// How the source goes onto the target.
#[derive(Clone, Copy)]
struct Blend {
mode: BlendMode,
opacity: f32,
/// Leave every pixel's alpha as it was (Preserve Transparency, or locked transparency).
keep_alpha: bool,
/// Normal mode replaces the pixels, transparency included (History: back to that state).
restore: bool,
}
/// `d` moved towards `c` by `k`, in premultiplied space (straight RGBA in and out).
fn lerp(d: [f32; 4], c: [f32; 4], k: f32) -> [f32; 4] {
let a = d[3] * (1.0 - k) + c[3] * k;
if a <= 0.0 {
return [0.0; 4];
}
let ch = |i: usize| (d[i] * d[3] * (1.0 - k) + c[i] * c[3] * k) / a;
[ch(0), ch(1), ch(2), a]
}
/// Blend `source` into `surf` over `area`: per pixel, the selection coverage times the opacity,
/// in the blend mode.
/// Works tile by tile in parallel on the encoded pixels (no full-surface f32 round trip).
fn blend_into(surf: &mut Surface, area: Rect, source: &Source, sel: Option<&Surface>, b: Blend) {
use rayon::prelude::*;
let fmt = surf.format();
// An opaque colour in Normal mode at 100 % that may replace alpha: fully covered tiles become
// one shared solid tile (copy-on-write), which costs nothing per pixel.
let solid = match source {
Source::Color(c) if c[3] >= 1.0 && b.mode == BlendMode::Normal && b.opacity >= 1.0 && (!b.keep_alpha || !fmt.alpha) => {
let mut enc = [0.0f32; 8];
let n = from_rgba_into(&fmt, *c, &mut enc);
Some(surf.solid_tile(enc.get(..n).unwrap_or(&[])))
}
_ => None,
};
let tiles = surf.take_tiles(area);
let done: Vec<_> = tiles
.into_par_iter()
.map(|(tc, tile)| {
let tr = tc.rect().intersect(&area);
let mask = sel.map(|m| Mask { bytes: m.tile(tc).map_or(m.default_bytes(), |t| t.bytes()), fmt: m.format(), origin: tc.rect() });
if let Some(s) = &solid
&& tr == tc.rect()
&& mask.as_ref().is_none_or(|m| m.full(tr))
{
return (tc, s.clone());
}
let mut tile = std::sync::Arc::unwrap_or_clone(tile);
blend_tile(tile.bytes_mut(), &fmt, tc.rect(), tr, source, mask.as_ref(), b);
(tc, std::sync::Arc::new(tile))
})
.collect();
surf.put_tiles(done);
}
/// The selection's coverage over one tile (its channel 0).
struct Mask<'a> {
bytes: &'a [u8],
fmt: photocraft_color::PixelFormat,
origin: Rect,
}
impl Mask<'_> {
fn at(&self, x: i32, y: i32) -> f32 {
let bpp = self.fmt.bytes_per_pixel();
// A default-pixel mask is one pixel long: every position reads it.
let i = if self.bytes.len() == bpp { 0 } else { ((y - self.origin.y0) as usize * TILE_SIZE as usize + (x - self.origin.x0) as usize) * bpp };
self.bytes.get(i..i + bpp).map_or(0.0, |px| photocraft_color::read_sample(px, self.fmt.sample, 0))
}
/// Fully selected over `r`?
fn full(&self, r: Rect) -> bool {
(r.y0..r.y1).all(|y| (r.x0..r.x1).all(|x| self.at(x, y) >= 1.0))
}
}
/// Blend into one tile's encoded pixels (`bytes`, the tile at `origin`) over `tr`.
fn blend_tile(bytes: &mut [u8], fmt: &photocraft_color::PixelFormat, origin: Rect, tr: Rect, source: &Source, mask: Option<&Mask>, b: Blend) {
let Blend { mode, opacity, keep_alpha, restore } = b;
let n = fmt.channels();
let bpp = fmt.bytes_per_pixel();
let blend = if mode == BlendMode::Dissolve { BlendMode::Normal } else { mode };
let mut px = [0.0f32; 8];
let mut enc = [0.0f32; 8];
// An opaque colour at full coverage in Normal mode just replaces the colour (and the alpha
// unless it is kept): copy its encoded bytes.
let opaque = match source {
Source::Color(c) if c[3] >= 1.0 && mode == BlendMode::Normal => {
let m = from_rgba_into(fmt, *c, &mut enc);
let mut bytes = vec![0u8; bpp];
photocraft_raster::encode_pixel(fmt, enc.get(..m.min(n)).unwrap_or(&[]), &mut bytes);
// Keeping alpha: copy the colour samples only (alpha is the last sample).
let keep = if (keep_alpha || !fmt.alpha) && fmt.alpha { bpp - bpp / n.max(1) } else { bpp };
Some((bytes, keep))
}
_ => None,
};
for y in tr.y0..tr.y1 {
let row = (y - origin.y0) as usize * TILE_SIZE as usize;
for x in tr.x0..tr.x1 {
let mut k = mask.map_or(1.0, |m| m.at(x, y)) * opacity;
if k <= 0.0 {
continue;
}
let i = (row + (x - origin.x0) as usize) * bpp;
let Some(pb) = bytes.get_mut(i..i + bpp) else { continue };
if k >= 1.0
&& let Some((enc_px, keep)) = &opaque
{
if let (Some(dst), Some(src)) = (pb.get_mut(..*keep), enc_px.get(..*keep)) {
dst.copy_from_slice(src);
}
continue;
}
for (c, v) in px.iter_mut().enumerate().take(n) {
*v = photocraft_color::read_sample(pb, fmt.sample, c);
}
let d = to_rgba(fmt, &px[..n.min(8)]);
let mut c = source.at(x, y);
if mode == BlendMode::Dissolve {
// Dissolve: each pixel is either fully painted or untouched, with the coverage as odds.
k = if photocraft_color::dither_noise(x, y) < c[3] * k { 1.0 } else { 0.0 };
c[3] = 1.0;
if k <= 0.0 {
continue;
}
}
let mut o = if restore && blend == BlendMode::Normal { lerp(d, c, k) } else { photocraft_color::blend::composite(blend, d, c, k) };
if keep_alpha || !fmt.alpha {
o[3] = d[3];
}
let m = from_rgba_into(fmt, o, &mut enc);
photocraft_raster::encode_pixel(fmt, enc.get(..m.min(n)).unwrap_or(&[]), pb);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap();
s
}
fn px(s: &Session, x: i32, y: i32) -> [f32; 4] {
let st = s.active().unwrap();
let l = st.doc.layer(st.active_layer.unwrap()).unwrap();
l.surface().unwrap().rgba(x, y)
}
fn close(a: [f32; 4], b: [f32; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1.5 / 255.0)
}
#[test]
fn every_constant_contents() {
let mut s = session();
s.execute("tools.setColors", json!({"foreground": [1.0, 0.0, 0.0, 1.0], "background": [0.0, 0.0, 1.0, 1.0]})).unwrap();
for (c, want) in [
("foreground", [1.0, 0.0, 0.0, 1.0]),
("background", [0.0, 0.0, 1.0, 1.0]),
("black", [0.0, 0.0, 0.0, 1.0]),
("gray", [128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]),
("white", [1.0; 4]),
] {
s.execute(CMD, json!({"contents": c})).unwrap();
assert!(close(px(&s, 5, 5), want), "{c}: {:?}", px(&s, 5, 5));
}
s.execute(CMD, json!({"contents": "color", "color": "#00ff00"})).unwrap();
assert!(close(px(&s, 5, 5), [0.0, 1.0, 0.0, 1.0]));
// The old form: a colour, or the foreground.
s.execute(CMD, json!({"color": "#ffff00"})).unwrap();
assert!(close(px(&s, 5, 5), [1.0, 1.0, 0.0, 1.0]));
}
#[test]
fn opacity_mode_and_preserve_transparency() {
let mut s = session();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap();
s.execute(CMD, json!({"contents": "white"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
// Preserve Transparency: the empty right half stays empty.
s.execute(CMD, json!({"contents": "black", "opacity": 50, "preserveTransparency": true})).unwrap();
assert!(close(px(&s, 5, 5), [0.5, 0.5, 0.5, 1.0]), "{:?}", px(&s, 5, 5));
assert_eq!(px(&s, 30, 5)[3], 0.0);
// Multiply with white leaves the grey.
s.execute(CMD, json!({"contents": "white", "mode": "multiply", "preserveTransparency": true})).unwrap();
assert!(close(px(&s, 5, 5), [0.5, 0.5, 0.5, 1.0]));
// Without it, the transparent half is filled.
s.execute(CMD, json!({"contents": "white", "opacity": 100})).unwrap();
assert_eq!(px(&s, 30, 5), [1.0; 4]);
// Dissolve paints whole pixels only.
s.execute(CMD, json!({"contents": "black", "mode": "dissolve", "opacity": 50})).unwrap();
let st = s.active().unwrap();
let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();
let blacks = (0..40).flat_map(|x| (0..30).map(move |y| (x, y))).filter(|&(x, y)| surf.rgba(x, y)[0] < 0.01).count();
assert!(blacks > 300 && blacks < 900, "{blacks}");
assert!((0..40).all(|x| {
let v = surf.rgba(x, 3)[0];
v < 0.01 || v > 0.99
}));
}
#[test]
fn history_pattern_and_content_aware() {
let mut s = session();
s.execute(CMD, json!({"contents": "white"})).unwrap();
s.execute(CMD, json!({"contents": "black"})).unwrap();
// History: back to state 1 (after the white fill).
s.execute(CMD, json!({"contents": "history", "state": 1})).unwrap();
assert_eq!(px(&s, 5, 5), [1.0; 4]);
// The default source is the oldest state: the empty layer.
s.execute(CMD, json!({"contents": "history"})).unwrap();
assert_eq!(px(&s, 5, 5)[3], 0.0);
assert!(s.execute(CMD, json!({"contents": "history", "state": 999})).is_err());
let r = s.execute(CMD, json!({"contents": "pattern", "pattern": "Diagonal Lines"})).unwrap();
assert!(r["pattern"].is_string());
assert!(px(&s, 5, 5)[3] > 0.0);
// Content-Aware fills the hole from what is around it.
s.execute(CMD, json!({"contents": "color", "color": "#336699"})).unwrap();
s.execute("select.rect", json!({"x": 15, "y": 10, "width": 6, "height": 6})).unwrap();
s.execute(CMD, json!({"contents": "white"})).unwrap();
s.execute(CMD, json!({"contents": "contentAware", "colorAdaptation": false})).unwrap();
assert!(close(px(&s, 17, 12), [0x33 as f32 / 255.0, 0x66 as f32 / 255.0, 0x99 as f32 / 255.0, 1.0]), "{:?}", px(&s, 17, 12));
}
/// `cargo test -p photocraft-engine --release --lib fill_cmds::tests::timing_24mp -- --ignored --nocapture`
#[test]
#[ignore]
fn timing_24mp() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 6000, "height": 4000, "background": "white"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute(CMD, json!({"contents": "white"})).unwrap();
let time = |s: &mut Session, label: &str, p: Value| {
let mut v = Vec::new();
for i in 0..5 {
let mut p = p.clone();
if p.get("contents").is_none() {
p["color"] = json!(if i % 2 == 0 { "#336699" } else { "#996633" });
}
let t = std::time::Instant::now();
s.execute(CMD, p).unwrap();
v.push(t.elapsed().as_secs_f64() * 1000.0);
}
v.sort_by(f64::total_cmp);
println!("{label:<48} median {:>8.1} ms min {:>8.1} ms", v[2], v[0]);
};
time(&mut s, "colour, no selection", json!({}));
time(&mut s, "colour, preserve transparency", json!({"preserveTransparency": true}));
time(&mut s, "colour 50% multiply", json!({"opacity": 50, "mode": "multiply"}));
s.execute("select.rect", json!({"x": 100, "y": 100, "width": 5000, "height": 3000})).unwrap();
time(&mut s, "colour, 15 MP rectangle selection", json!({}));
s.execute("select.deselect", json!({})).unwrap();
// The old per-pixel f32 path, for comparison.
let st = s.active().unwrap();
let mut surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().clone();
let t = std::time::Instant::now();
crate::pixels::fill_surface(&mut surf, Rect::new(0, 0, 6000, 4000), [0.2, 0.4, 0.6, 1.0], None, false);
println!("{:<48} {:>8.1} ms", "old fill_surface, no selection", t.elapsed().as_secs_f64() * 1000.0);
}
#[test]
fn bad_params_are_errors() {
let mut s = session();
for p in [
json!({"contents": "plaid"}),
json!({"contents": 3}),
json!({"mode": "sideways"}),
json!({"mode": "passThrough"}),
json!({"opacity": "lots"}),
json!({"preserveTransparency": "yes"}),
json!({"contents": "contentAware"}),
json!({"contents": "pattern", "pattern": "no such pattern"}),
json!({"contents": "history", "state": -1}),
json!({"contents": "contentAware", "colorAdaptation": 1}),
] {
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
}
// Everything selected leaves nothing to sample from.
s.execute("select.all", json!({})).unwrap();
assert!(s.execute(CMD, json!({"contents": "contentAware"})).is_err());
// Out-of-range opacity is clamped.
s.execute(CMD, json!({"contents": "white", "opacity": 1e300})).unwrap();
}
}
+1
View File
@@ -28,6 +28,7 @@ pub mod edit_menu_cmds;
pub mod eraser_cmds;
pub mod extra_cmds;
pub mod file_cmds;
pub mod fill_cmds;
pub mod fill_key_cmds;
pub mod filters;
pub mod filters_ext;
+1 -45
View File
@@ -15,7 +15,7 @@ use photocraft_doc::{Color, Document, Fill, Layer, LayerContent, Pattern, Rect};
use photocraft_raster::Surface;
use serde_json::{Value, json};
use crate::commands::{CommandSpec, layer_param};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
/// The app-level pattern library (Window › Patterns).
@@ -315,50 +315,6 @@ fn new_fill_layer(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"layer": id.0, "pattern": pat.id}))
}
/// Edit › Fill with `"contents": "pattern"`: tiles the pattern over the selection (or the whole
/// target), composited at `opacity` (Normal), respecting transparency locks.
pub fn fill_with_pattern(s: &mut Session, p: &Value) -> Result<Value> {
let pat = resolve_param(s, "edit.fill", p)?;
let (scale, angle, _, phase) = placement(p);
let opacity = (p.get("opacity").and_then(Value::as_f64).unwrap_or(100.0) as f32 / 100.0).clamp(0.0, 1.0);
let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_param(s, p)?) };
let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| bad("edit.fill", "the pattern is empty"))?;
s.edit("Fill", |doc, _| {
let sel = doc.selection.clone();
let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc.bounds());
// Photoshop's Fill uses the canvas origin.
let place = photocraft_compose::pattern::Placement::new(Rect::EMPTY, false, phase, scale, angle);
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
let fmt = surf.format();
let n = fmt.channels();
let mut region = surf.read_region(area);
let src = photocraft_compose::pattern::render(&tile, &place, area);
let w = area.width() as usize;
for (i, (px, c)) in region.chunks_exact_mut(n).zip(&src).enumerate() {
let k = sel.as_ref().map_or(1.0, |m| m.sample_channel(area.x0 + (i % w) as i32, area.y0 + (i / w) as i32, 0)) * opacity;
if k <= 0.0 {
continue;
}
let d = photocraft_raster::to_rgba(&fmt, px);
let sa = c[3] * k;
let oa = sa + d[3] * (1.0 - sa);
let mut o = [0.0f32; 4];
if oa > 0.0 {
for ch in 0..3 {
o[ch] = (c[ch] * sa + d[ch] * d[3] * (1.0 - sa)) / oa;
}
}
o[3] = if lock || !fmt.alpha { d[3] } else { oa };
let mut enc = [0.0f32; 8];
photocraft_raster::from_rgba_into(&fmt, o, &mut enc);
px.copy_from_slice(&enc[..n]);
}
surf.write_region(area, &region);
Ok(())
})?;
Ok(json!({"pattern": pat.id}))
}
fn brush_texture(s: &mut Session, p: &Value) -> Result<Value> {
let pat = resolve_param(s, "brush.texturePattern", p)?;
// Brush textures are grey: luminance of the composite over white.
+3
View File
@@ -744,6 +744,9 @@ pub struct Preferences {
/// as the user changes it and restored at launch when Workspace › Remember Workspace
/// Changes is on. JSON owned by the shell.
pub panel_layout: Value,
/// The last choices of dialogs that remember them across restarts, by command id (Edit ›
/// Fill…: `"edit.fill"` → its params). JSON owned by the shell.
pub dialogs: BTreeMap<String, Value>,
/// File › Scripts › Script Events Manager: event → script bindings.
pub script_events: crate::automate_cmds::ScriptEvents,
}
+38
View File
@@ -128,6 +128,44 @@ fn pencil_is_aliased() {
assert!((10..32).any(|y| (0..64).any(|x| (0.01..0.99).contains(&s.rgba(x, y)[3]))));
}
#[test]
fn pencil_dabs_sit_on_the_pixel_grid() {
use crate::render::{grid_center, grid_square};
// Odd sizes centre on the pixel holding the point, even ones on the nearest corner.
assert_eq!(grid_center(10.0, 5.0, 1.0), (10.5, 5.5));
assert_eq!(grid_center(10.9, 5.2, 3.0), (10.5, 5.5));
assert_eq!(grid_center(10.4, 5.6, 2.0), (10.0, 6.0));
assert_eq!(grid_square(10.2, 5.7, 1.0), [10.0, 5.0, 11.0, 6.0]);
assert_eq!(grid_square(10.2, 5.7, 4.0), [8.0, 4.0, 12.0, 8.0]);
assert_eq!(grid_square(f64::NAN, 0.0, f32::NAN).len(), 4);
// A 1 px pencil paints exactly the pixel under each point, even on a pixel corner, with
// no partial alpha.
let b = BrushSettings { aliased: true, hardness: 1.0, size: 1.0, ..brush() };
let s = paint(&b, &[StrokePoint::new(7.0, 4.0, 1.0)], 16, 16);
for y in 0..16 {
for x in 0..16 {
assert_eq!(s.rgba(x, y)[3], if (x, y) == (7, 4) { 1.0 } else { 0.0 }, "({x},{y})");
}
}
// A horizontal 1 px line is one pixel thick and unbroken.
let s = paint(&b, &line(2.0, 20.0, 9.3), 24, 16);
for x in 2..20 {
assert_eq!(s.rgba(x, 9)[3], 1.0, "x {x}");
assert_eq!(s.rgba(x, 8)[3] + s.rgba(x, 10)[3], 0.0, "x {x}");
}
// A 4 px dab stays inside its cursor square.
let b = BrushSettings { size: 4.0, ..b };
let s = paint(&b, &[StrokePoint::new(10.2, 5.7, 1.0)], 24, 16);
let [x0, y0, x1, y1] = grid_square(10.2, 5.7, 4.0);
for y in 0..16 {
for x in 0..24 {
let inside = (x as f64) >= x0 && (x as f64) < x1 && (y as f64) >= y0 && (y as f64) < y1;
assert!(inside || s.rgba(x, y)[3] == 0.0, "({x},{y}) outside the square is painted");
}
}
assert_eq!(s.rgba(9, 5)[3], 1.0);
}
// ---------- spacing ----------
#[test]
+1 -1
View File
@@ -35,7 +35,7 @@ pub use brush::{
Smoothing, Texture, TipShape, Transfer,
};
pub use mixer::MixerSettings;
pub use render::{BrushContext, StrokeRenderer, render_stroke};
pub use render::{BrushContext, StrokeRenderer, grid_center, grid_square, render_stroke};
pub use tile::GrayTile;
/// One input sample. Missing stylus data defaults to "mouse": full pressure, no tilt/rotation.
+20 -1
View File
@@ -26,6 +26,25 @@ pub const COV_TILE: i32 = 64;
const NOISE_SALT: u64 = 0x006E_6F69_7365;
#[inline]
/// Where an aliased (Pencil) dab of `diameter` pixels centred near (`x`, `y`) lands on the pixel
/// grid: the centre of the pixel holding the point for odd diameters, the nearest pixel corner
/// for even ones. Its footprint is then a whole-pixel block around that point, the same square
/// the Pencil cursor shows ([`grid_square`]).
pub fn grid_center(x: f64, y: f64, diameter: f32) -> (f32, f32) {
let n = if diameter.is_finite() { diameter.round().max(1.0) as i64 } else { 1 };
let snap = |v: f64| if n % 2 == 1 { v.floor() + 0.5 } else { v.round() };
(snap(x) as f32, snap(y) as f32)
}
/// The whole-pixel square `[x0, y0, x1, y1]` an aliased dab of `diameter` pixels at (`x`, `y`)
/// can touch (see [`grid_center`]): the Pencil's cursor.
pub fn grid_square(x: f64, y: f64, diameter: f32) -> [f64; 4] {
let n = if diameter.is_finite() { f64::from(diameter.round().clamp(1.0, 100_000.0)) } else { 1.0 };
let (cx, cy) = grid_center(x, y, n as f32);
let h = n / 2.0;
[f64::from(cx) - h, f64::from(cy) - h, f64::from(cx) + h, f64::from(cy) + h]
}
fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
@@ -141,7 +160,7 @@ impl BrushContext {
let wet = b.wet_edges && !dual;
let noise = b.noise && !dual;
let tex_tip = !dual && b.texture.enabled && b.texture.each_tip && self.texture.is_some();
let (cx, cy) = (d.center.x as f32, d.center.y as f32);
let (cx, cy) = if aliased { grid_center(d.center.x, d.center.y, 2.0 * d.radius) } else { (d.center.x as f32, d.center.y as f32) };
let (sn, cs) = d.angle.sin_cos();
let (fx, fy) = (if d.flip_x { -1.0 } else { 1.0 }, if d.flip_y { -1.0 } else { 1.0 });
// Brush Projection: stretch the sampling coordinates along the tilt direction, which
+34
View File
@@ -395,6 +395,40 @@ impl Surface {
}
}
/// Take the tiles covering `r` out of the surface (missing ones filled with the default
/// pixel), so they can be edited elsewhere, e.g. in parallel: `Arc::unwrap_or_clone` copies a
/// tile only if an undo snapshot still shares it. Give them back with [`Surface::put_tiles`].
pub fn take_tiles(&mut self, r: Rect) -> Vec<(TileCoord, Arc<Tile>)> {
let fmt = self.format;
let mut blank: Option<Arc<Tile>> = None;
r.tiles()
.map(|c| {
let t = self.tiles.remove(&c).unwrap_or_else(|| blank.get_or_insert_with(|| Arc::new(Tile::filled(&fmt, &self.default_pixel))).clone());
(c, t)
})
.collect()
}
/// Put tiles back (see [`Surface::take_tiles`]); each replaces the tile at its coordinate.
pub fn put_tiles(&mut self, tiles: impl IntoIterator<Item = (TileCoord, Arc<Tile>)>) {
for (c, t) in tiles {
self.tiles.insert(c, t);
}
}
/// A tile of this surface's format filled with `px` (normalised channel values). Sharing one
/// across many coordinates fills them without copying (tiles are copy-on-write).
pub fn solid_tile(&self, px: &[f32]) -> Arc<Tile> {
let mut enc = vec![0u8; self.format.bytes_per_pixel()];
encode_pixel(&self.format, px, &mut enc);
Arc::new(Tile::filled(&self.format, &enc))
}
/// The default pixel's encoded bytes.
pub fn default_bytes(&self) -> &[u8] {
&self.default_pixel
}
/// Fill a rectangle with one pixel value.
pub fn fill_rect(&mut self, r: Rect, px: &[f32]) {
let n = self.channels();
+53 -8
View File
@@ -178,15 +178,41 @@ fn live_stroke(app: &PhotocraftApp, idx: usize) -> Option<&LiveStroke> {
/// `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 stroke_params(app: &PhotocraftApp, tool: Tool, erase: bool, points: &[Vec<f64>]) -> serde_json::Value {
let mut p = json!({ "points": points, "erase": erase, "zoom": app.current_zoom(), "target": paint_target(app) });
if tool == Tool::Pencil {
p["autoErase"] = json!(app.ui.tool_options.pencil_auto_erase);
}
p
}
/// Tools whose strokes the engine renders while they are drawn (`LiveStroke`).
pub(crate) fn strokes_live(tool: Tool) -> bool {
matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser)
}
/// The command a live-stroking tool commits: the Pencil's `paint.pencil`, else `paint.stroke`.
pub(crate) fn stroke_command(tool: Tool) -> &'static str {
if tool == Tool::Pencil { "paint.pencil" } else { "paint.stroke" }
}
/// The Pencil's cursor at `doc` (document pixels): the whole-pixel square its dab fills
/// (`paint::grid_square`), in screen points with its edges on physical pixels (`ppp` = pixels
/// per point), so it lines up with the pixel grid at any zoom.
pub(crate) fn pencil_cursor_rect(xf: &ViewXform, doc: [f64; 2], size: f32, ppp: f32) -> Rect {
let [x0, y0, x1, y1] = photocraft_engine::paint::grid_square(doc[0], doc[1], size);
let r = Rect::from_two_pos(xf.to_screen(x0 as f32, y0 as f32), xf.to_screen(x1 as f32, y1 as f32));
let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 };
let snap = |v: f32| (v * ppp).round() / ppp;
Rect::from_min_max(pos2(snap(r.min.x), snap(r.min.y)), pos2(snap(r.max.x), snap(r.max.y)))
}
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, d.erase, &app.stylus.stroke_points(&d.points))).ok()?;
let p = stroke_params(app, d.tool, d.erase, &app.stylus.stroke_points(&d.points));
let stroke = photocraft_engine::brush_cmds::LiveStroke::begin_with(&app.session, stroke_command(d.tool), &p).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() })
@@ -1498,6 +1524,19 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
crosshair(5.0);
egui::CursorIcon::None
}
// The Pencil: the square of whole pixels its dab fills, on the pixel grid.
_ if tool == Tool::Pencil => {
let ppp = painter.ctx().pixels_per_point();
let sq = pencil_cursor_rect(&xf, xf.to_doc(p), brush.size, ppp);
let px = 1.0 / ppp;
painter.rect_stroke(sq, 0.0, Stroke::new(px, Color32::from_black_alpha(160)), egui::StrokeKind::Outside);
painter.rect_stroke(sq, 0.0, Stroke::new(px, Color32::from_white_alpha(230)), egui::StrokeKind::Inside);
// Too small to see where it is: the hotspot as well.
if cur.show_crosshair_in_brush_tip || sq.width() < 6.0 {
crosshair(4.0);
}
egui::CursorIcon::None
}
_ => {
painter.circle_stroke(p, r + 0.5, Stroke::new(1.0, Color32::from_black_alpha(140)));
painter.circle_stroke(p, r, Stroke::new(1.0, Color32::from_white_alpha(220)));
@@ -1738,7 +1777,7 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View
}
match d.tool {
// The canvas shows the live stroke itself (`LiveStroke`).
Tool::Brush | Tool::Eraser => {}
Tool::Brush | Tool::Pencil | Tool::Eraser => {}
t if t.is_brushlike() || t == Tool::QuickSelection => {
// Retouching strokes preview as a translucent trail of the brush footprint: a mask,
// not a brush-wide egui polyline (which zoomed in tessellates into wedges, #189).
@@ -1877,6 +1916,12 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
}
match ev {
ToolEvent::Down { x, y, pressure } => {
// Painting a type, shape, Smart Object or fill layer asks to rasterize it first
// (⌥-click with the Clone Stamp or Healing Brush only sets the source).
let sets_source = matches!(tool, Tool::CloneStamp | Tool::Healing) && mods.alt;
if !sets_source && crate::rasterize_prompt::intercept(app, tool, x, y, pressure) {
return;
}
match tool {
Tool::Pen => {
crate::vector_ui::pen_down(app, x, y);
@@ -1932,7 +1977,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
if from.is_some() {
app.stylus.record_point();
}
app.live_stroke = if matches!(tool, Tool::Brush | Tool::Eraser) { begin_live_stroke(app) } else { None };
app.live_stroke = if strokes_live(tool) { begin_live_stroke(app) } else { None };
}
ToolEvent::Move { x, y, pressure } => {
if tool == Tool::Type && app.drag.is_none() {
@@ -2007,15 +2052,15 @@ fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::finish_shape(app, t, d.start, [end[0], end[1]], d.modifiers),
Tool::PathSelection => crate::vector_ui::path_selection_finish(app, d.start, [end[0], end[1]]),
Tool::Type => crate::type_tool::pointer_up(app, d.start, [end[0], end[1]]),
Tool::Brush | Tool::Eraser => {
Tool::Brush | Tool::Pencil | Tool::Eraser => {
let live = app.live_stroke.take();
let mut p = stroke_params(app, d.erase, &app.stylus.stroke_points(&d.points));
let mut p = stroke_params(app, d.tool, d.erase, &app.stylus.stroke_points(&d.points));
if let Some(l) = &live {
p["seed"] = json!(l.stroke.seed);
}
// The canvas already shows the stroke: let the commit's damage rect refresh it rather
// than recompositing the whole document.
if app.run("paint.stroke", p).is_ok()
if app.run(stroke_command(d.tool), p).is_ok()
&& let Some(l) = live
{
let display_key = app.session.active().map_or(0, |st| canvas_display(app, &st.doc).1);
+4
View File
@@ -100,6 +100,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
ui.add_space(10.0);
ui.weak("egui · wgpu · photocraft-engine");
}
DialogKind::Command if crate::fill_ui::owns(&fields) => crate::fill_ui::body(app, ui, &mut fields),
DialogKind::Command if crate::rasterize_prompt::owns(&fields) => crate::rasterize_prompt::body(ui, &fields),
DialogKind::Command if crate::variables_ui::owns(&fields) => crate::variables_ui::body(app, ui, &mut fields),
DialogKind::Command if crate::file_ui::owns(&fields) => crate::file_ui::body(app, ui, &mut fields),
DialogKind::Command if crate::color_picker_ui::owns(&fields) => crate::color_picker_ui::body(ui, &mut fields),
@@ -212,6 +214,8 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
}
r
}
DialogKind::Command if crate::fill_ui::owns(&d.fields) => crate::fill_ui::confirm(app, &d.fields),
DialogKind::Command if crate::rasterize_prompt::owns(&d.fields) => crate::rasterize_prompt::confirm(app, &d.fields),
DialogKind::Command if crate::variables_ui::owns(&d.fields) => crate::variables_ui::confirm(app, &d.fields),
DialogKind::Command if crate::file_ui::owns(&d.fields) => crate::file_ui::confirm(app, &d.fields),
DialogKind::Command if crate::color_picker_ui::owns(&d.fields) => crate::color_picker_ui::confirm(app, &d.fields),
+213
View File
@@ -0,0 +1,213 @@
//! Edit › Fill… (Shift+F5, Shift+Backspace): Photoshop's Fill dialog in front of `edit.fill`.
//!
//! Contents (Foreground, Background, Color…, Content-Aware, Pattern, History, Black, 50% Gray,
//! White) with the content's own options, then Blending: Mode, Opacity and Preserve
//! Transparency. The fields are the command's params, so `ui.dialog.set` / `ui.dialog.confirm`
//! drive it like any dialog. OK remembers the choices in the preferences (`dialogs["edit.fill"]`),
//! which are saved to disk, so the dialog opens with them again after a restart.
use egui::{Align2, Sense, pos2, vec2};
use serde_json::{Map, Value, json};
use crate::PhotocraftApp;
use crate::theme::Tokens;
pub const COMMAND: &str = "edit.fill";
const MARK: &str = "__fill";
/// The Contents menu: (param, label), in Photoshop's order.
pub const CONTENTS: [(&str, &str); 9] = [
("foreground", "Foreground Color"),
("background", "Background Color"),
("color", "Color…"),
("contentAware", "Content-Aware"),
("pattern", "Pattern"),
("history", "History"),
("black", "Black"),
("gray", "50% Gray"),
("white", "White"),
];
/// Params the dialog edits and remembers.
const KEYS: [&str; 7] = ["contents", "color", "pattern", "colorAdaptation", "mode", "opacity", "preserveTransparency"];
/// Is this dialog the Fill dialog?
pub fn owns(fields: &Map<String, Value>) -> bool {
fields.contains_key(MARK)
}
fn hex(c: [f32; 4]) -> String {
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
format!("#{:02x}{:02x}{:02x}", b(c[0]), b(c[1]), b(c[2]))
}
/// A remembered value, if it is one the dialog can show (a corrupt preference is ignored).
fn valid(app: &PhotocraftApp, key: &str, v: &Value) -> bool {
match key {
"contents" => v.as_str().is_some_and(|c| CONTENTS.iter().any(|(k, _)| *k == c)),
"mode" => v.as_str().and_then(photocraft_engine::commands::blend_from_str).is_some_and(|m| m != photocraft_color::BlendMode::PassThrough),
"opacity" => v.as_f64().is_some_and(|o| (0.0..=100.0).contains(&o)),
"color" => v.as_str().is_some_and(|h| h.len() == 7 && h.starts_with('#') && h.get(1..).is_some_and(|d| d.chars().all(|c| c.is_ascii_hexdigit()))),
"pattern" => v.as_str().is_some_and(|p| app.session.patterns.items.iter().any(|q| q.id == p)),
"colorAdaptation" | "preserveTransparency" => v.is_boolean(),
_ => false,
}
}
/// The dialog's fields: Photoshop's defaults, overridden by the last choices.
pub fn fields(app: &PhotocraftApp) -> Map<String, Value> {
let mut f = Map::new();
f.insert(MARK.into(), json!(true));
f.insert("__command".into(), json!(COMMAND));
f.insert("__label".into(), json!("Fill"));
f.insert("contents".into(), json!("foreground"));
f.insert("color".into(), json!(hex(app.session.tools.foreground)));
if let Some(p) = app.session.patterns.items.first() {
f.insert("pattern".into(), json!(p.id));
}
f.insert("colorAdaptation".into(), json!(true));
f.insert("mode".into(), json!("normal"));
f.insert("opacity".into(), json!(100.0));
f.insert("preserveTransparency".into(), json!(false));
if let Some(Value::Object(saved)) = app.session.prefs().dialogs.get(COMMAND) {
for k in KEYS {
if let Some(v) = saved.get(k).filter(|v| valid(app, k, v)) {
f.insert(k.into(), v.clone());
}
}
}
f
}
/// Open the Fill dialog; returns its id.
pub fn open(app: &mut PhotocraftApp) -> u64 {
let f = fields(app);
app.ui.open_dialog(crate::state::DialogKind::Command, f)
}
/// The `edit.fill` params of the dialog's fields: only the options of the chosen contents.
pub fn params(f: &Map<String, Value>) -> Value {
let contents = f.get("contents").and_then(Value::as_str).unwrap_or("foreground");
let mut p = json!({ "contents": contents });
for k in ["mode", "opacity", "preserveTransparency"] {
if let Some(v) = f.get(k) {
p[k] = v.clone();
}
}
let extra = match contents {
"color" => Some("color"),
"pattern" => Some("pattern"),
"contentAware" => Some("colorAdaptation"),
_ => None,
};
if let Some(k) = extra
&& let Some(v) = f.get(k)
{
p[k] = v.clone();
}
p
}
/// OK: remember the choices, then fill.
pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
let remembered: Map<String, Value> = KEYS.iter().filter_map(|k| f.get(*k).map(|v| (k.to_string(), v.clone()))).collect();
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), Value::Object(remembered)));
app.run(COMMAND, params(f))
}
fn label(ui: &mut egui::Ui, text: &str) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(112.0, 22.0), Sense::hover());
ui.painter().text(pos2(r.right() - 6.0, r.center().y), Align2::RIGHT_CENTER, text, egui::FontId::proportional(12.0), t.text_dim);
}
fn get_str(f: &Map<String, Value>, k: &str, d: &str) -> String {
f.get(k).and_then(Value::as_str).unwrap_or(d).to_string()
}
fn parse_hex(h: &str) -> [u8; 3] {
let d = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).unwrap_or(0);
[d(1), d(3), d(5)]
}
/// The dialog body.
pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Value>) {
ui.spacing_mut().item_spacing.y = 6.0;
let mut contents = get_str(f, "contents", "foreground");
ui.horizontal(|ui| {
label(ui, "Contents:");
let opts: Vec<(String, &str)> = CONTENTS.iter().map(|(k, l)| (k.to_string(), *l)).collect();
if crate::widgets::dropdown(ui, "fill-contents", &mut contents, &opts, 170.0) {
f.insert("contents".into(), json!(contents));
}
});
match contents.as_str() {
"color" => {
ui.horizontal(|ui| {
label(ui, "Color:");
let mut rgb = parse_hex(&get_str(f, "color", "#000000"));
if egui::color_picker::color_edit_button_srgb(ui, &mut rgb).changed() {
f.insert("color".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
}
});
}
"pattern" => {
ui.horizontal(|ui| {
label(ui, "Custom Pattern:");
let mut pat = get_str(f, "pattern", "");
let opts: Vec<(String, &str)> = app.session.patterns.items.iter().map(|p| (p.id.clone(), p.display_name())).collect();
if opts.is_empty() {
ui.label(egui::RichText::new("No patterns").color(Tokens::get(ui.ctx()).text_faint));
} else if crate::widgets::dropdown(ui, "fill-pattern", &mut pat, &opts, 170.0) {
f.insert("pattern".into(), json!(pat));
}
});
}
"contentAware" => {
ui.horizontal(|ui| {
label(ui, "");
let mut on = f.get("colorAdaptation").and_then(Value::as_bool).unwrap_or(true);
if crate::widgets::checkbox(ui, &mut on, "Color Adaptation").changed() {
f.insert("colorAdaptation".into(), json!(on));
}
});
}
_ => {}
}
ui.add_space(4.0);
crate::widgets::section_label(ui, "Blending");
crate::widgets::hairline(ui);
ui.horizontal(|ui| {
label(ui, "Mode:");
let mut mode = get_str(f, "mode", "normal");
let opts: Vec<(String, &str)> = photocraft_color::BlendMode::LAYER_MODES.iter().map(|m| (mode_key(*m), m.label())).collect();
if crate::widgets::dropdown(ui, "fill-mode", &mut mode, &opts, 170.0) {
f.insert("mode".into(), json!(mode));
}
});
ui.horizontal(|ui| {
label(ui, "Opacity:");
let mut o = f.get("opacity").and_then(Value::as_f64).unwrap_or(100.0) as f32;
if crate::widgets::value_field(ui, &mut o, 0.0..=100.0, "%", 70.0).changed() {
f.insert("opacity".into(), json!(o.round()));
}
});
ui.horizontal(|ui| {
label(ui, "");
let mut on = f.get("preserveTransparency").and_then(Value::as_bool).unwrap_or(false);
if crate::widgets::checkbox(ui, &mut on, "Preserve Transparency").changed() {
f.insert("preserveTransparency".into(), json!(on));
}
});
}
/// The `mode` param naming a blend mode (`"normal"`, `"colorBurn"`…), which the engine parses.
fn mode_key(m: photocraft_color::BlendMode) -> String {
let s = format!("{m:?}");
let mut c = s.chars();
c.next().map(|f| f.to_ascii_lowercase().to_string() + c.as_str()).unwrap_or_default()
}
#[cfg(test)]
#[path = "fill_ui_tests.rs"]
mod tests;
+162
View File
@@ -0,0 +1,162 @@
use egui::{Key, Modifiers, vec2};
use egui_kittest::Harness;
use serde_json::{Value, json};
use super::*;
fn app() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap();
app.run("tools.setColors", json!({"foreground": "#ff0000", "background": "#0000ff"})).unwrap();
app
}
fn px(app: &PhotocraftApp, x: i32, y: i32) -> [f32; 4] {
let st = app.session.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y)
}
fn close(a: [f32; 4], b: [f32; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1.5 / 255.0)
}
/// Open the dialog, set `fields`, press OK.
fn fill(app: &mut PhotocraftApp, fields: Value) -> Result<Value, String> {
let id = open(app);
let d = app.ui.dialog_mut(id).unwrap();
for (k, v) in fields.as_object().unwrap() {
d.fields.insert(k.clone(), v.clone());
}
crate::dialogs::confirm(app, id)
}
#[test]
fn every_contents_option_fills() {
let mut app = app();
for (contents, want) in [
("foreground", [1.0, 0.0, 0.0, 1.0]),
("background", [0.0, 0.0, 1.0, 1.0]),
("black", [0.0, 0.0, 0.0, 1.0]),
("gray", [128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]),
("white", [1.0; 4]),
] {
fill(&mut app, json!({ "contents": contents })).unwrap();
assert!(close(px(&app, 3, 3), want), "{contents}: {:?}", px(&app, 3, 3));
}
fill(&mut app, json!({"contents": "color", "color": "#00ff00"})).unwrap();
assert!(close(px(&app, 3, 3), [0.0, 1.0, 0.0, 1.0]));
// History: the default source is the opening state (the empty layer).
fill(&mut app, json!({"contents": "history"})).unwrap();
assert_eq!(px(&app, 3, 3)[3], 0.0);
let pattern = app.session.patterns.items.first().map(|p| p.id.clone()).unwrap();
fill(&mut app, json!({"contents": "pattern", "pattern": pattern})).unwrap();
assert!(px(&app, 3, 3)[3] > 0.0);
// Content-Aware fills a selected hole from around it.
fill(&mut app, json!({"contents": "color", "color": "#336699"})).unwrap();
app.run("select.rect", json!({"x": 15, "y": 10, "width": 6, "height": 6})).unwrap();
fill(&mut app, json!({"contents": "white"})).unwrap();
fill(&mut app, json!({"contents": "contentAware", "colorAdaptation": false})).unwrap();
assert!(close(px(&app, 17, 12), [0.2, 0.4, 0.6, 1.0]), "{:?}", px(&app, 17, 12));
// Each fill is one history step.
assert_eq!(app.session.active().unwrap().history.past_len(), 12);
}
#[test]
fn mode_opacity_and_preserve_transparency() {
let mut app = app();
app.run("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap();
fill(&mut app, json!({"contents": "white"})).unwrap();
app.run("select.deselect", json!({})).unwrap();
fill(&mut app, json!({"contents": "black", "opacity": 50, "preserveTransparency": true})).unwrap();
assert!(close(px(&app, 5, 5), [0.5, 0.5, 0.5, 1.0]), "{:?}", px(&app, 5, 5));
assert_eq!(px(&app, 30, 5)[3], 0.0, "transparent pixels stay transparent");
fill(&mut app, json!({"contents": "white", "opacity": 100, "mode": "multiply", "preserveTransparency": true})).unwrap();
assert!(close(px(&app, 5, 5), [0.5, 0.5, 0.5, 1.0]));
}
#[test]
fn choices_persist_across_restarts() {
let mut app = app();
// Photoshop's defaults the first time.
let f = fields(&app);
assert_eq!((f["contents"].as_str(), f["mode"].as_str(), f["opacity"].as_f64()), (Some("foreground"), Some("normal"), Some(100.0)));
fill(&mut app, json!({"contents": "gray", "mode": "multiply", "opacity": 40, "preserveTransparency": true})).unwrap();
// A fresh app with the saved preferences opens the dialog with the same choices.
let saved = app.session.prefs_to_json();
let mut s = photocraft_engine::Session::new();
s.load_prefs_json(&saved).unwrap();
let mut again = PhotocraftApp::new(s, Default::default());
again.run("file.new", json!({"width": 10, "height": 10})).unwrap();
let f = fields(&again);
assert_eq!(f["contents"], json!("gray"));
assert_eq!(f["mode"], json!("multiply"));
assert_eq!(f["opacity"].as_f64(), Some(40.0));
assert_eq!(f["preserveTransparency"], json!(true));
// A corrupt remembered value falls back to the default.
again.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"contents": "plaid", "opacity": "lots", "mode": 7})));
let f = fields(&again);
assert_eq!((f["contents"].as_str(), f["mode"].as_str(), f["opacity"].as_f64()), (Some("foreground"), Some("normal"), Some(100.0)));
again.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!("not an object")));
assert_eq!(fields(&again)["contents"], json!("foreground"));
}
#[test]
fn bad_fields_fail_gracefully() {
let mut app = app();
for f in [json!({"contents": "plaid"}), json!({"mode": "sideways"}), json!({"opacity": "x"}), json!({"contents": "contentAware"})] {
assert!(fill(&mut app, f.clone()).is_err(), "{f}");
}
assert!(app.ui.dialogs.is_empty(), "OK closes the dialog even when the fill fails");
}
fn harness() -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 120})).unwrap();
app
});
h.run_steps(6);
h
}
fn press(h: &mut Harness<'_, PhotocraftApp>, key: Key, m: Modifiers) {
let c = h.state().last_canvas_rect.center();
h.hover_at(c);
h.run_steps(1);
h.event(egui::Event::ModifiersChanged(m));
h.event(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m });
h.event(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m });
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(3);
}
#[test]
fn shift_f5_and_shift_backspace_open_the_dialog() {
for key in [Key::F5, Key::Backspace] {
let mut h = harness();
press(&mut h, key, Modifiers::SHIFT);
let open: Vec<_> = h.state().ui.dialogs.iter().filter(|d| owns(&d.fields)).map(|d| d.id).collect();
assert_eq!(open.len(), 1, "{key:?} opens the Fill dialog");
// Nothing is filled until OK.
assert_eq!(h.state().session.active().unwrap().history.past_len(), 0);
let id = open[0];
crate::dialogs::confirm(h.state_mut(), id).unwrap();
assert_eq!(h.state().session.active().unwrap().history.past_len(), 1);
}
}
#[test]
fn menu_opens_the_dialog_and_params_skip_it() {
let mut h = harness();
let ctx = h.ctx.clone();
let r = crate::menus::invoke(h.state_mut(), &ctx, COMMAND, json!({})).unwrap();
assert!(r["dialog"].is_u64());
// With params (agents, actions), the engine fills directly.
crate::menus::invoke(h.state_mut(), &ctx, COMMAND, json!({"contents": "black"})).unwrap();
assert_eq!(h.state().session.active().unwrap().history.past_len(), 1);
// No document: an error, not a dialog.
let mut empty = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
assert!(crate::menus::invoke(&mut empty, &ctx, COMMAND, json!({})).is_err());
assert!(empty.ui.dialogs.is_empty());
}
+1
View File
@@ -68,6 +68,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("panel-right", include_bytes!("../../../assets/icons/panel-right.svg")),
("panels-top-left", include_bytes!("../../../assets/icons/panels-top-left.svg")),
("pen-tool", include_bytes!("../../../assets/icons/pen-tool.svg")),
("pencil", include_bytes!("../../../assets/icons/pencil.svg")),
("pentagon", include_bytes!("../../../assets/icons/pentagon.svg")),
("pipette", include_bytes!("../../../assets/icons/pipette.svg")),
("play", include_bytes!("../../../assets/icons/play.svg")),
+1
View File
@@ -48,6 +48,7 @@ pub fn tool_icon(t: Tool) -> &'static str {
Tool::RectMarquee => "square-dashed",
Tool::EllipseMarquee => "circle-dashed",
Tool::Brush => "brush",
Tool::Pencil => "pencil",
Tool::Eraser => "eraser",
Tool::BackgroundEraser => "eraser-background",
Tool::MagicEraser => "eraser-magic",
+5
View File
@@ -48,6 +48,7 @@ pub mod eraser_ui;
pub mod export_dialog;
pub mod file_open;
pub mod file_ui;
pub mod fill_ui;
pub mod filter_dialog;
pub mod gallery_ui;
pub mod gpu_canvas;
@@ -86,6 +87,7 @@ pub mod preset_panels;
pub mod props_layout;
pub mod proxy;
pub mod puppet_ui;
pub mod rasterize_prompt;
pub mod retouch_ui;
pub mod rulers;
pub mod shortcut_dispatch;
@@ -1201,6 +1203,9 @@ impl PhotocraftApp {
#[cfg(test)]
mod input_tests;
#[cfg(test)]
mod pencil_tests;
#[cfg(test)]
mod marquee_tests;
+7
View File
@@ -312,6 +312,13 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
// Photoshop's "Select and Mask…" is the engine's select.refineEdge.
// Select › Color Range… from the menu: the dialog (with params: the engine directly).
"select.colorRange" if params.as_object().is_none_or(|o| o.is_empty()) => Ok(json!({"dialog": crate::color_range_ui::open(app)})),
// Edit › Fill… from the menu or its shortcuts: the Fill dialog (with params: the engine).
crate::fill_ui::COMMAND if params.as_object().is_none_or(|o| o.is_empty()) => {
if let Some(Err(why)) = photocraft_engine::commands::find(id).map(|c| (c.enabled)(&app.session)) {
return Err(why);
}
Ok(json!({"dialog": crate::fill_ui::open(app)}))
}
"select.selectAndMask" => Ok(json!({"dialog": crate::filter_dialog::open(app, "select.refineEdge")})),
// Select › Transform Selection from the menu: the interactive box (with params: the engine).
"select.transformSelection" if params.as_object().is_none_or(|o| o.is_empty()) => {
+1 -1
View File
@@ -15,7 +15,7 @@ use crate::state::Tool;
/// 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) {
if !matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser) || app.ui.smoothing_tool == Some(tool) {
return;
}
let current = app.session.tools.brush.smoothing.clone();
+28 -2
View File
@@ -25,7 +25,7 @@ const TOOL_SECTIONS: &[&[&[Tool]]] = &[
],
&[
&[Tool::SpotHealing, Tool::Healing],
&[Tool::Brush],
&[Tool::Brush, Tool::Pencil],
&[Tool::CloneStamp],
&[Tool::HistoryBrush],
&[Tool::Eraser, Tool::BackgroundEraser, Tool::MagicEraser],
@@ -423,7 +423,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
let tool = app.ui.tool;
// Brush edits here go through `tools.setBrush`, one journal entry per gesture (Rule 1).
if (tool.is_brushlike() && !matches!(tool, Tool::Brush | Tool::Eraser)) || tool == Tool::QuickSelection {
if (tool.is_brushlike() && !matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser)) || tool == Tool::QuickSelection {
let before = app.session.tools.brush.clone();
let mut b = before.clone();
let pick = brush_preset_chip(ui, &mut b, &app.session.tools.presets);
@@ -479,6 +479,32 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
let _ = icons::button(ui, "arrow-left-right", 24.0, false, tl!("Set painting symmetry options"));
}
// Pencil: Photoshop's options (no hardness or flow: the pencil is always hard).
Tool::Pencil => {
picked = brush_preset_chip(ui, b, &app.session.tools.presets);
crate::brush_picker::settings_toggle(app, ui);
widgets::vline(ui, 22.0);
if !t.pro {
opt_label(ui, "Size");
widgets::value_field(ui, &mut b.size, 1.0..=5000.0, "px", 76.0);
widgets::vline(ui, 22.0);
}
opt_label(ui, "Mode");
let mut mode = b.mode;
let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect();
if widgets::dropdown(ui, "pencil-mode", &mut mode, &opts, 96.0) {
b.mode = mode;
}
opt_label(ui, "Opacity");
let mut o = b.opacity * 100.0;
if widgets::value_field(ui, &mut o, 0.0..=100.0, "%", if t.pro { 62.0 } else { 66.0 }).changed() {
b.opacity = o / 100.0;
}
opt_label(ui, "Smoothing");
smoothing_field(ui, b, if t.pro { 58.0 } else { 66.0 });
widgets::vline(ui, 22.0);
widgets::checkbox(ui, &mut app.ui.tool_options.pencil_auto_erase, "Auto Erase");
}
Tool::Brush | Tool::Eraser => {
picked = brush_preset_chip(ui, b, &app.session.tools.presets);
crate::brush_picker::settings_toggle(app, ui);
+164
View File
@@ -0,0 +1,164 @@
//! The Pencil tool (#213): aliased strokes through the live-stroke path, Auto Erase, the square
//! pixel-grid cursor, ⇧-click lines, Control+Alt resizing and the B group.
use egui::{Key, Modifiers, Pos2, Rect, vec2};
use egui_kittest::Harness;
use serde_json::json;
use crate::PhotocraftApp;
use crate::canvas::{ToolEvent, ViewXform, pencil_cursor_rect, tool_event};
use crate::state::Tool;
fn app() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 120, "height": 80, "background": "transparent"})).unwrap();
app.run("tools.setColors", json!({"foreground": "#000000", "background": "#ffffff"})).unwrap();
app.run("tools.setBrush", json!({"brush": {"size": 3, "hardness": 0.0, "smoothing": {"amount": 0.0}}})).unwrap();
app.ui.tool = Tool::Pencil;
app
}
fn layer(app: &PhotocraftApp) -> photocraft_engine::doc::Layer {
let st = app.session.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().clone()
}
fn rgba(app: &PhotocraftApp, x: i32, y: i32) -> [f32; 4] {
layer(app).surface().unwrap().rgba(x, y)
}
fn drag(app: &mut PhotocraftApp, pts: &[(f64, f64)], mods: Modifiers) {
let (x, y) = pts[0];
tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, mods);
for &(x, y) in &pts[1..] {
tool_event(app, ToolEvent::Move { x, y, pressure: 1.0 }, mods);
}
let &(x, y) = pts.last().unwrap();
tool_event(app, ToolEvent::Up { x, y }, mods);
}
fn last(app: &PhotocraftApp) -> (String, serde_json::Value) {
app.session.journal.last().cloned().unwrap()
}
#[test]
fn pencil_strokes_are_aliased_live_and_one_undo_step() {
let mut app = app();
let pts = [(10.0, 10.0), (40.3, 22.7), (70.0, 50.0), (100.0, 30.0)];
let (x, y) = pts[0];
tool_event(&mut app, ToolEvent::Down { x, y, pressure: 1.0 }, Modifiers::NONE);
for &(x, y) in &pts[1..] {
tool_event(&mut app, ToolEvent::Move { x, y, pressure: 1.0 }, Modifiers::NONE);
}
// The canvas shows the engine's real dabs while dragging.
assert!(app.live_stroke.is_some(), "the Pencil strokes live");
tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 30.0 }, Modifiers::NONE);
let (id, p) = last(&app);
assert_eq!(id, "paint.pencil");
assert_eq!(p["autoErase"], json!(false));
assert_eq!(app.session.active().unwrap().history.past_len(), 1);
// 8 bits, a soft session brush: still no partial alpha anywhere.
let s = layer(&app).surface().unwrap().clone();
let mut painted = 0;
for y in 0..80 {
for x in 0..120 {
let a = s.rgba(x, y)[3];
assert!(a == 0.0 || a == 1.0, "partial alpha {a} at ({x},{y})");
painted += usize::from(a == 1.0);
}
}
assert!(painted > 200, "{painted}");
assert_eq!(rgba(&app, 70, 50), [0.0, 0.0, 0.0, 1.0]);
}
#[test]
fn auto_erase_paints_the_background_colour_from_foreground_pixels() {
let mut app = app();
drag(&mut app, &[(10.0, 40.0), (110.0, 40.0)], Modifiers::NONE);
app.ui.tool_options.pencil_auto_erase = true;
// Starting on the black line: white.
drag(&mut app, &[(50.0, 40.0), (50.0, 70.0)], Modifiers::NONE);
assert_eq!(rgba(&app, 50, 60), [1.0; 4]);
assert_eq!(last(&app).1["autoErase"], json!(true));
// Starting off it: black.
drag(&mut app, &[(80.0, 10.0), (80.0, 30.0)], Modifiers::NONE);
assert_eq!(rgba(&app, 80, 20), [0.0, 0.0, 0.0, 1.0]);
}
#[test]
fn shift_click_draws_a_pencil_line() {
let mut app = app();
drag(&mut app, &[(10.0, 10.0), (20.0, 10.0)], Modifiers::NONE);
drag(&mut app, &[(100.0, 60.0)], Modifiers::SHIFT);
let (id, p) = last(&app);
assert_eq!(id, "paint.pencil");
assert_eq!(p["points"].as_array().unwrap().len(), 2, "{p}");
assert_eq!(rgba(&app, 60, 35)[3], 1.0);
assert_eq!(app.session.active().unwrap().history.past_len(), 2);
}
#[test]
fn ctrl_alt_drag_resizes_the_pencil_without_painting() {
let mut app = app();
let ctrl_alt = Modifiers { alt: true, ctrl: true, ..Default::default() };
assert!(crate::brush_resize::applies(Tool::Pencil));
drag(&mut app, &[(50.0, 40.0), (60.0, 40.0)], ctrl_alt);
assert_eq!(app.session.tools.brush.size, 23.0);
assert_eq!(app.session.active().unwrap().history.past_len(), 0);
}
#[test]
fn square_cursor_sits_on_the_pixel_grid() {
let rect = Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(800.0, 600.0));
let xf = ViewXform { rect, zoom: 8.0, center: [50.0, 37.5], flip: false };
// 1 px at 800 %: the 8-point square of the pixel under the pointer.
let r = pencil_cursor_rect(&xf, [10.3, 5.7], 1.0, 1.0);
assert_eq!(r, Rect::from_two_pos(xf.to_screen(10.0, 5.0), xf.to_screen(11.0, 6.0)));
assert_eq!(r.size(), vec2(8.0, 8.0));
// Even sizes centre on the nearest pixel corner; odd ones on the pixel.
let r = pencil_cursor_rect(&xf, [10.3, 5.7], 4.0, 1.0);
assert_eq!(r, Rect::from_two_pos(xf.to_screen(8.0, 4.0), xf.to_screen(12.0, 8.0)));
let r = pencil_cursor_rect(&xf, [10.9, 5.1], 3.0, 1.0);
assert_eq!(r, Rect::from_two_pos(xf.to_screen(9.0, 4.0), xf.to_screen(12.0, 7.0)));
// At 2× and at an odd zoom the edges land on physical pixels.
for (zoom, ppp) in [(3.3, 2.0), (0.5, 2.0), (1.0, 1.0), (13.7, 1.5)] {
let xf = ViewXform { rect: Rect::from_min_size(Pos2::new(0.3, 0.7), vec2(800.0, 600.0)), zoom, center: [50.2, 37.9], flip: true };
let r = pencil_cursor_rect(&xf, [20.4, 30.6], 5.0, ppp);
for v in [r.min.x, r.min.y, r.max.x, r.max.y] {
assert!((v * ppp - (v * ppp).round()).abs() < 1e-3, "zoom {zoom} ppp {ppp}: {v}");
}
assert!((r.width() - 5.0 * zoom).abs() <= 1.0 / ppp + 1e-3, "{r:?}");
}
// Hostile numbers don't panic.
let _ = pencil_cursor_rect(&xf, [f64::NAN, f64::INFINITY], f32::NAN, f32::NAN);
}
#[test]
fn b_cycles_brush_and_pencil() {
let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 120})).unwrap();
app
});
h.state_mut().ui.tool = Tool::Move;
h.run_steps(4);
let press = |h: &mut Harness<'_, PhotocraftApp>, m: Modifiers| {
h.event(egui::Event::Key { key: Key::B, physical_key: None, pressed: true, repeat: false, modifiers: m });
h.event(egui::Event::Key { key: Key::B, physical_key: None, pressed: false, repeat: false, modifiers: m });
h.run_steps(2);
};
press(&mut h, Modifiers::NONE);
assert_eq!(h.state().ui.tool, Tool::Brush);
press(&mut h, Modifiers::NONE);
assert_eq!(h.state().ui.tool, Tool::Pencil);
// Use Shift Key for Tool Switch: ⇧B cycles, B keeps the group's tool.
h.state_mut().session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = true);
press(&mut h, Modifiers::SHIFT);
assert_eq!(h.state().ui.tool, Tool::Brush);
press(&mut h, Modifiers::SHIFT);
assert_eq!(h.state().ui.tool, Tool::Pencil);
press(&mut h, Modifiers::NONE);
assert_eq!(h.state().ui.tool, Tool::Pencil);
assert_eq!(Tool::from_name("pencil"), Some(Tool::Pencil));
}
+161
View File
@@ -0,0 +1,161 @@
//! "Rasterize?" prompt: painting the pixels of a type, shape, Smart Object or fill layer with a
//! pixel tool asks first, as Photoshop does ("This type layer must be rasterized before
//! proceeding. Its text will no longer be editable. Rasterize the type?"), instead of failing
//! with a status-bar message.
//!
//! The prompt is an ordinary dialog in the UI state (`ui.inspect` shows it, `ui.dialog.confirm`
//! and `ui.dialog.cancel` answer it). OK runs the matching `layer.rasterize.*` command, then paints
//! where the click was, so rasterizing and painting are two history states, as in Photoshop.
//! Cancel does nothing.
use serde_json::{Map, Value, json};
use crate::PhotocraftApp;
use crate::canvas::ToolEvent;
use crate::state::{DialogKind, Tool};
const MARK: &str = "__rasterize";
/// A layer kind that must be rasterized before its pixels can be painted.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Type,
Shape,
SmartObject,
Fill,
}
impl Kind {
pub const ALL: [Kind; 4] = [Kind::Type, Kind::Shape, Kind::SmartObject, Kind::Fill];
fn key(self) -> &'static str {
match self {
Kind::Type => "type",
Kind::Shape => "shape",
Kind::SmartObject => "smartObject",
Kind::Fill => "fill",
}
}
/// The command that rasterizes it.
pub fn command(self) -> &'static str {
match self {
Kind::Type => "layer.rasterize.type",
Kind::Shape => "layer.rasterize.shape",
Kind::SmartObject => "layer.rasterize.smartObject",
Kind::Fill => "layer.rasterize.fillContent",
}
}
/// Photoshop's question.
pub fn message(self) -> &'static str {
match self {
Kind::Type => "This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type?",
Kind::Shape => "This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape?",
Kind::SmartObject => "This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object?",
Kind::Fill => "This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer?",
}
}
fn from_key(k: &str) -> Option<Kind> {
Kind::ALL.into_iter().find(|x| x.key() == k)
}
}
/// Does `tool` paint the layer's pixels (so a vector or Smart Object layer must be rasterized)?
pub fn paints_pixels(app: &PhotocraftApp, tool: Tool) -> bool {
tool.is_brushlike() || matches!(tool, Tool::PaintBucket | Tool::MagicEraser) || (tool == Tool::Gradient && app.ui.tool_options.gradient_classic)
}
/// The active layer's kind when painting its pixels needs rasterizing first: the tool targets
/// the layer's pixels (not its mask, an alpha channel or the Quick Mask) and the layer is a
/// type, shape, Smart Object or fill layer that isn't locked.
pub fn needed(app: &PhotocraftApp) -> Option<(Kind, photocraft_doc::LayerId)> {
if crate::canvas::paint_target(app) != json!("pixels") {
return None;
}
let st = app.session.active()?;
let id = st.active_layer?;
let l = st.doc.layer(id)?;
if l.locks.all || l.locks.pixels {
return None;
}
use photocraft_doc::LayerContent as C;
let kind = match &l.content {
C::Text(_) => Kind::Type,
C::Shape(_) => Kind::Shape,
C::Smart(_) => Kind::SmartObject,
C::Fill(_) => Kind::Fill,
_ => return None,
};
Some((kind, id))
}
/// A tool's pointer press: when it would paint a layer that must be rasterized first, park it
/// behind the prompt and return true (the tool must not see the press).
pub fn intercept(app: &mut PhotocraftApp, tool: Tool, x: f64, y: f64, pressure: f32) -> bool {
if !paints_pixels(app, tool) {
return false;
}
let Some((kind, layer)) = needed(app) else { return false };
if !app.ui.dialogs.iter().any(|d| owns(&d.fields)) {
open(app, kind, layer, tool, [x, y, f64::from(pressure)]);
}
true
}
/// Open the prompt for `layer`; OK rasterizes it, then `tool` paints at `at` (x, y, pressure).
pub fn open(app: &mut PhotocraftApp, kind: Kind, layer: photocraft_doc::LayerId, tool: Tool, at: [f64; 3]) -> u64 {
let mut f = Map::new();
f.insert(MARK.into(), json!(kind.key()));
f.insert("__command".into(), json!(kind.command()));
f.insert("__label".into(), json!("PhotoCraft"));
f.insert("message".into(), json!(kind.message()));
f.insert("layer".into(), json!(layer.0));
f.insert("tool".into(), json!(format!("{tool:?}")));
f.insert("at".into(), json!(at));
app.ui.open_dialog(DialogKind::Command, f)
}
/// Is this dialog the prompt?
pub fn owns(fields: &Map<String, Value>) -> bool {
fields.contains_key(MARK)
}
/// The prompt's body: the question, with a caution mark.
pub fn body(ui: &mut egui::Ui, f: &Map<String, Value>) {
let t = crate::theme::Tokens::get(ui.ctx());
let text = f.get("message").and_then(Value::as_str).unwrap_or("This layer must be rasterized before proceeding.");
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 12.0;
let (r, _) = ui.allocate_exact_size(egui::vec2(32.0, 32.0), egui::Sense::hover());
crate::icons::paint(ui, r, "triangle-alert", 30.0, t.warning);
ui.add(egui::Label::new(egui::RichText::new(text).color(t.text).size(13.0)).wrap());
});
}
/// OK: rasterize, then paint at the click (two history states).
pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
let kind = f.get(MARK).and_then(Value::as_str).and_then(Kind::from_key).ok_or("not a rasterize prompt")?;
let layer = f.get("layer").and_then(Value::as_u64).ok_or("the prompt has no layer")?;
let r = app.run(kind.command(), json!({ "layer": layer }))?;
let tool = f.get("tool").and_then(Value::as_str).and_then(Tool::from_name);
let at: Vec<f64> = f.get("at").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
let (Some(tool), Some(&x), Some(&y)) = (tool, at.first(), at.get(1)) else {
return Ok(json!({ "rasterized": r, "painted": false }));
};
let pressure = at.get(2).copied().unwrap_or(1.0) as f32;
// The same tool, layer and point as when the prompt opened.
app.ui.tool = tool;
let before = app.session.active().map(|st| st.history.past_len());
if app.session.active().and_then(|st| st.active_layer) == Some(photocraft_doc::LayerId(layer)) && x.is_finite() && y.is_finite() {
crate::canvas::tool_event(app, ToolEvent::Down { x, y, pressure: pressure.clamp(0.0, 1.0) }, egui::Modifiers::NONE);
crate::canvas::tool_event(app, ToolEvent::Up { x, y }, egui::Modifiers::NONE);
}
let painted = app.session.active().map(|st| st.history.past_len()) != before;
Ok(json!({ "rasterized": r, "painted": painted }))
}
#[cfg(test)]
#[path = "rasterize_prompt_tests.rs"]
mod tests;
@@ -0,0 +1,167 @@
use egui::Modifiers;
use photocraft_doc::LayerContent;
use serde_json::json;
use super::*;
use crate::canvas::tool_event;
/// A document whose active layer is a `kind` layer over a white Background.
fn app_with(kind: Kind) -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 120, "height": 80})).unwrap();
match kind {
Kind::Type => app.run("type.create", json!({"x": 10, "y": 40, "text": "Hello"})).map(|_| ()),
Kind::Shape => app.run("shape.create", json!({"kind": "rect", "rect": [10, 10, 60, 40], "fill": "#ff0000"})).map(|_| ()),
Kind::SmartObject => {
app.run("layer.new.layer", json!({})).unwrap();
app.run("edit.fill", json!({"color": "#00ff00"})).unwrap();
app.run("layer.smartObjects.convertToSmartObject", json!({})).map(|_| ())
}
Kind::Fill => app.run("layer.newFillLayer.solidColor", json!({"color": "#00ff00"})).map(|_| ()),
}
.unwrap();
app.run("tools.setColors", json!({"foreground": "#0000ff"})).unwrap();
app
}
fn active_content(app: &PhotocraftApp) -> &'static str {
let st = app.session.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().content.kind_name()
}
fn past(app: &PhotocraftApp) -> usize {
app.session.active().unwrap().history.past_len()
}
fn click(app: &mut PhotocraftApp, x: f64, y: f64) {
tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, Modifiers::NONE);
tool_event(app, ToolEvent::Up { x, y }, Modifiers::NONE);
}
fn prompt(app: &PhotocraftApp) -> Option<&crate::state::Dialog> {
app.ui.dialogs.iter().find(|d| owns(&d.fields))
}
#[test]
fn ok_rasterizes_then_paints_as_two_history_states() {
for kind in Kind::ALL {
for tool in [Tool::Brush, Tool::Pencil] {
let mut app = app_with(kind);
app.ui.tool = tool;
let before = past(&app);
click(&mut app, 30.0, 20.0);
let d = prompt(&app).unwrap_or_else(|| panic!("{kind:?}/{tool:?}: no prompt"));
assert_eq!(d.fields["message"], json!(kind.message()));
assert_eq!(past(&app), before, "nothing happens until the answer");
let id = d.id;
let r = crate::dialogs::confirm(&mut app, id).unwrap();
assert_eq!(r["painted"], json!(true), "{kind:?}/{tool:?}");
assert_eq!(active_content(&app), "Pixel");
assert_eq!(past(&app), before + 2, "{kind:?}/{tool:?}: rasterize, then paint");
let st = app.session.active().unwrap();
let c = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(30, 20);
assert!(c[2] > 0.99 && c[0] < 0.01, "{kind:?}/{tool:?}: painted blue at the click: {c:?}");
// One undo takes the paint back; the layer stays rasterized.
app.session.undo();
assert_eq!(active_content(&app), "Pixel");
app.session.undo();
assert_eq!(active_content(&app), kind_name(kind));
}
}
}
fn kind_name(k: Kind) -> &'static str {
match k {
Kind::Type => "Type",
Kind::Shape => "Shape",
Kind::SmartObject => "Smart Object",
Kind::Fill => "Fill",
}
}
#[test]
fn cancel_does_nothing() {
let mut app = app_with(Kind::Type);
app.ui.tool = Tool::Brush;
let before = (past(&app), app.session.active().unwrap().doc.clone());
click(&mut app, 30.0, 20.0);
let id = prompt(&app).unwrap().id;
// A second press while it is up doesn't stack another prompt.
click(&mut app, 40.0, 20.0);
assert_eq!(app.ui.dialogs.len(), 1);
app.ui.close_dialog(id).unwrap();
assert_eq!(past(&app), before.0);
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &before.1) || *app.session.active().unwrap().doc == *before.1);
assert_eq!(active_content(&app), "Type");
}
#[test]
fn only_pixel_painting_asks() {
let mut app = app_with(Kind::Type);
for tool in [Tool::Move, Tool::RectMarquee, Tool::Eyedropper, Tool::Hand, Tool::Type] {
app.ui.tool = tool;
click(&mut app, 30.0, 20.0);
assert!(prompt(&app).is_none(), "{tool:?} must not ask");
app.ui.text_edit = None;
}
for tool in [Tool::Eraser, Tool::CloneStamp, Tool::PaintBucket, Tool::Smudge] {
app.ui.tool = tool;
click(&mut app, 30.0, 20.0);
let id = prompt(&app).unwrap_or_else(|| panic!("{tool:?} asks")).id;
app.ui.close_dialog(id);
}
// ⌥-click with the Clone Stamp sets the source.
app.ui.tool = Tool::CloneStamp;
tool_event(&mut app, ToolEvent::Down { x: 30.0, y: 20.0, pressure: 1.0 }, Modifiers::ALT);
assert!(prompt(&app).is_none());
// Painting the layer's mask needs no rasterizing.
app.run("layer.layerMask.revealAll", json!({})).unwrap();
app.ui.mask_target = true;
app.ui.tool = Tool::Brush;
click(&mut app, 30.0, 20.0);
assert!(prompt(&app).is_none());
assert!(app.session.active().unwrap().doc.layers.iter().any(|l| matches!(l.content, LayerContent::Text(_))));
}
#[test]
fn a_stale_or_bad_prompt_fails_gracefully() {
let mut app = app_with(Kind::Type);
app.ui.tool = Tool::Brush;
click(&mut app, 30.0, 20.0);
let id = prompt(&app).unwrap().id;
// The layer was rasterized elsewhere meanwhile: OK reports the error, nothing panics.
app.run("layer.rasterize.type", json!({})).unwrap();
assert!(crate::dialogs::confirm(&mut app, id).is_err());
// Garbage fields.
for f in [json!({"__rasterize": "plaid", "layer": 1}), json!({"__rasterize": "type"}), json!({"__rasterize": "type", "layer": 999_999})] {
let id = app.ui.open_dialog(crate::state::DialogKind::Command, f.as_object().unwrap().clone());
assert!(crate::dialogs::confirm(&mut app, id).is_err(), "{f}");
}
// A bad tool or point rasterizes without painting.
let mut app = app_with(Kind::Fill);
let layer = app.session.active().unwrap().active_layer.unwrap();
let id = open(&mut app, Kind::Fill, layer, Tool::Brush, [f64::NAN, 1.0, 1.0]);
app.ui.dialog_mut(id).unwrap().fields.insert("tool".into(), json!("no such tool"));
let r = crate::dialogs::confirm(&mut app, id).unwrap();
assert_eq!(r["painted"], json!(false));
assert_eq!(active_content(&app), "Pixel");
}
#[test]
fn the_control_channel_sees_and_confirms_the_prompt() {
let mut app = app_with(Kind::Shape);
let ctx = egui::Context::default();
app.ui.tool = Tool::Pencil;
let call = |app: &mut PhotocraftApp, method: &str, params: serde_json::Value| {
let (req, _rx) = crate::control::ControlRequest::new(method, params);
match crate::control::handle(app, &ctx, &req) {
crate::control::Outcome::Done(v) => v,
_ => panic!("deferred"),
}
};
call(&mut app, "ui.pointer", json!({"events": [{"kind": "down", "x": 30, "y": 20}, {"kind": "up", "x": 30, "y": 20}]}));
let id = prompt(&app).map(|d| d.id).expect("prompt");
let r = call(&mut app, "ui.dialog.confirm", json!({"dialog": id}));
assert_eq!(r["ok"], json!(true), "{r}");
assert_eq!(active_content(&app), "Pixel");
}
+1 -1
View File
@@ -129,7 +129,7 @@ fn pct(ui: &mut egui::Ui, label: &str, v: &mut f32) {
/// Options bar for the retouching and smart-selection tools. Returns false for other tools.
pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bool {
if !tool.is_brushlike() && !matches!(tool, Tool::QuickSelection | Tool::ObjectSelection) || matches!(tool, Tool::Brush | Tool::Eraser) {
if !tool.is_brushlike() && !matches!(tool, Tool::QuickSelection | Tool::ObjectSelection) || matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser) {
return false;
}
let o = &mut app.ui.tool_options;
+7 -1
View File
@@ -39,6 +39,10 @@ pub fn take_log(ctx: &egui::Context) -> Vec<(String, Outcome)> {
ctx.data_mut(|d| d.remove_temp::<Vec<(String, Outcome)>>(log_id())).unwrap_or_default()
}
/// Alternative default shortcuts Photoshop gives some commands besides the one its menu shows:
/// (command, shortcut). Edit › Fill… is Shift+F5 and also Shift+Backspace (Shift+Delete on a Mac).
pub const SECONDARY: &[(&str, &str)] = &[("edit.fill", "Shift+Backspace")];
/// Every key binding, most modifiers first (so ⇧⌘Z wins over ⌘Z), each key owned by one command:
/// command and shell shortcuts, then the menu catalogue's for live items without their own,
/// then Edit › Keyboard Shortcuts assignments to any other menu item. D and X
@@ -67,8 +71,10 @@ pub fn bindings(app: &PhotocraftApp) -> Vec<(String, KeyboardShortcut)> {
&& !crate::hold_keys::is_temporary(id)
})
.map(|(id, sc)| (id.as_str(), Some(sc.as_str())));
// Photoshop's second shortcuts, kept while the command's main one is the default.
let secondary = SECONDARY.iter().filter(|(id, _)| !prefs.shortcuts.contains_key(*id)).map(|&(id, sc)| (id, Some(sc)));
let mut all: Vec<(String, KeyboardShortcut)> = Vec::new();
for (id, sc) in ui.chain(engine).chain(catalog).chain(overrides) {
for (id, sc) in ui.chain(engine).chain(catalog).chain(overrides).chain(secondary) {
let Some(sc) = sc.and_then(parse) else { continue };
if !all.iter().any(|(_, b)| *b == sc) {
all.push((id.to_string(), sc));
@@ -265,6 +265,8 @@ fn common_shortcuts_work_after_clicking_panels() {
h.run_steps(2);
}
if !text {
// From another group (B again would cycle to the Pencil).
h.state_mut().ui.tool = Tool::Move;
press(&mut h, "B");
assert_eq!(h.state().ui.tool, Tool::Brush, "{place:?}: B picks the Brush");
press(&mut h, "V");
+10 -2
View File
@@ -17,6 +17,7 @@ pub enum Tool {
Note,
Count,
Brush,
Pencil,
Eraser,
BackgroundEraser,
MagicEraser,
@@ -50,7 +51,7 @@ pub enum Tool {
}
impl Tool {
pub const ALL: [Tool; 42] = [
pub const ALL: [Tool; 43] = [
Tool::Move,
Tool::RectMarquee,
Tool::EllipseMarquee,
@@ -63,6 +64,7 @@ impl Tool {
Tool::Note,
Tool::Count,
Tool::Brush,
Tool::Pencil,
Tool::Eraser,
Tool::BackgroundEraser,
Tool::MagicEraser,
@@ -101,6 +103,7 @@ impl Tool {
Tool::RectMarquee => "Rectangular Marquee Tool",
Tool::EllipseMarquee => "Elliptical Marquee Tool",
Tool::Brush => "Brush Tool",
Tool::Pencil => "Pencil Tool",
Tool::Eraser => "Eraser Tool",
Tool::BackgroundEraser => "Background Eraser Tool",
Tool::MagicEraser => "Magic Eraser Tool",
@@ -146,6 +149,7 @@ impl Tool {
matches!(
self,
Tool::Brush
| Tool::Pencil
| Tool::Eraser
| Tool::BackgroundEraser
| Tool::SpotHealing
@@ -165,7 +169,7 @@ impl Tool {
match self {
Tool::Move => 'V',
Tool::RectMarquee | Tool::EllipseMarquee => 'M',
Tool::Brush => 'B',
Tool::Brush | Tool::Pencil => 'B',
Tool::Eraser | Tool::BackgroundEraser | Tool::MagicEraser => 'E',
Tool::Eyedropper | Tool::Ruler | Tool::Note | Tool::Count => 'I',
Tool::Lasso | Tool::PolygonLasso => 'L',
@@ -384,6 +388,9 @@ pub struct ToolOptions {
/// Zoom tool › Scrubby Zoom: dragging left/right zooms continuously (else a zoom rectangle).
#[serde(default = "yes")]
pub zoom_scrubby: bool,
/// Pencil › Auto Erase: a stroke that starts on the foreground colour paints the background colour.
#[serde(default)]
pub pencil_auto_erase: bool,
}
fn yes() -> bool {
@@ -453,6 +460,7 @@ impl Default for ToolOptions {
bg_tolerance: 50.0,
bg_protect_fg: false,
zoom_scrubby: true,
pencil_auto_erase: false,
}
}
}
+2
View File
@@ -38,6 +38,8 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, maskTarget?, selectionMode?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?}`: change UI state (`selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520)
- `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}`
- Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`).
- A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing.
- `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 (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen); `space: true` holds Space (the Crop frame, marquee, lasso or shape being drawn then moves instead of growing)
- `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`
+6 -6
View File
@@ -8,8 +8,8 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|---|---:|---:|---:|---|
| [Performance](#performance) | 3 | 22 | 10 | 44 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 0 not run, 10 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 0 | 6 | 21 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 126/256, round trip ≥ 256/256 |
| [Tools](#tools) | 0 | 16 | 14 | |
| [Workspace and UI](#workspace-and-ui) | 0 | 3 | 20 | settings that do nothing: 61 of 132 (target 0) |
| [Tools](#tools) | 3 | 14 | 13 | |
| [Workspace and UI](#workspace-and-ui) | 0 | 3 | 20 | settings that do nothing: 61 of 133 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 1 | 3 | 7 | 626 of 626 menu items live (100.0%) |
| [Reliability](#reliability) | 2 | 1 | 4 | never-crash lints on 26 of 26 crates; panic_hunt present |
@@ -118,12 +118,12 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| Id | Target | Status | Issue | Note |
|---|---|---|---|---|
| TOOL-213-1 | Pencil tool: aliased 1 px painting, square pixel-aligned cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.pencil exists (brush_cmds.rs:322) with an aliasing test; no Tool::Pencil (state.rs:7) |
| TOOL-213-1 | Pencil tool: aliased 1 px painting, square pixel-aligned cursor | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::Pencil (B group): live aliased strokes on the pixel grid, square cursor, Auto Erase; pencil_tests.rs |
| TOOL-213-2 | Mixer Brush tool on the toolbar | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.mixerBrush (brush_cmds.rs:337) + panel options; no Tool variant |
| TOOL-213-3 | Patch tool with a live preview of the patched area | missing | [#213](https://github.com/storytold/photocraft/issues/213) | No patch command or tool |
| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | missing | [#213](https://github.com/storytold/photocraft/issues/213) | inpaint::complete only used by spot healing (retouch_cmds.rs:386) |
| TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | missing | [#213](https://github.com/storytold/photocraft/issues/213) | Rasterize only in the layer menus (layer_menu_ui.rs:40) |
| TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | partial | [#213](https://github.com/storytold/photocraft/issues/213) | edit.fill: color or pattern + opacity, Shift+F5 (commands.rs:263-267); no blend/history/CA/persist |
| TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm |
| TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | done | [#213](https://github.com/storytold/photocraft/issues/213) | fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opacity, preserve; remembered in prefs; Shift+F5 / Shift+Backspace |
| TOOL-213-7 | Live stroke preview for every brush-based retouch tool | missing | [#213](https://github.com/storytold/photocraft/issues/213) | Live stroke only for Brush and Eraser (canvas.rs:1684) |
| TOOL-213-8 | Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Panel W/H + overlay settings (preset_panels.rs:946-1015); no link, overlay never drawn |
| TOOL-213-9 | Eyedropper comparison ring (new colour above, current below) while sampling | missing | [#213](https://github.com/storytold/photocraft/issues/213) | sample_eyedropper (canvas.rs:1552) draws no ring |
@@ -151,7 +151,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
## Workspace and UI
**Settings that do nothing: 61 of 132** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
**Settings that do nothing: 61 of 133** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
Unread: `general.color_picker`, `general.beep_when_done`, `general.export_clipboard`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `general.use_legacy_free_transform`, `interface.ui_font_size`, `interface.show_channels_in_color`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.open_documents_as_tabs`, `workspace.enable_floating_document_window_docking`, `workspace.large_tabs`, `workspace.enable_narrow_options_bar`, `tools.zoom_clicked_point_to_center`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `tools.overscroll`, `tools.double_click_layer_mask_launches_select_and_mask`, `file_handling.image_previews`, `file_handling.lowercase_extension`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.ask_before_saving_layered_tiff`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `units_and_rulers.screen_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.use_esc_to_commit`, `type_.text_engine`, `type_.font_preview`, `type_.fill_new_type_layers_with_placeholder`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `enhanced_controls.rotate_view_with_trackpad`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
+6 -6
View File
@@ -8,9 +8,9 @@ order = 3
[[item]]
id = "TOOL-213-1"
target = "Pencil tool: aliased 1 px painting, square pixel-aligned cursor"
status = "partial"
status = "done"
issue = 213
note = "paint.pencil exists (brush_cmds.rs:322) with an aliasing test; no Tool::Pencil (state.rs:7)"
note = "Tool::Pencil (B group): live aliased strokes on the pixel grid, square cursor, Auto Erase; pencil_tests.rs"
[[item]]
id = "TOOL-213-2"
@@ -36,16 +36,16 @@ note = "inpaint::complete only used by spot healing (retouch_cmds.rs:386)"
[[item]]
id = "TOOL-213-5"
target = "Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke"
status = "missing"
status = "done"
issue = 213
note = "Rasterize only in the layer menus (layer_menu_ui.rs:40)"
note = "rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm"
[[item]]
id = "TOOL-213-6"
target = "Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace"
status = "partial"
status = "done"
issue = 213
note = "edit.fill: color or pattern + opacity, Shift+F5 (commands.rs:263-267); no blend/history/CA/persist"
note = "fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opacity, preserve; remembered in prefs; Shift+F5 / Shift+Backspace"
[[item]]
id = "TOOL-213-7"