Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 c3ac5f672e test: clear texture deltas in transform gpu_ctx helper
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 07:22:38 -04:00
Brandon ThomasandClaude Opus 5.5 cc418e0b08 Move/Free Transform modifier keys (#90); sharp full-resolution transform preview (#91)
Move tool: Shift locks the drag to the dominant axis (re-evaluated with
hysteresis, so moving far enough the other way switches it); Alt at drag
start duplicates the selected layers once the pointer moves and moves the
copies, folded into one "Duplicate + Move" history step; arrow keys nudge
1/10 px (Alt duplicates first) and nudge the Free Transform box. All in a new
move_mods module with two one-line hooks in canvas::tool_event.

Free Transform: Shift on the body locks to 8 directions, Alt-click moves the
reference point, Cmd on an edge skews (Shift: along the edge, Alt: about the
centre), Cmd+Alt+Shift on a corner = perspective.

Preview (#91): the moving pixels were sampled down to 2048 px (nearest
decimation) and drawn bilinear, so a 24 MP layer previewed at 1/3
resolution. Now uploaded at full resolution (up to the device limit, box
filtered beyond), with a lazily built box-filtered mip chain for zoomed-out
views and nearest filtering at >= 200% (or Nearest Neighbor interpolation).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 07:22:38 -04:00
Brandon ThomasandClaude Opus 5.5 b9809ca63c Dock polish: Layers gets the height, Character/Paragraph group, eliding tab strips, reveal the active layer (#147, #150, #151, #152) (#181)
- #147: default-sized dock groups give way (down to compact heights) so the
  filler (Layers) keeps a preferred 500 pt: 10+ rows at 1440x900, ~5 at
  1280x720 (was ~2.5 and 0). Heights the user dragged to are untouched; the
  first splitter drag pins the other groups at the heights they show.
- #150: Window > Character / Paragraph (and Type > Panels) open a new
  Character | Paragraph dock group below Properties instead of switching
  Properties; choosing the visible one again closes it; workspace presets and
  Reset close it. Saved layouts missing a group slot it below its default
  predecessor, so it never lands under Layers.
- #151: dock tab strips (Pro and Studio) fit left of the panel menu button:
  padding shrinks, then labels elide with '...', then overflowing tabs move
  into a >> chevron menu; the selected tab always stays on the strip.
- #152: when the active layer changes by any means (canvas auto-select,
  Select commands, undo, automation) the Layers panel opens its closed parent
  groups (layer.setExpanded) and scrolls the row into view if it is hidden.
- Tests: rows visible at 900/720 pt; Character keeps Properties and toggles;
  tab rects never intersect the menu button in every theme at 1x/2x and
  180-420 pt; chevron menu switches tabs; reveal expands and scrolls (and
  doesn't scroll for visible rows); undo reveals. Adjustment-editor and
  shortcut tests no longer depend on default group heights.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:22:37 -04:00
Nigel Ihza Firdausy d94a8f617a fix(ui): avoid duplicate sliders for wide filter ranges (#179) 2026-10-05 07:22:22 -04:00
Nigel Ihza Firdausy 043e050237 fix(ui): pad Curves graph around endpoint handles (#177) 2026-10-05 07:22:07 -04:00
Nigel Ihza Firdausy d8cf255c47 fix(ui): sample eyedropper while dragging (#176) 2026-10-05 07:21:52 -04:00
Nigel Ihza Firdausy 107302f05c fix(ui): rerender Liquify proxy after undo (#175) 2026-10-05 07:21:37 -04:00
Brandon ThomasandClaude Opus 5.5 1c4cb8307c Brushes: Tilt Scale, Brush Projection, Spacing checkbox, section locks, Mixer Wetness/Mix; Brushes grid + drag and drop; options bar through tools.setBrush (#174)
* paint: Tilt Scale, Brush Projection, speed spacing, section locks, mixer settings in the brush

Shape Dynamics gains Tilt Scale (pen tilt squashes the tip height when the size
control is Pen Tilt) and Brush Projection (tilt foreshortens the tip along the
tilt direction, barrel rotation turns it). Spacing gets Photoshop's checkbox:
off spaces dabs by stroke time, so speed sets the spacing. Section locks keep a
section when another preset is picked. Transfer gains Wetness/Mix jitter, and
the brush carries the Mixer Brush options (Wet, Load, Mix, Flow, Sample All
Layers).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* engine, io: mixer settings from the brush, preset organising commands, one journal entry per setBrush gesture, ABR mapping of the new fields

- paint.mixerBrush defaults to the brush's Mixer settings (Wet/Load/Mix/Flow/Sample All Layers).
- brush.presets.rename/move/moveGroup/renameGroup/deleteGroup (drag and drop and context menus in
  the Brushes panel); the preset store index keeps the full panel order, built-ins included.
- tools.setBrush calls sharing a coalesce key journal as one entry (the patch from the brush before
  the gesture), so a slider drag is one command.
- .abr: Tilt Scale, Brush Projection, the Spacing checkbox (Intr), Wetness/Mix jitter and mixer
  tool options map instead of warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* ui: Tilt Scale, Brush Projection, Spacing checkbox, section locks, mixer controls; Brushes grid/list, drag and drop, rename/delete; options bar through tools.setBrush

- Brush Settings: Tilt Scale (enabled with Size on Pen Tilt) and Brush Projection in Shape
  Dynamics, the Spacing checkbox, Wetness/Mix Jitter plus the Mixer Brush options in Transfer,
  and a lock beside every section.
- Brushes tab (new brushes_tab.rs): list/grid toggle, drag presets within and between groups,
  drag group headers to reorder, context menus to rename or delete presets and groups; all via
  brush.presets.* commands.
- Options bar, the canvas brush picker and the [ ] keys edit the brush through tools.setBrush;
  edits of one gesture share a coalesce key, so a drag is one journal entry.
- ui.set accepts brushesView (list|grid).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:21:23 -04:00
Brandon ThomasandClaude Opus 5.5 433b84fae7 Docs: where we stand and where we're going (roadmap, README, AGENTS.md) (#186)
roadmap.md: the honest parity assessment now breaks parity down by dimension with dated
measurements (PSD oracle 115/170, round trips 169/169, shortcut audit 0 failures, perf numbers,
RAW coverage, AI 0%, ~20 missing tools...), an overall estimate (feature surface ~60-70%,
pro-switchable ~25-35%), and a priority order for what's next.
README: the status note says plainly that PhotoCraft isn't yet a daily-driver Photoshop
replacement, what the biggest gaps are, and links the assessment.
AGENTS.md: agents read the assessment first when picking work and update it when numbers move.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 06:14:53 -04:00
Brandon ThomasandClaude Opus 5.5 3389ba558b Menus scroll and navigate by keyboard on small displays; Color Range dialog on the no-GPU path (#145, #138) (#168)
* Menus on small displays: scroll arrows, keyboard navigation, reachability tests (#138)

Builds on #160 (scrollable menu levels). Each menu level is now bounded
from the menu bar down (so a top-level menu no longer slides over the
bar), shows native-style ▲/▼ scroll arrows when it overflows (resting on
one scrolls; the wheel works over them too), and the keyboard drives an
open menu: ↑/↓ move a highlight that is kept in view, → opens a submenu
(highlighting its first row) or the next menu, ← closes it or opens the
previous menu, ↩/Space run the item, Esc closes. Keyboard shortcuts
wait while a menu is open; a resting pointer no longer undoes ←/→.

Tests: every enabled row of Filter, Layer, Image and Edit is reachable
and visible at 1280×720 and 1024×600 at 1× and 1.5×, by keyboard, wheel
and the scroll arrows; submenus open/close by keyboard; ↩ runs a command.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Color Range: test the dialog in the full app without a GPU; show preview failures (#145)

The dialog opens from Select › Color Range… in the whole app on the CPU
canvas (no wgpu render state, as on a Linux adapter that can't run the
GPU canvas) at 1024×600, on screen, with both previews drawn, and OK
applies. v0.1.1 and earlier had no Color Range dialog (it shipped in
0.2.0 with #80): the menu item ran select.colorRange directly with the
foreground colour, which matches the report (a selection appears, no
window).

A preview that can't be computed used to stay silently blank; it now
logs the reason once and shows "Preview unavailable: <reason>" in the
preview (also in its accessibility label).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 06:08:25 -04:00
50 changed files with 3812 additions and 567 deletions

No files matched your search

+5 -1
View File
@@ -11,7 +11,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
| `docs/contributing.md` | Rules: clean-room, tests, layering, style, commits; the "add a command" checklist |
| `docs/control-protocol.md` | JSON control channel: how agents drive and screenshot the running app |
| `docs/ui-design.md` | Design tokens, themes, widgets, and how to match Photoshop's look |
| `docs/roadmap.md` | Milestones and the **current focus** |
| `docs/roadmap.md` | Honest parity assessment (where we're lacking, where we're going), milestones, **current focus** |
| `docs/parity.md` | Generated list of every Photoshop menu item, live or missing |
| `crates/<name>/README.md` (where present) | Public API of that crate |
@@ -67,6 +67,10 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
Priorities: important infrastructure first, then low-hanging parity, then the long tail.
0. **Read `docs/roadmap.md` → "Honest parity assessment" first.** It says, dimension by dimension,
where PhotoCraft is lacking and the priority order of where we're going. `docs/parity.md`
(menu wiring) is not a measure of behaviour. When your work moves a measured number (PSD oracle,
round trips, workflow tests, performance), update that section with the dated figure.
1. `docs/roadmap.md` → **Current focus**.
2. `cargo xtask parity` → `docs/parity.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`.
3. `log/devlog.md` → the "Still open" bullets of recent entries.
+1 -1
View File
@@ -267,7 +267,7 @@ flatpak run ai.storyteller.photocraft
Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
> [!IMPORTANT]
> **Status:** PhotoCraft is in early alpha. The core editing workflow is here, and we're working toward full Photoshop parity, milestone by milestone (see [`docs/roadmap.md`](docs/roadmap.md)). Progress is measured, not guessed: `cargo xtask parity` checks every item in Photoshop's menu tree against the live command registry and writes [`docs/parity.md`](docs/parity.md). Expect rough edges, and please file issues. You can also tell us what broke on [Discord](https://discord.gg/artcraft).
> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, about twenty missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity.md`](docs/parity.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap's parity assessment](docs/roadmap.md#honest-parity-assessment-2026-10-05). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft).
## Documentation
+91 -4
View File
@@ -337,8 +337,16 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "paint.mixerBrush";
let pts = parse_points(p, cmd)?;
let brush = resolve_brush(s, p, cmd)?;
let m = MixerSettings { wet: pct(p, "wet", 50.0), load: pct(p, "load", 50.0), mix: pct(p, "mix", 50.0), flow: pct(p, "flow", 100.0) };
let sample_all = flag(p, "sampleAllLayers", false);
// The brush's Mixer Brush options, overridden by the scalar params.
let bm = &brush.mixer;
let m = MixerSettings {
wet: pct(p, "wet", bm.wet * 100.0),
load: pct(p, "load", bm.load * 100.0),
mix: pct(p, "mix", bm.mix * 100.0),
flow: pct(p, "flow", bm.flow * 100.0),
sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers),
};
let sample_all = m.sample_all_layers;
let (clean, load_after) = (flag(p, "cleanAfterStroke", true), flag(p, "loadAfterStroke", true));
let mut state = s.tools.mixer.clone();
if load_after || state.reservoir.is_none() {
@@ -543,10 +551,89 @@ fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
}
}
b = merge_brush(&b, &patch, cmd)?;
s.tools.brush = b;
let before = std::mem::replace(&mut s.tools.brush, b);
// A coalesced gesture (one slider drag) journals as one call: remember the brush it started from.
let key = p.get("coalesce").and_then(Value::as_str).filter(|_| p.get("preset").is_none() && p.get("reset").is_none());
let continuing = |s: &Session, k: &str| {
s.tools.brush_gesture.as_ref().is_some_and(|(g, _)| g == k)
&& s.journal.last().is_some_and(|(id, lp)| id == cmd && lp.get("coalesce").and_then(Value::as_str) == Some(k))
};
match key {
Some(k) if continuing(s, k) => {}
Some(k) => s.tools.brush_gesture = Some((k.to_string(), before)),
None => s.tools.brush_gesture = None,
}
Ok(brush_json(&s.tools.brush))
}
/// The brush as JSON with the bitmaps (sampled tips, pattern tiles) that `skip` names replaced by
/// placeholders: they can be megabytes of base64, and skipped ones are equal on both sides.
fn light_json(b: &BrushSettings, skip: (bool, bool, bool)) -> Value {
let mut c = b.clone();
if skip.0 {
c.tip = TipShape::Round;
}
if skip.1 {
c.dual_brush.tip = TipShape::Round;
}
if skip.2 {
c.texture.pattern = photocraft_paint::Pattern::default();
}
serde_json::to_value(&c).unwrap_or(Value::Null)
}
/// Fields of `new` that differ from `old`, nested objects diffed key by key: the minimal
/// `tools.setBrush` `brush` patch that turns `old` into `new` (`{}` when nothing changed).
pub fn brush_patch(old: &BrushSettings, new: &BrushSettings) -> Value {
let skip = (old.tip == new.tip, old.dual_brush.tip == new.dual_brush.tip, old.texture.pattern == new.texture.pattern);
fn diff(a: &Value, b: &Value) -> Option<Value> {
match (a, b) {
(Value::Object(ao), Value::Object(bo)) => {
let mut out = serde_json::Map::new();
for (k, bv) in bo {
match ao.get(k) {
Some(av) => {
if let Some(d) = diff(av, bv) {
out.insert(k.clone(), d);
}
}
None => {
out.insert(k.clone(), bv.clone());
}
}
}
(!out.is_empty()).then_some(Value::Object(out))
}
// Enums with payloads (tips, patterns) are replaced whole.
_ => (a != b).then(|| b.clone()),
}
}
diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({}))
}
/// Journal hook ([`Session::execute`]): consecutive `tools.setBrush` calls with the same
/// `coalesce` key (one drag in the options bar or the Brush Settings panel) are one journal
/// entry, the patch from the brush before the gesture to the brush now. Returns true when the
/// call was folded into the previous entry.
pub fn coalesce_journal(s: &mut Session, id: &str, params: &Value) -> bool {
if id != "tools.setBrush" {
return false;
}
let Some(key) = params.get("coalesce").and_then(Value::as_str) else { return false };
let Some((gk, start)) = s.tools.brush_gesture.as_ref() else { return false };
if gk != key {
return false;
}
let patch = brush_patch(start, &s.tools.brush);
match s.journal.last_mut() {
Some((last, lp)) if last == id && lp.get("coalesce").and_then(Value::as_str) == Some(key) => {
*lp = json!({ "brush": patch, "coalesce": key });
true
}
_ => false,
}
}
macro_rules! spec {
($id:literal, $label:literal, $params:literal, $en:expr, $run:expr, $journal:expr) => {
CommandSpec { id: $id, label: $label, menu: &[], shortcut: None, params: $params, enabled: $en, run: $run, journal: $journal }
@@ -567,7 +654,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"paint.mixerBrush",
"Mixer Brush",
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":px?,"wet":0..100=50,"load":0..100=50,"mix":0..100=50,"flow":0..100=100,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=false,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##,
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##,
has_paintable,
mixer_brush,
true
+90
View File
@@ -311,3 +311,93 @@ fn live_stroke_matches_the_committed_stroke() {
assert!(LiveStroke::begin(&s, &json!({"points": [[1, 1]], "target": {"channel": 9}})).is_err());
assert!(LiveStroke::begin(&Session::new(), &json!({"points": [[1, 1]]})).is_err());
}
#[test]
fn new_brush_controls_round_trip_through_set_brush() {
let mut s = session(40, 40);
let patch = json!({
"spacingEnabled": false,
"shapeDynamics": {"tiltScale": 1.5, "brushProjection": true},
"transfer": {"wetness": {"jitter": 0.3, "control": "penPressure"}, "mix": {"jitter": 0.6, "minimum": 0.2}},
"locks": {"shapeDynamics": true, "texture": true, "noise": true},
"mixer": {"wet": 0.8, "load": 0.25, "mix": 0.4, "flow": 0.9, "sampleAllLayers": true},
});
s.execute("tools.setBrush", json!({ "brush": patch })).unwrap();
let b = &s.tools.brush;
assert!(!b.spacing_enabled && b.shape_dynamics.brush_projection && b.shape_dynamics.tilt_scale == 1.5);
assert_eq!(b.transfer.wetness.control, photocraft_paint::Control::PenPressure);
assert_eq!((b.transfer.mix.jitter, b.transfer.mix.minimum), (0.6, 0.2));
assert!(b.locks.shape_dynamics && b.locks.texture && b.locks.noise && !b.locks.scattering);
assert_eq!((b.mixer.wet, b.mixer.load, b.mixer.mix, b.mixer.flow, b.mixer.sample_all_layers), (0.8, 0.25, 0.4, 0.9, true));
// brush.get reports them, and a fresh session gets the same brush from the reported JSON.
let got = s.execute("brush.get", json!({})).unwrap();
for (path, want) in [
("/spacingEnabled", json!(false)),
("/shapeDynamics/tiltScale", json!(1.5)),
("/shapeDynamics/brushProjection", json!(true)),
("/transfer/wetness/control", json!("penPressure")),
("/locks/texture", json!(true)),
("/mixer/sampleAllLayers", json!(true)),
("/mixer/load", json!(0.25)),
] {
assert_eq!(got.pointer(path), Some(&want), "{path}");
}
let mut t = session(40, 40);
t.execute("tools.setBrush", json!({ "brush": got })).unwrap();
assert_eq!(t.tools.brush, s.tools.brush);
// Locked sections survive picking a preset through the command.
s.execute("tools.setBrush", json!({"preset": "Chalk"})).unwrap();
assert!(s.tools.brush.shape_dynamics.brush_projection && s.tools.brush.locks.texture);
// Wrong types are errors and leave the brush alone.
let before = s.tools.brush.clone();
for bad in
[json!({"spacingEnabled": 3}), json!({"locks": {"texture": "yes"}}), json!({"mixer": {"wet": "wet"}}), json!({"shapeDynamics": {"tiltScale": []}})]
{
assert!(s.execute("tools.setBrush", json!({ "brush": bad })).is_err());
}
assert_eq!(s.tools.brush, before);
}
#[test]
fn mixer_brush_uses_the_brush_mixer_settings() {
let mut s = session(200, 30);
s.execute("tools.setColors", json!({"foreground": "#ff0000"})).unwrap();
// No wet/load params: the brush's Mixer settings (a dry brush with 0 % Load) apply.
s.execute("tools.setBrush", json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": {"wet": 0, "load": 0, "mix": 0}}})).unwrap();
s.execute("paint.mixerBrush", json!({"points": [[5, 15], [195, 15]], "size": 8})).unwrap();
assert!(rgba(&s, 10, 15)[3] > 0.95 && rgba(&s, 190, 15)[3] < 0.5, "the brush's 0 % Load dries out");
// A param still overrides the brush.
s.execute("paint.mixerBrush", json!({"points": [[5, 5], [195, 5]], "size": 8, "load": 100})).unwrap();
assert!(rgba(&s, 190, 5)[3] > 0.95);
}
#[test]
fn coalesced_set_brush_calls_journal_once_per_gesture() {
let mut s = session(20, 20);
let n = s.journal.len();
for k in 1..=10 {
s.execute("tools.setBrush", json!({"brush": {"opacity": k as f32 / 20.0}, "coalesce": "bar:1"})).unwrap();
s.execute("brush.get", json!({})).unwrap();
}
s.execute("tools.setBrush", json!({"brush": {"shapeDynamics": {"tiltScale": 0.5}}, "coalesce": "bar:1"})).unwrap();
assert_eq!(s.journal.len(), n + 1, "one gesture, one entry");
let (id, p) = s.journal.last().unwrap();
assert_eq!(id, "tools.setBrush");
assert_eq!(p["brush"], json!({"opacity": 0.5, "shapeDynamics": {"tiltScale": 0.5}}));
// A new gesture is a new entry; so is an uncoalesced call.
s.execute("tools.setBrush", json!({"brush": {"flow": 0.5}, "coalesce": "bar:2"})).unwrap();
s.execute("tools.setBrush", json!({"brush": {"flow": 0.4}, "coalesce": "bar:2"})).unwrap();
s.execute("tools.setBrush", json!({"brush": {"size": 33}})).unwrap();
assert_eq!(s.journal.len(), n + 3);
// Another command in between ends the gesture.
s.execute("tools.setBrush", json!({"brush": {"size": 34}, "coalesce": "bar:3"})).unwrap();
s.execute("paint.stroke", json!({"points": [[2, 2], [10, 10]]})).unwrap();
s.execute("tools.setBrush", json!({"brush": {"size": 35}, "coalesce": "bar:3"})).unwrap();
assert_eq!(s.journal.len(), n + 6);
// Replay gives the same brush.
let mut t = session(20, 20);
for (id, p) in s.journal.iter().skip(1) {
t.execute(id, p.clone()).unwrap();
}
assert_eq!(t.tools.brush, s.tools.brush);
}
+336
View File
@@ -0,0 +1,336 @@
//! Organising brush presets (the Brushes panel's drag and drop and context menus): rename, move a
//! preset within or between groups, move, rename and delete whole groups.
//!
//! The library order is the panel order: groups appear in order of their first preset. Every
//! change syncs to the preset store ([`crate::preset_store`]), whose index keeps the group order
//! and the order of every preset, built-ins included. A built-in that is renamed or moved to
//! another group becomes the user's preset (built-ins are regenerated with their own group).
use photocraft_paint::BrushPreset;
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
fn always(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
/// Longest preset or group name accepted.
pub const MAX_NAME: usize = 255;
fn text(p: &Value, k: &str, cmd: &str) -> Result<String> {
let v = p.get(k).and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, format!("missing `{k}`")))?;
if v.chars().count() > MAX_NAME {
return Err(bad(cmd, format!("`{k}` is longer than {MAX_NAME} characters")));
}
Ok(v.to_string())
}
/// A group name param: may be empty (the ungrouped presets), never absurdly long.
fn group_param(p: &Value, k: &str, cmd: &str) -> Result<Option<String>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(g)) if g.chars().count() <= MAX_NAME => Ok(Some(g.trim().to_string())),
Some(_) => Err(bad(cmd, format!("`{k}` must be a group name (string, at most {MAX_NAME} characters)"))),
}
}
fn index_of(presets: &[BrushPreset], name: &str) -> Option<usize> {
presets.iter().position(|x| x.name.eq_ignore_ascii_case(name))
}
fn find(s: &Session, p: &Value, k: &str, cmd: &str) -> Result<usize> {
let name = text(p, k, cmd)?;
index_of(&s.tools.presets, &name).ok_or_else(|| bad(cmd, format!("no brush preset named `{name}`")))
}
/// Groups in panel order (first appearance).
pub fn group_order(presets: &[BrushPreset]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for p in presets {
if !out.contains(&p.group) {
out.push(p.group.clone());
}
}
out
}
fn position_in_group(presets: &[BrushPreset], i: usize) -> usize {
presets.get(i).map_or(0, |pr| presets[..i].iter().filter(|x| x.group == pr.group).count())
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "brush.presets.rename";
let i = find(s, p, "name", cmd)?;
let new = text(p, "newName", cmd)?;
if index_of(&s.tools.presets, &new).is_some_and(|j| j != i) {
return Err(bad(cmd, format!("a brush preset named `{new}` already exists")));
}
let pr = s.tools.presets.get_mut(i).ok_or_else(|| bad(cmd, "no such preset"))?;
if pr.name != new {
pr.name = new.clone();
pr.builtin = false;
s.brush_presets_changed();
}
Ok(json!({ "name": new }))
}
fn move_preset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "brush.presets.move";
let i = find(s, p, "name", cmd)?;
let before = match p.get("before") {
None | Some(Value::Null) => None,
Some(_) => {
let b = find(s, p, "before", cmd)?;
if b == i {
return Err(bad(cmd, "`before` names the preset being moved"));
}
Some(b)
}
};
let index = match p.get("index") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_u64().ok_or_else(|| bad(cmd, "`index` must be a non-negative integer"))?),
};
let lib = &mut s.tools.presets;
let target = match (group_param(p, "group", cmd)?, before) {
(Some(g), _) => g,
(None, Some(b)) => lib[b].group.clone(),
(None, None) => lib[i].group.clone(),
};
if let Some(b) = before
&& lib[b].group != target
{
return Err(bad(cmd, format!("`before` is not in group `{target}`")));
}
let before_name = before.map(|b| lib[b].name.clone());
let mut pr = lib.remove(i);
if pr.group != target {
pr.group = target.clone();
// A built-in moved elsewhere is the user's now (built-ins regenerate in their own group).
pr.builtin = false;
}
let members: Vec<usize> = lib.iter().enumerate().filter(|(_, x)| x.group == target).map(|(k, _)| k).collect();
let pos = match (before_name, index) {
(Some(n), _) => index_of(lib, &n).unwrap_or(lib.len()),
(None, Some(n)) => match members.get(n.min(usize::MAX as u64) as usize) {
Some(&k) => k,
None => members.last().map_or(lib.len(), |k| k + 1),
},
(None, None) => members.last().map_or(lib.len(), |k| k + 1),
};
let pos = pos.min(lib.len());
let name = pr.name.clone();
lib.insert(pos, pr);
let at = position_in_group(lib, pos);
s.brush_presets_changed();
Ok(json!({ "name": name, "group": target, "index": at }))
}
fn group_block(s: &Session, g: &str, cmd: &str) -> Result<()> {
if s.tools.presets.iter().any(|x| x.group == g) { Ok(()) } else { Err(bad(cmd, format!("no brush preset group named `{g}`"))) }
}
fn move_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "brush.presets.moveGroup";
let g = group_param(p, "group", cmd)?.ok_or_else(|| bad(cmd, "missing `group`"))?;
group_block(s, &g, cmd)?;
let before = group_param(p, "before", cmd)?;
if let Some(b) = &before {
group_block(s, b, cmd)?;
if *b == g {
return Err(bad(cmd, "`before` names the group being moved"));
}
}
let index = match p.get("index") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_u64().ok_or_else(|| bad(cmd, "`index` must be a non-negative integer"))?),
};
let lib = std::mem::take(&mut s.tools.presets);
let (block, mut rest): (Vec<BrushPreset>, Vec<BrushPreset>) = lib.into_iter().partition(|x| x.group == g);
let order = group_order(&rest);
let first = |name: &str, rest: &[BrushPreset]| rest.iter().position(|x| x.group == name).unwrap_or(rest.len());
let pos = match (&before, index) {
(Some(b), _) => first(b, &rest),
(None, Some(n)) => order.get(n.min(usize::MAX as u64) as usize).map_or(rest.len(), |name| first(name, &rest)),
(None, None) => rest.len(),
};
let tail = rest.split_off(pos.min(rest.len()));
rest.extend(block);
rest.extend(tail);
s.tools.presets = rest;
s.brush_presets_changed();
let at = group_order(&s.tools.presets).iter().position(|x| *x == g).unwrap_or(0);
Ok(json!({ "group": g, "index": at }))
}
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "brush.presets.renameGroup";
let g = group_param(p, "group", cmd)?.ok_or_else(|| bad(cmd, "missing `group`"))?;
group_block(s, &g, cmd)?;
let new = text(p, "newName", cmd)?;
if new != g && s.tools.presets.iter().any(|x| x.group == new) {
return Err(bad(cmd, format!("a group named `{new}` already exists")));
}
if new != g {
for x in s.tools.presets.iter_mut().filter(|x| x.group == g) {
x.group = new.clone();
x.builtin = false;
}
s.brush_presets_changed();
}
Ok(json!({ "group": new }))
}
fn delete_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "brush.presets.deleteGroup";
let g = group_param(p, "group", cmd)?.ok_or_else(|| bad(cmd, "missing `group`"))?;
group_block(s, &g, cmd)?;
let before = s.tools.presets.len();
s.tools.presets.retain(|x| x.group != g);
s.brush_presets_changed();
Ok(json!({ "deleted": before - s.tools.presets.len(), "count": s.tools.presets.len() }))
}
macro_rules! spec {
($id:literal, $label:literal, $params:literal, $run:expr) => {
CommandSpec { id: $id, label: $label, menu: &[], shortcut: None, params: $params, enabled: always, run: $run, journal: true }
};
}
/// Brush preset organisation specs.
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("brush.presets.rename", "Rename Brush", r##"{"name":string,"newName":string} → {name}"##, rename),
spec!(
"brush.presets.move",
"Move Brush",
r##"{"name":string,"group":string?=its group ("" = ungrouped),"before":name? (preset to land before) | "index":n? (position in the group)=end} → {name, group, index}"##,
move_preset
),
spec!(
"brush.presets.moveGroup",
"Move Brush Group",
r##"{"group":string,"before":group? | "index":n? (group position)=end} → {group, index}"##,
move_group
),
spec!("brush.presets.renameGroup", "Rename Brush Group", r##"{"group":string,"newName":string} → {group}"##, rename_group),
spec!("brush.presets.deleteGroup", "Delete Brush Group", r##"{"group":string} → {deleted, count}"##, delete_group),
]
}
#[cfg(test)]
mod tests {
use super::*;
fn names(s: &Session, g: &str) -> Vec<String> {
s.tools.presets.iter().filter(|x| x.group == g).map(|x| x.name.clone()).collect()
}
fn with_groups() -> Session {
let mut s = Session::new();
for (n, g) in [("A1", "Alpha"), ("A2", "Alpha"), ("A3", "Alpha"), ("B1", "Beta"), ("B2", "Beta")] {
s.tools.presets.push(BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: g.into() });
}
s
}
#[test]
fn move_within_and_between_groups() {
let mut s = with_groups();
let r = s.execute("brush.presets.move", json!({"name": "A3", "before": "A1"})).unwrap();
assert_eq!(r, json!({"name": "A3", "group": "Alpha", "index": 0}));
assert_eq!(names(&s, "Alpha"), ["A3", "A1", "A2"]);
s.execute("brush.presets.move", json!({"name": "A3", "index": 99})).unwrap();
assert_eq!(names(&s, "Alpha"), ["A1", "A2", "A3"]);
s.execute("brush.presets.move", json!({"name": "A1", "index": 1})).unwrap();
assert_eq!(names(&s, "Alpha"), ["A2", "A1", "A3"]);
// Into another group, before a member (the group comes from `before`).
s.execute("brush.presets.move", json!({"name": "A1", "before": "B2"})).unwrap();
assert_eq!((names(&s, "Alpha"), names(&s, "Beta")), (vec!["A2".to_string(), "A3".into()], vec!["B1".to_string(), "A1".into(), "B2".into()]));
// Into a group by name (appended), and into a new group.
s.execute("brush.presets.move", json!({"name": "A2", "group": "Beta"})).unwrap();
assert_eq!(names(&s, "Beta"), ["B1", "A1", "B2", "A2"]);
s.execute("brush.presets.move", json!({"name": "A3", "group": "Gamma"})).unwrap();
assert_eq!(group_order(&s.tools.presets).last().map(String::as_str), Some("Gamma"));
assert!(names(&s, "Alpha").is_empty());
// A built-in moved to another group becomes the user's.
let b = s.tools.presets.iter().find(|x| x.builtin).unwrap().name.clone();
s.execute("brush.presets.move", json!({"name": b, "group": "Beta", "index": 0})).unwrap();
let moved = photocraft_paint::presets::find(&s.tools.presets, &b).unwrap();
assert!(!moved.builtin && moved.group == "Beta");
assert_eq!(names(&s, "Beta")[0], b);
}
#[test]
fn groups_move_rename_and_delete() {
let mut s = with_groups();
let order = group_order(&s.tools.presets);
let n = order.len();
assert_eq!(&order[n - 2..], ["Alpha", "Beta"]);
let r = s.execute("brush.presets.moveGroup", json!({"group": "Beta", "index": 0})).unwrap();
assert_eq!(r["index"], 0);
assert_eq!(group_order(&s.tools.presets)[0], "Beta");
assert_eq!(names(&s, "Beta"), ["B1", "B2"], "a group moves as a block, in order");
s.execute("brush.presets.moveGroup", json!({"group": "Alpha", "before": "Beta"})).unwrap();
assert_eq!(&group_order(&s.tools.presets)[..2], ["Alpha", "Beta"]);
s.execute("brush.presets.moveGroup", json!({"group": "Alpha"})).unwrap();
assert_eq!(group_order(&s.tools.presets).last().map(String::as_str), Some("Alpha"));
s.execute("brush.presets.renameGroup", json!({"group": "Alpha", "newName": "First"})).unwrap();
assert_eq!(names(&s, "First"), ["A1", "A2", "A3"]);
assert!(s.execute("brush.presets.renameGroup", json!({"group": "First", "newName": "Beta"})).is_err());
let r = s.execute("brush.presets.deleteGroup", json!({"group": "First"})).unwrap();
assert_eq!(r["deleted"], 3);
assert!(names(&s, "First").is_empty());
}
#[test]
fn rename_checks_names() {
let mut s = with_groups();
s.execute("brush.presets.rename", json!({"name": "a1", "newName": "Alpha One"})).unwrap();
assert!(photocraft_paint::presets::find(&s.tools.presets, "Alpha One").is_some());
assert!(s.execute("brush.presets.rename", json!({"name": "A2", "newName": "alpha one"})).is_err());
// Case-only renames of itself are fine.
s.execute("brush.presets.rename", json!({"name": "Alpha One", "newName": "ALPHA ONE"})).unwrap();
let b = s.tools.presets.iter().find(|x| x.builtin).unwrap().name.clone();
s.execute("brush.presets.rename", json!({"name": b, "newName": "My Round"})).unwrap();
assert!(!photocraft_paint::presets::find(&s.tools.presets, "My Round").unwrap().builtin);
}
#[test]
fn bad_params_fail_without_changes() {
let mut s = with_groups();
let before = s.tools.presets.clone();
let long = "x".repeat(MAX_NAME + 1);
for (id, p) in [
("brush.presets.move", json!({})),
("brush.presets.move", json!({"name": "Nope"})),
("brush.presets.move", json!({"name": "A1", "before": "A1"})),
("brush.presets.move", json!({"name": "A1", "before": "Nope"})),
("brush.presets.move", json!({"name": "A1", "group": "Alpha", "before": "B1"})),
("brush.presets.move", json!({"name": "A1", "index": -1})),
("brush.presets.move", json!({"name": "A1", "index": "first"})),
("brush.presets.move", json!({"name": "A1", "group": 7})),
("brush.presets.move", json!({"name": "A1", "group": long})),
("brush.presets.rename", json!({"name": "A1"})),
("brush.presets.rename", json!({"name": "A1", "newName": " "})),
("brush.presets.rename", json!({"name": "A1", "newName": long})),
("brush.presets.moveGroup", json!({"group": "Nope"})),
("brush.presets.moveGroup", json!({"group": "Alpha", "before": "Alpha"})),
("brush.presets.moveGroup", json!({"group": "Alpha", "index": 1.5})),
("brush.presets.renameGroup", json!({"group": "Alpha"})),
("brush.presets.deleteGroup", json!({"group": null})),
("brush.presets.deleteGroup", json!({"group": "Nope"})),
] {
assert!(s.execute(id, p.clone()).is_err(), "{id} {p}");
}
assert_eq!(s.tools.presets, before);
// Huge indices clamp to the end.
s.execute("brush.presets.move", json!({"name": "A1", "index": u64::MAX})).unwrap();
s.execute("brush.presets.moveGroup", json!({"group": "Alpha", "index": u64::MAX})).unwrap();
}
}
+1
View File
@@ -940,6 +940,7 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::edit_cmds::specs());
v.extend(crate::color_cmds::specs());
v.extend(crate::brush_cmds::specs());
v.extend(crate::brush_preset_cmds::specs());
v.extend(crate::eraser_cmds::specs());
v.extend(crate::preset_import_cmds::specs());
v.extend(crate::retouch_cmds::specs());
+6 -1
View File
@@ -14,6 +14,7 @@ pub mod analysis_cmds;
pub mod artboard_cmds;
pub mod automate_cmds;
pub mod brush_cmds;
pub mod brush_preset_cmds;
pub mod build_info;
mod canvas_geom;
pub mod channel_cmds;
@@ -198,6 +199,9 @@ pub struct ToolState {
pub presets_rev: u64,
/// Mixer Brush paint carried between strokes.
pub mixer: photocraft_paint::mixer::MixerState,
/// The coalescing key of the running `tools.setBrush` gesture and the brush before it, so the
/// gesture journals as one call ([`brush_cmds::coalesce_journal`]).
pub brush_gesture: Option<(String, photocraft_paint::BrushSettings)>,
}
impl Default for ToolState {
@@ -213,6 +217,7 @@ impl Default for ToolState {
presets: photocraft_paint::presets::builtin(),
presets_rev: 0,
mixer: Default::default(),
brush_gesture: None,
}
}
}
@@ -336,7 +341,7 @@ impl Session {
edit_menu_cmds::after_command(self, id);
automate_cmds::after_command(self, id);
self.sync_preset_store();
if spec.journal {
if spec.journal && !brush_cmds::coalesce_journal(self, id, &params) {
self.journal.push((id.to_string(), params));
}
Ok(r)
+31 -6
View File
@@ -9,7 +9,8 @@
//! - `tips/<hash>.pctip`: content-addressed bitmaps, deflated, stored at 8 bits per sample when
//! the tip came from 8-bit data and 16 bits otherwise. Renaming or re-grouping a preset only
//! rewrites a small JSON file; a tip no group references any more is deleted;
//! - `index.json`: group order and the built-in presets the user deleted.
//! - `index.json`: group order, the order of every preset (built-ins included, so drag and drop
//! in the Brushes panel persists) and the built-in presets the user deleted.
//!
//! Built-in presets are never written (they are regenerated on start). The store syncs after
//! every command that changes the brush presets ([`Session::brush_presets_changed`]); only groups
@@ -324,6 +325,8 @@ struct IndexFile {
groups: Vec<String>,
/// Built-in presets the user deleted (by name).
hidden_builtins: Vec<String>,
/// Every preset's name in library (panel) order; empty when it is the default order.
order: Vec<String>,
}
/// The group's file name: a readable, sanitised prefix plus a hash of the exact name, so
@@ -403,6 +406,8 @@ pub struct Opened {
pub presets: Vec<BrushPreset>,
/// Built-in presets the user deleted.
pub hidden_builtins: Vec<String>,
/// The saved library order (preset names), empty when none was saved.
pub order: Vec<String>,
/// Files that were skipped (corrupt, oversized, missing tips).
pub warnings: Vec<String>,
}
@@ -515,8 +520,9 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
}
}
let hidden_builtins = index.as_ref().map(|i| i.hidden_builtins.clone()).unwrap_or_default();
let order = index.as_ref().map(|i| i.order.clone()).unwrap_or_default();
store.index = index;
Opened { store, presets, hidden_builtins, warnings }
Opened { store, presets, hidden_builtins, order, warnings }
}
/// Read and decode tips, in parallel on native targets.
@@ -615,10 +621,24 @@ impl PresetStore {
// Index: group order and deleted built-ins.
let hidden: Vec<String> = builtin_names().iter().filter(|n| !presets.iter().any(|p| p.name.eq_ignore_ascii_case(n))).map(|n| n.to_string()).collect();
let groups: Vec<String> = files.into_iter().filter(|f| self.groups.contains_key(f)).collect();
let want = IndexFile { version: 1, groups, hidden_builtins: hidden };
let same = self.index.as_ref().is_some_and(|i| i.groups == want.groups && i.hidden_builtins == want.hidden_builtins);
// The full order is only saved once it differs from the default (built-ins, then the rest).
let order: Vec<String> = presets.iter().map(|p| p.name.clone()).collect();
let default_order: Vec<String> = presets.iter().filter(|p| p.builtin).chain(presets.iter().filter(|p| !p.builtin)).map(|p| p.name.clone()).collect();
let builtins_in_order = {
let names = builtin_names();
let mut it = presets.iter().filter(|p| p.builtin).map(|p| names.iter().position(|n| *n == p.name));
let first = it.next().flatten();
it.try_fold(first, |prev, cur| match (prev, cur) {
(Some(a), Some(b)) if b > a => Some(Some(b)),
_ => None,
})
.is_some()
};
let order = if builtins_in_order && order == default_order { Vec::new() } else { order };
let want = IndexFile { version: 1, groups, hidden_builtins: hidden, order };
let same = self.index.as_ref().is_some_and(|i| i.groups == want.groups && i.hidden_builtins == want.hidden_builtins && i.order == want.order);
if !same {
let empty = want.groups.is_empty() && want.hidden_builtins.is_empty();
let empty = want.groups.is_empty() && want.hidden_builtins.is_empty() && want.order.is_empty();
let r = if empty && self.index.is_none() {
Ok(())
} else {
@@ -693,7 +713,7 @@ impl Session {
/// built-in of the same name; presets created before the store finished loading win), hide
/// deleted built-ins, then sync. Returns the load warnings.
pub fn attach_preset_store(&mut self, opened: Opened) -> Vec<String> {
let Opened { store, presets, hidden_builtins, mut warnings } = opened;
let Opened { store, presets, hidden_builtins, order, mut warnings } = opened;
let lib = &mut self.tools.presets;
lib.retain(|p| !(p.builtin && hidden_builtins.iter().any(|h| h.eq_ignore_ascii_case(&p.name))));
for p in presets {
@@ -703,6 +723,11 @@ impl Session {
None => lib.push(p),
}
}
// The saved panel order (stable: presets it doesn't name keep their order, at the end).
if !order.is_empty() {
let pos: HashMap<String, usize> = order.iter().enumerate().map(|(i, n)| (n.to_lowercase(), i)).collect();
lib.sort_by_key(|p| pos.get(&p.name.to_lowercase()).copied().unwrap_or(usize::MAX));
}
self.preset_store = Some(store);
self.brush_presets_changed();
self.sync_preset_store();
+35
View File
@@ -239,6 +239,41 @@ fn startup_with_many_groups_loads_them_all() {
assert_eq!(groups, (0..25).map(|g| format!("Group {g}")).collect::<Vec<_>>(), "group order survives");
}
#[test]
fn reordering_presets_and_groups_persists() {
let names = |s: &Session| s.tools.presets.iter().map(|p| (p.name.clone(), p.group.clone())).collect::<Vec<_>>();
let dir = TempDir::new("reorder");
let (mut s, _) = session(&dir);
for (n, g) in [("Ink 1", "Inks"), ("Ink 2", "Inks"), ("Wash", "Washes")] {
s.execute("brush.presets.save", json!({"name": n})).unwrap();
s.tools.presets.iter_mut().find(|p| p.name == n).unwrap().group = g.into();
}
s.brush_presets_changed();
s.sync_preset_store();
// Untouched order: no order list is written (the default order is implied).
let (t, _) = session(&dir);
assert_eq!(names(&t), names(&s));
// Reorder a built-in within its group, a user preset across groups, and whole groups.
let builtin_group = s.tools.presets[0].group.clone();
let last_builtin = s.tools.presets.iter().rfind(|p| p.builtin && p.group == builtin_group).unwrap().name.clone();
let first = s.tools.presets[0].name.clone();
s.execute("brush.presets.move", json!({"name": last_builtin, "before": first})).unwrap();
s.execute("brush.presets.move", json!({"name": "Ink 2", "group": "Washes", "index": 0})).unwrap();
s.execute("brush.presets.moveGroup", json!({"group": "Washes", "index": 0})).unwrap();
s.execute("brush.presets.moveGroup", json!({"group": "Inks", "before": builtin_group})).unwrap();
assert_eq!(s.tools.presets[0].name, "Ink 2");
let (t, w) = session(&dir);
assert!(w.is_empty(), "{w:?}");
assert_eq!(names(&t), names(&s), "the panel order survives a restart");
assert!(t.tools.presets.iter().find(|p| p.name == last_builtin).unwrap().builtin, "reordering keeps a built-in built-in");
// Renaming and deleting a group persist too.
s.execute("brush.presets.renameGroup", json!({"group": "Inks", "newName": "Pens"})).unwrap();
s.execute("brush.presets.deleteGroup", json!({"group": "Washes"})).unwrap();
let (t, _) = session(&dir);
assert_eq!(names(&t), names(&s));
assert!(t.tools.presets.iter().all(|p| p.group != "Washes" && p.group != "Inks"));
}
#[test]
fn corrupt_and_oversized_files_are_skipped_with_a_warning() {
let dir = TempDir::new("corrupt");
+33 -15
View File
@@ -10,7 +10,7 @@
use std::collections::BTreeSet;
use photocraft_paint::{
BrushPreset, BrushSettings, Control, DualBrush, Dynamic, GrayTile, MaskMode, Pattern, PatternStyle, Pose, ShapeDynamics, TipShape, Transfer,
BrushPreset, BrushSettings, Control, DualBrush, Dynamic, GrayTile, MaskMode, MixerSettings, Pattern, PatternStyle, Pose, ShapeDynamics, TipShape, Transfer,
};
use photocraft_psd::abr::{self, AbrFile, AbrSample, LegacyTip};
use photocraft_psd::descriptor::{Descriptor, Value};
@@ -281,6 +281,25 @@ impl Mapper<'_> {
}
}
/// Mixer Brush tool options captured with a preset (Wet, Load, Mix, Flow, Sample All Layers).
/// Key names are best effort (Photoshop doesn't document them); several spellings are read.
fn mixer_options(&mut self, to: &Descriptor, m: &mut MixerSettings) {
let first = |keys: &[&str]| keys.iter().find_map(|k| pct(to, k));
let wet = first(&["wetness", "Wtns", "wet"]);
let load = first(&["dryness", "load", "Ld "]);
let mix = first(&["mix", "mixRatio", "Mx "]);
if wet.is_none() && load.is_none() && mix.is_none() {
return;
}
m.wet = wet.unwrap_or(m.wet).clamp(0.0, 1.0);
m.load = load.unwrap_or(m.load).clamp(0.0, 1.0);
m.mix = mix.unwrap_or(m.mix).clamp(0.0, 1.0);
m.flow = pct(to, "flow").unwrap_or(m.flow).clamp(0.0, 1.0);
if let Some(v) = ["sampleMerged", "useAllLayers", "sampleAllLayers", "Mrgd"].iter().find_map(|k| boolean(to, k)) {
m.sample_all_layers = v;
}
}
fn preset(&mut self, d: &Descriptor) -> Option<BrushSettings> {
for (k, _) in &d.items {
let k = String::from_utf8_lossy(k.as_bytes()).to_string();
@@ -298,8 +317,9 @@ impl Mapper<'_> {
}
b.angle = num(t, "Angl").unwrap_or(0.0) as f32;
b.roundness = pct(t, "Rndn").unwrap_or(1.0).clamp(0.01, 1.0);
// `Intr` off = spacing unchecked (Photoshop spaces by pointer speed): keep it tight.
b.spacing = if boolean(t, "Intr") == Some(false) { 0.05 } else { pct(t, "Spcn").unwrap_or(0.25).clamp(0.01, 10.0) };
// `Intr` off = the Spacing checkbox is off: the pointer's speed sets the spacing.
b.spacing = pct(t, "Spcn").unwrap_or(0.25).clamp(0.01, 10.0);
b.spacing_enabled = boolean(t, "Intr") != Some(false);
b.flip_x = boolean(t, "flipX").unwrap_or(false);
b.flip_y = boolean(t, "flipY").unwrap_or(false);
}
@@ -316,13 +336,9 @@ impl Mapper<'_> {
roundness,
flip_x_jitter: boolean(d, "flipX").unwrap_or(false),
flip_y_jitter: boolean(d, "flipY").unwrap_or(false),
tilt_scale: pct(d, "tiltScale").unwrap_or(0.0).clamp(0.0, 2.0),
brush_projection: boolean(d, "brushProjection").unwrap_or(false),
};
if pct(d, "tiltScale").is_some_and(|v| v > 0.0) {
self.warnings.insert("Tilt Scale (Shape Dynamics) is not supported".into());
}
if boolean(d, "brushProjection") == Some(true) {
self.warnings.insert("Brush Projection is not supported".into());
}
}
// Scattering.
if boolean(d, "useScatter") == Some(true) {
@@ -393,12 +409,13 @@ impl Mapper<'_> {
}
// Transfer.
if boolean(d, "usePaintDynamics") == Some(true) {
b.transfer = Transfer { enabled: true, opacity: self.dynamic(obj(d, "opVr")), flow: self.dynamic(obj(d, "prVr")) };
for (k, what) in [("wtVr", "Wetness"), ("mxVr", "Mix")] {
if self.dynamic(obj(d, k)).is_active() {
self.warnings.insert(format!("Transfer › {what} jitter (Mixer Brush) is not supported"));
}
}
b.transfer = Transfer {
enabled: true,
opacity: self.dynamic(obj(d, "opVr")),
flow: self.dynamic(obj(d, "prVr")),
wetness: self.dynamic(obj(d, "wtVr")),
mix: self.dynamic(obj(d, "mxVr")),
};
}
// Brush Pose (Photoshop's tilt is ±100 %; ours is ±90°).
if boolean(d, "useBrushPose") == Some(true) {
@@ -443,6 +460,7 @@ impl Mapper<'_> {
if let Some(v) = boolean(to, "smoothingZoomCompensation") {
b.smoothing.adjust_for_zoom = v;
}
self.mixer_options(to, &mut b.mixer);
if mask_blend_is_set(to) {
self.warnings.insert("tool blend modes stored with presets are not applied".into());
}
+45
View File
@@ -226,6 +226,51 @@ fn big_tips_are_downsampled() {
assert!(imp.warnings.iter().any(|w| w.contains("downsampled")));
}
#[test]
fn tilt_scale_projection_spacing_and_mixer_fields_map() {
let tip = Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 30.0)).with("Spcn", prc(35.0)).with("Intr", Value::Boolean(false));
let preset = Descriptor::new("brushPreset")
.with("Nm ", t("Wet Pen"))
.with("Brsh", Value::Descriptor(tip))
.with("useTipDynamics", Value::Boolean(true))
.with("szVr", var(3, 0.0, 0.0))
.with("tiltScale", prc(150.0))
.with("brushProjection", Value::Boolean(true))
.with("usePaintDynamics", Value::Boolean(true))
.with("wtVr", var(2, 30.0, 10.0))
.with("mxVr", var(1, 45.0, 0.0))
.with(
"toolOptions",
Value::Descriptor(
Descriptor::new("currentToolOptions")
.with("wetness", prc(80.0))
.with("dryness", prc(20.0))
.with("mix", prc(65.0))
.with("flow", prc(70.0))
.with("sampleMerged", Value::Boolean(true)),
),
);
let plain =
Descriptor::new("brushPreset").with("Nm ", t("Plain")).with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 9.0))));
let bytes = write_v6(2, &[], &[], &[preset, plain], true).unwrap();
let imp = read_abr(&bytes, "Mixer").unwrap();
let b = &imp.presets[0].brush;
assert!(!b.spacing_enabled && (b.spacing - 0.35).abs() < 1e-6, "Intr off = Spacing unchecked, the value kept");
let sd = &b.shape_dynamics;
assert!(sd.brush_projection && (sd.tilt_scale - 1.5).abs() < 1e-6 && sd.size.control == Control::PenTilt);
let tr = &b.transfer;
assert_eq!((tr.wetness.control, tr.wetness.jitter, tr.wetness.minimum), (Control::PenPressure, 0.3, 0.1));
assert_eq!((tr.mix.control, tr.mix.jitter), (Control::Fade, 0.45));
let m = &b.mixer;
assert_eq!((m.wet, m.load, m.mix, m.flow, m.sample_all_layers), (0.8, 0.2, 0.65, 0.7, true));
assert!(!imp.warnings.iter().any(|w| w.contains("Tilt Scale") || w.contains("Projection") || w.contains("Mixer")), "{:?}", imp.warnings);
// Defaults when the file says nothing.
let p = &imp.presets[1].brush;
assert!(p.spacing_enabled && !p.shape_dynamics.brush_projection && p.shape_dynamics.tilt_scale == 0.0);
assert_eq!(p.mixer, photocraft_paint::MixerSettings::default());
assert_eq!((p.transfer.wetness, p.transfer.mix), (Dynamic::default(), Dynamic::default()));
}
#[test]
fn garbage_is_an_error() {
assert!(read_abr(b"", "g").is_err());
+5
View File
@@ -99,6 +99,11 @@ impl History {
self.undo.len()
}
/// Rename the current step (e.g. after folding several steps into one with [`Self::purge_last`]).
pub fn set_current_label(&mut self, label: impl Into<String>) {
self.current_label = label.into();
}
/// Forget the most recent undo state and every redo state (Edit › Purge › Undo): the
/// last step can no longer be undone and its pixels are released.
pub fn purge_last(&mut self) -> bool {
+80 -2
View File
@@ -10,6 +10,7 @@
use photocraft_color::BlendMode;
use serde::{Deserialize, Serialize};
use crate::mixer::MixerSettings;
use crate::tile::GrayTile;
/// What drives a dynamic parameter (Photoshop's "Control" pop-ups).
@@ -80,12 +81,19 @@ pub struct ShapeDynamics {
pub enabled: bool,
/// Size jitter / control; `minimum` is Photoshop's Minimum Diameter.
pub size: Dynamic,
/// Tilt Scale, 0..2 (0–200 %): with the size control on Pen Tilt, how much a tilted pen
/// squashes the tip's height (before the tip angle is applied). At full tilt the height is
/// scaled by `1 − tiltScale / 2`, so 0 leaves the tip alone and 200 % flattens it.
pub tilt_scale: f32,
/// Angle jitter (fraction of ±180°) / control.
pub angle: Dynamic,
/// Roundness jitter / control; `minimum` is Minimum Roundness.
pub roundness: Dynamic,
pub flip_x_jitter: bool,
pub flip_y_jitter: bool,
/// Brush Projection: the pen's tilt and barrel rotation are applied to the tip as a
/// projection (the tip is foreshortened along the tilt direction and turns with the pen).
pub brush_projection: bool,
}
/// Scattering.
@@ -254,13 +262,36 @@ impl Default for ColorDynamics {
}
}
/// Transfer: per-dab opacity ceiling and flow.
/// Transfer: per-dab opacity ceiling and flow, plus the Mixer Brush's wetness and mix.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Transfer {
pub enabled: bool,
pub opacity: Dynamic,
pub flow: Dynamic,
/// Wetness Jitter / control (Mixer Brush only): scales the Wet setting per dab.
pub wetness: Dynamic,
/// Mix Jitter / control (Mixer Brush only): scales the Mix setting per dab.
pub mix: Dynamic,
}
/// The sections a lock can hold (the lock icons in Photoshop's Brush Settings list): a locked
/// section keeps its settings when another brush preset is picked.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct SectionLocks {
pub shape_dynamics: bool,
pub scattering: bool,
pub texture: bool,
pub dual_brush: bool,
pub color_dynamics: bool,
pub transfer: bool,
pub pose: bool,
pub noise: bool,
pub wet_edges: bool,
pub build_up: bool,
pub smoothing: bool,
pub protect_texture: bool,
}
/// Brush Pose: stylus values used instead of (override) or for missing device data.
@@ -327,6 +358,9 @@ pub struct BrushSettings {
pub hardness: f32,
/// Spacing between dabs as a fraction of the diameter.
pub spacing: f32,
/// Photoshop's Spacing checkbox. Off: the pointer's speed sets the spacing (one dab every
/// [`crate::dynamics::SPEED_SPACING_MS`] of stroke time; without timestamps, one per input point).
pub spacing_enabled: bool,
/// Maximum coverage for the whole stroke.
pub opacity: f32,
/// Per-dab coverage.
@@ -374,6 +408,10 @@ pub struct BrushSettings {
pub smoothing: Smoothing,
/// Keep the current texture (pattern + scale) when switching to another textured preset.
pub protect_texture: bool,
/// Sections whose settings stay when another preset is picked (tool state, like Photoshop's locks).
pub locks: SectionLocks,
/// Mixer Brush options (Wet, Load, Mix, Flow, Sample All Layers) used by `paint.mixerBrush`.
pub mixer: MixerSettings,
/// Random seed for jitters (commands derive it from the stroke when not given), so strokes replay.
pub seed: u64,
}
@@ -384,6 +422,7 @@ impl Default for BrushSettings {
size: 20.0,
hardness: 1.0,
spacing: 0.1,
spacing_enabled: true,
opacity: 1.0,
flow: 1.0,
pressure_size: true,
@@ -411,6 +450,8 @@ impl Default for BrushSettings {
build_up_rate: 20.0,
smoothing: Smoothing::default(),
protect_texture: false,
locks: SectionLocks::default(),
mixer: MixerSettings::default(),
seed: 0,
}
}
@@ -418,10 +459,47 @@ impl Default for BrushSettings {
impl BrushSettings {
/// This brush (a preset) picked while `current` is the tool's brush: Smoothing is a tool
/// option, so it stays the tool's (Photoshop), and a protected texture stays too.
/// option, so it stays the tool's (Photoshop), a protected texture stays too, and so does every
/// section `current` has locked (the locks themselves are tool state and carry over).
pub fn picked_over(self, current: &BrushSettings) -> Self {
let mut b = self.with_protected_texture(current);
b.smoothing = current.smoothing.clone();
let l = &current.locks;
if l.shape_dynamics {
b.shape_dynamics = current.shape_dynamics.clone();
}
if l.scattering {
b.scattering = current.scattering.clone();
}
if l.texture {
b.texture = current.texture.clone();
}
if l.dual_brush {
b.dual_brush = current.dual_brush.clone();
}
if l.color_dynamics {
b.color_dynamics = current.color_dynamics.clone();
}
if l.transfer {
b.transfer = current.transfer.clone();
}
if l.pose {
b.pose = current.pose.clone();
}
if l.noise {
b.noise = current.noise;
}
if l.wet_edges {
b.wet_edges = current.wet_edges;
}
if l.build_up {
b.build_up = current.build_up;
b.build_up_rate = current.build_up_rate;
}
if l.protect_texture {
b.protect_texture = current.protect_texture;
}
b.locks = current.locks.clone();
b
}
+175
View File
@@ -232,6 +232,7 @@ fn jitter_is_deterministic_per_seed() {
roundness: Dynamic { jitter: 1.0, minimum: 0.2, ..Default::default() },
flip_x_jitter: true,
flip_y_jitter: true,
..Default::default()
},
scattering: Scattering { enabled: true, scatter: Dynamic::jitter(2.0), both_axes: true, count: 3, count_jitter: Dynamic::jitter(1.0) },
..brush()
@@ -638,3 +639,177 @@ fn tail_preview_shows_the_stroke_as_finishing_it_would() {
r.push(&pts);
assert!(r.tail_preview().is_none());
}
// ---------- Tilt Scale, Brush Projection, speed spacing, locks, mixer transfer ----------
/// Coverage extent (pixels above half coverage) of a rasterised dab along x and y through its centre.
fn extents(b: &BrushSettings, d: &Dab) -> (usize, usize) {
let r = raster(b, d);
let (cx, cy) = (d.center.x.floor() as i32, d.center.y.floor() as i32);
let w = (r.0.x0..r.0.x1).filter(|&x| at(&r, x, cy) > 0.5).count();
let h = (r.0.y0..r.0.y1).filter(|&y| at(&r, cx, y) > 0.5).count();
(w, h)
}
fn pen(x: f64, y: f64, tilt_x: f32, tilt_y: f32, rotation: f32) -> StrokePoint {
StrokePoint { tilt_x, tilt_y, rotation, ..StrokePoint::new(x, y, 1.0) }
}
#[test]
fn tilt_scale_squashes_the_tip_height_with_pen_tilt() {
// Size control on Pen Tilt with a 100 % minimum, so only Tilt Scale changes the shape.
let mk = |scale: f32| BrushSettings {
size: 40.0,
shape_dynamics: ShapeDynamics {
enabled: true,
size: Dynamic { control: Control::PenTilt, minimum: 1.0, ..Default::default() },
tilt_scale: scale,
..Default::default()
},
..brush()
};
let upright = [pen(50.0, 50.0, 0.0, 0.0, 0.0)];
let tilted = [pen(50.0, 50.0, 45.0, 0.0, 0.0)];
// 0 % (the default) and an upright pen leave the tip round.
let d = dabs_of(&mk(0.0), &tilted)[0];
assert_eq!(d.roundness, 1.0);
let d = dabs_of(&mk(2.0), &upright)[0];
assert_eq!(d.roundness, 1.0);
// 100 % at 45° tilt: height × (1 − 0.5 × 0.5) = 0.75; 200 %: 0.5.
let d = dabs_of(&mk(1.0), &tilted)[0];
assert!((d.roundness - 0.75).abs() < 1e-4, "{}", d.roundness);
let d2 = dabs_of(&mk(2.0), &tilted)[0];
assert!((d2.roundness - 0.5).abs() < 1e-4, "{}", d2.roundness);
let (w, h) = extents(&mk(2.0), &d2);
assert!(w >= 38 && (h as i32 - 20).abs() <= 2, "{w}×{h}");
// Only with the size control on Pen Tilt (Photoshop greys the slider otherwise).
let mut b = mk(2.0);
b.shape_dynamics.size.control = Control::PenPressure;
assert_eq!(dabs_of(&b, &tilted)[0].roundness, 1.0);
// Rendered strokes differ.
let a = paint(&mk(0.0), &[pen(20.0, 40.0, 60.0, 0.0, 0.0), pen(120.0, 40.0, 60.0, 0.0, 0.0)], 0, 0);
let c = paint(&mk(2.0), &[pen(20.0, 40.0, 60.0, 0.0, 0.0), pen(120.0, 40.0, 60.0, 0.0, 0.0)], 0, 0);
let area = Rect::new(0, 0, 160, 80);
assert!(alpha_sum(&c, area) < alpha_sum(&a, area) * 0.75, "{} vs {}", alpha_sum(&c, area), alpha_sum(&a, area));
}
#[test]
fn brush_projection_foreshortens_along_tilt_and_turns_with_rotation() {
let mk = |on: bool| BrushSettings { size: 40.0, shape_dynamics: ShapeDynamics { enabled: true, brush_projection: on, ..Default::default() }, ..brush() };
// Tilted 60° towards +x: the tip is foreshortened to cos 60° = ½ along x.
let pts = [pen(50.0, 50.0, 60.0, 0.0, 0.0)];
let d = dabs_of(&mk(true), &pts)[0];
assert!((d.proj_scale - 0.5).abs() < 1e-4 && d.proj_angle.abs() < 1e-5, "{d:?}");
let (w, h) = extents(&mk(true), &d);
assert!((w as i32 - 20).abs() <= 2 && h >= 38, "{w}×{h}");
// Tilted along y instead: foreshortened vertically.
let d = dabs_of(&mk(true), &[pen(50.0, 50.0, 0.0, 60.0, 0.0)])[0];
let (w, h) = extents(&mk(true), &d);
assert!(w >= 38 && (h as i32 - 20).abs() <= 2, "{w}×{h}");
// Off: the tilt doesn't touch the tip.
let d = dabs_of(&mk(false), &pts)[0];
assert_eq!((d.proj_scale, d.angle), (1.0, 0.0));
assert_eq!(extents(&mk(false), &d), (40, 40));
// Barrel rotation turns the tip (an elliptical one shows it).
let mut b = mk(true);
b.roundness = 0.5;
let d = dabs_of(&b, &[pen(50.0, 50.0, 0.0, 0.0, 90.0)])[0];
assert!((d.angle.to_degrees() - 90.0).abs() < 1e-3);
let (w, h) = extents(&b, &d);
assert!(w < h, "{w}×{h}");
// Dual-brush dabs are never projected.
let ctx = BrushContext::new(&mk(true));
let dd = Dab { proj_scale: 0.3, ..Dab::round(Point::new(50.0, 50.0), 20.0, 1.0) };
let (r, mut a, mut c) = (ctx.dab_rect(&dd, true), Vec::new(), Vec::new());
ctx.rasterize(&dd, true, r, &mut a);
ctx.rasterize(&Dab { proj_scale: 1.0, ..dd }, true, r, &mut c);
assert_eq!(a, c);
}
#[test]
fn spacing_off_spaces_dabs_by_pointer_speed() {
let mut b = BrushSettings { size: 20.0, spacing_enabled: false, ..brush() };
let timed = |ms: f64| vec![StrokePoint { time: 0.0, ..StrokePoint::new(0.0, 10.0, 1.0) }, StrokePoint { time: ms, ..StrokePoint::new(100.0, 10.0, 1.0) }];
// 100 px in 400 ms: a dab every 8 ms = every 2 px; in 40 ms: every 20 px.
let slow = dabs_of(&b, &timed(400.0)).len();
let fast = dabs_of(&b, &timed(40.0)).len();
assert_eq!((slow, fast), (51, 6));
// Never denser than half a pixel, however slow.
assert!(dabs_of(&b, &timed(1.0e9)).len() <= 201);
// No timestamps: one dab per input point.
let pts: Vec<StrokePoint> = (0..7).map(|i| StrokePoint::new(f64::from(i) * 15.0, 10.0, 1.0)).collect();
assert_eq!(dabs_of(&b, &pts).len(), 7);
// Checked: fixed spacing (25 % of 20 px = 5 px) whatever the speed.
b.spacing_enabled = true;
assert_eq!(dabs_of(&b, &timed(400.0)).len(), dabs_of(&b, &timed(40.0)).len());
// Chunked input gives the same dabs.
b.spacing_enabled = false;
let pts: Vec<StrokePoint> = (0..20).map(|i| StrokePoint { time: f64::from(i) * 13.0, ..StrokePoint::new(f64::from(i) * 7.0, 10.0, 1.0) }).collect();
let one = dabs_of(&b, &pts);
let mut g = crate::dynamics::DabGenerator::new(&b, 1.0);
let (mut out, mut dual) = (Vec::new(), Vec::new());
for c in pts.chunks(3) {
g.push(c, &mut out, &mut dual);
}
g.finish(&mut out, &mut dual);
assert_eq!(one, out);
}
#[test]
fn locked_sections_survive_picking_a_preset() {
let presets = crate::presets::builtin();
let chalk = crate::presets::find(&presets, "chalk").unwrap().brush.clone();
let mut cur = BrushSettings {
scattering: Scattering { enabled: true, count: 5, ..Default::default() },
transfer: Transfer { enabled: true, wetness: Dynamic::jitter(0.4), ..Default::default() },
noise: true,
..brush()
};
// Unlocked: the preset's sections win.
let next = chalk.clone().picked_over(&cur);
assert_eq!(next.scattering, chalk.scattering);
assert_eq!(next.noise, chalk.noise);
// Locked: the current sections stay, and so do the locks.
cur.locks = SectionLocks { scattering: true, transfer: true, noise: true, ..Default::default() };
let next = chalk.clone().picked_over(&cur);
assert_eq!(next.scattering, cur.scattering);
assert_eq!(next.transfer, cur.transfer);
assert!(next.noise);
assert_eq!(next.locks, cur.locks);
// Unlocked sections still come from the preset.
assert_eq!(next.texture, chalk.texture);
assert_eq!(next.tip, chalk.tip);
}
#[test]
fn transfer_wetness_and_mix_vary_per_dab() {
let b = BrushSettings {
transfer: Transfer {
enabled: true,
wetness: Dynamic::controlled(Control::PenPressure),
mix: Dynamic { jitter: 1.0, minimum: 0.25, ..Default::default() },
..Default::default()
},
..brush()
};
let pts = [StrokePoint::new(0.0, 0.0, 0.3), StrokePoint::new(60.0, 0.0, 0.3)];
let d = dabs_of(&b, &pts);
assert!(d.iter().all(|x| (x.wet - 0.3).abs() < 1e-5));
assert!(d.iter().all(|x| (0.25..=1.0).contains(&x.mix)) && d.iter().any(|x| x.mix < 0.9));
// Transfer off: as set.
let d = dabs_of(&brush(), &pts);
assert!(d.iter().all(|x| x.wet == 1.0 && x.mix == 1.0));
// New fields round-trip and old JSON loads with neutral values.
let full = BrushSettings {
spacing_enabled: false,
shape_dynamics: ShapeDynamics { tilt_scale: 1.5, brush_projection: true, ..Default::default() },
locks: SectionLocks { texture: true, ..Default::default() },
mixer: crate::mixer::MixerSettings { wet: 0.9, sample_all_layers: true, ..Default::default() },
..b
};
let back: BrushSettings = serde_json::from_value(serde_json::to_value(&full).unwrap()).unwrap();
assert_eq!(back, full);
let old: BrushSettings = serde_json::from_str(r#"{"size": 12, "shapeDynamics": {"enabled": true}, "transfer": {"enabled": true}}"#).unwrap();
assert!(old.spacing_enabled && !old.shape_dynamics.brush_projection && old.shape_dynamics.tilt_scale == 0.0 && old.locks == SectionLocks::default());
assert_eq!(old.mixer, crate::mixer::MixerSettings::default());
}
+84 -7
View File
@@ -13,6 +13,13 @@ use crate::{Dab, StrokePoint};
/// Maximum pulled-string length at 100 % smoothing, in screen pixels.
pub const MAX_STRING_PX: f64 = 100.0;
/// With Spacing unchecked, one dab per this many milliseconds of stroke time, so faster strokes
/// space their dabs further apart (Photoshop: "the speed of the cursor determines the spacing").
pub const SPEED_SPACING_MS: f64 = 8.0;
/// Densest speed spacing, in pixels between dabs (a slow, long stroke never floods the buffer).
const MIN_SPEED_STEP: f64 = 0.5;
#[inline]
fn lerp_pt(a: &StrokePoint, b: &StrokePoint, f: f64) -> StrokePoint {
let ff = f as f32;
@@ -147,11 +154,32 @@ pub struct PathWalker {
/// Airbrush interval in ms (None = off).
interval: Option<f64>,
time_acc: f64,
/// Speed spacing (Spacing unchecked): one step per this many ms of stroke time.
speed: Option<f64>,
speed_acc: f64,
}
impl PathWalker {
pub fn new(build_up_interval_ms: Option<f64>) -> Self {
Self { last: None, next_at: 0.0, pending_first: None, initial_dir: None, dir: 0.0, step: 0, interval: build_up_interval_ms, time_acc: 0.0 }
Self {
last: None,
next_at: 0.0,
pending_first: None,
initial_dir: None,
dir: 0.0,
step: 0,
interval: build_up_interval_ms,
time_acc: 0.0,
speed: None,
speed_acc: 0.0,
}
}
/// Space steps by stroke time instead of distance ([`SPEED_SPACING_MS`]): faster movement
/// spreads them out. Segments without timestamps get one step at their end point.
pub fn speed_spacing(mut self, interval_ms: f64) -> Self {
self.speed = Some(interval_ms.max(0.1));
self
}
fn emit(&mut self, point: StrokePoint, out: &mut impl FnMut(StepInput)) {
@@ -179,12 +207,36 @@ impl PathWalker {
self.emit(f, out);
}
if len > 1e-9 {
while self.next_at <= len + 1e-9 {
let q = lerp_pt(&a, &p, (self.next_at / len).min(1.0));
self.emit(q, out);
self.next_at += step_len(&q).max(0.25);
match self.speed {
None => {
while self.next_at <= len + 1e-9 {
let q = lerp_pt(&a, &p, (self.next_at / len).min(1.0));
self.emit(q, out);
self.next_at += step_len(&q).max(0.25);
}
self.next_at -= len;
}
Some(iv) => {
let dt = p.time - a.time;
if dt > 0.0 && dt.is_finite() {
self.speed_acc += dt;
let max_n = (len / MIN_SPEED_STEP).ceil().max(1.0) as usize;
let mut n = 0;
while self.speed_acc >= iv && n < max_n {
self.speed_acc -= iv;
let f = ((dt - self.speed_acc) / dt).clamp(0.0, 1.0);
self.emit(lerp_pt(&a, &p, f), out);
n += 1;
}
if n == max_n {
self.speed_acc = self.speed_acc.rem_euclid(iv);
}
} else {
// No timestamps: one dab per input point.
self.emit(p, out);
}
}
}
self.next_at -= len;
}
if let Some(iv) = self.interval {
let dt = p.time - a.time;
@@ -379,6 +431,22 @@ impl DabBuilder {
fy = !fy;
}
}
// Tilt Scale and Brush Projection (Shape Dynamics).
let (mut proj_angle, mut proj_scale) = (0.0, 1.0);
if sd.enabled {
let tilt = s.point.tilt_x.hypot(s.point.tilt_y).clamp(0.0, 90.0);
if sd.size.control == Control::PenTilt && sd.tilt_scale > 0.0 {
let k = 1.0 - sd.tilt_scale.clamp(0.0, 2.0) * 0.5 * tilt / 90.0;
roundness = (roundness * k).clamp(0.01, 1.0);
}
if sd.brush_projection {
angle += s.point.rotation;
if tilt > 0.0 {
proj_angle = (-s.point.tilt_y).atan2(s.point.tilt_x);
proj_scale = tilt.to_radians().cos().max(0.05);
}
}
}
let radius = (diameter / 2.0).max(0.5);
// Scatter.
let mut c = Point::new(s.point.x, s.point.y);
@@ -403,6 +471,7 @@ impl DabBuilder {
opacity = scalar(&tr.opacity, s, r(stream::OPACITY));
flow *= scalar(&tr.flow, s, r(stream::FLOW));
}
let (wet, mix) = if tr.enabled { (scalar(&tr.wetness, s, r(stream::WET)), scalar(&tr.mix, s, r(stream::MIX))) } else { (1.0, 1.0) };
if b.pressure_opacity && !(tr.enabled && tr.opacity.control != Control::Off) {
opacity *= s.point.pressure.clamp(0.0, 1.0);
}
@@ -428,6 +497,10 @@ impl DabBuilder {
color,
depth,
index: i,
proj_angle,
proj_scale,
wet,
mix,
});
}
}
@@ -479,6 +552,10 @@ impl DualBuilder {
color: [0.0, 0.0, 0.0, 1.0],
depth: 1.0,
index: i,
proj_angle: 0.0,
proj_scale: 1.0,
wet: 1.0,
mix: 1.0,
});
}
}
@@ -519,7 +596,7 @@ impl DabGenerator {
let dual_on = brush.dual_brush.enabled;
Self {
smoother: Smoother::new(&brush.smoothing, zoom),
walker: PathWalker::new(interval),
walker: if brush.spacing_enabled { PathWalker::new(interval) } else { PathWalker::new(interval).speed_spacing(SPEED_SPACING_MS) },
dual_walker: dual_on.then(|| PathWalker::new(None)),
builder: DabBuilder::new(brush),
dual: dual_on.then(|| DualBuilder::new(brush)),
+14 -2
View File
@@ -31,9 +31,10 @@ pub mod rng;
pub mod tile;
pub use brush::{
BrushPreset, BrushSettings, ColorDynamics, Control, DualBrush, Dynamic, MaskMode, Pattern, PatternStyle, Pose, Scattering, ShapeDynamics, Smoothing,
Texture, TipShape, Transfer,
BrushPreset, BrushSettings, ColorDynamics, Control, DualBrush, Dynamic, MaskMode, Pattern, PatternStyle, Pose, Scattering, SectionLocks, ShapeDynamics,
Smoothing, Texture, TipShape, Transfer,
};
pub use mixer::MixerSettings;
pub use render::{BrushContext, StrokeRenderer, render_stroke};
pub use tile::GrayTile;
@@ -94,6 +95,13 @@ pub struct Dab {
pub depth: f32,
/// Position in the stroke (0 = first).
pub index: u64,
/// Brush Projection: direction (radians, counter-clockwise, y up) along which the tip is
/// foreshortened, and the factor (1 = no projection).
pub proj_angle: f32,
pub proj_scale: f32,
/// Mixer Brush multipliers of Wet and Mix (Transfer › Wetness/Mix Jitter), 1 = as set.
pub wet: f32,
pub mix: f32,
}
impl Dab {
@@ -111,6 +119,10 @@ impl Dab {
color: [0.0, 0.0, 0.0, 1.0],
depth: 1.0,
index: 0,
proj_angle: 0.0,
proj_scale: 1.0,
wet: 1.0,
mix: 1.0,
}
}
}
+35 -3
View File
@@ -24,11 +24,13 @@ pub struct MixerSettings {
pub load: f32,
pub mix: f32,
pub flow: f32,
/// Pick up colour from all visible layers instead of the target layer only.
pub sample_all_layers: bool,
}
impl Default for MixerSettings {
fn default() -> Self {
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0 }
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false }
}
}
@@ -87,6 +89,8 @@ pub fn apply_mixer_stroke(
let mut acc = vec![0.0f32; n];
let mut paint = vec![0.0f32; n];
let dmg = apply_dab_stroke(target, &st, selection, lock_transparency, 0, |work, fp| {
// Transfer › Wetness / Mix Jitter scale the settings per dab.
let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0));
// Sample the canvas under the dab.
acc.iter_mut().for_each(|v| *v = 0.0);
let (mut wsum, mut asum) = (0.0f32, 0.0f32);
@@ -165,7 +169,7 @@ mod tests {
let mut s = Surface::new(PixelFormat::RGBA8);
let mut st = MixerState::default();
st.load([1.0, 0.0, 0.0, 1.0]);
apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 0.0, load, mix: 0.0, flow: 1.0 }, &mut st, None, false);
apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 0.0, load, mix: 0.0, flow: 1.0, ..Default::default() }, &mut st, None, false);
let (start, end) = (s.rgba(10, 10)[3], s.rgba(190, 10)[3]);
assert!(start > 0.95, "{start}");
if expect_dry {
@@ -186,7 +190,15 @@ mod tests {
s.fill_rect(Rect::new(100, 0, 200, 20), &[1.0, 1.0, 1.0, 1.0]);
let mut st = MixerState::default();
st.load([1.0, 0.0, 0.0, 1.0]);
apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 1.0, load: 0.3, mix: 0.9, flow: 1.0 }, &mut st, None, false);
apply_mixer_stroke(
&mut s,
None,
&stroke(10.0),
&MixerSettings { wet: 1.0, load: 0.3, mix: 0.9, flow: 1.0, ..Default::default() },
&mut st,
None,
false,
);
// Blue is dragged into the white half.
let c = s.rgba(115, 10);
assert!(c[2] > c[1] + 0.05 && c[1] < 0.95, "{c:?}");
@@ -205,4 +217,24 @@ mod tests {
assert!(!dmg.is_empty());
assert_eq!(s.rgba(50, 10)[3], 0.0);
}
#[test]
fn transfer_wetness_control_scales_pickup_per_dab() {
// Wetness on Pen Pressure: no pressure means a dry brush (no pickup), full pressure picks up.
let run = |pressure: f32| {
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 200, 20), &[0.0, 0.0, 1.0, 1.0]);
let mut st = MixerState::default();
st.load([1.0, 0.0, 0.0, 1.0]);
let mut k = stroke(10.0);
k.brush.transfer = crate::Transfer { enabled: true, wetness: crate::Dynamic::controlled(crate::Control::PenPressure), ..Default::default() };
for p in &mut k.points {
p.pressure = pressure;
}
apply_mixer_stroke(&mut s, None, &k, &MixerSettings { wet: 1.0, load: 1.0, mix: 1.0, flow: 1.0, ..Default::default() }, &mut st, None, false);
st.pickup
};
assert!(run(0.0).is_none());
assert!(run(1.0).is_some_and(|p| p[2] > 0.2));
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ fn size_dyn(size: Dynamic) -> ShapeDynamics {
}
fn transfer(opacity: Dynamic, flow: Dynamic) -> Transfer {
Transfer { enabled: true, opacity, flow }
Transfer { enabled: true, opacity, flow, ..Default::default() }
}
fn paper(seed: u32, depth: f32) -> Texture {
+13 -2
View File
@@ -144,6 +144,12 @@ impl BrushContext {
let (cx, cy) = (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
// foreshortens the tip there by `proj_scale`.
let proj = (!dual && d.proj_scale < 0.999).then(|| {
let (ps, pc) = d.proj_angle.sin_cos();
(pc, ps, 1.0 / d.proj_scale.max(0.05) - 1.0)
});
let r = d.radius;
let ro = d.roundness.max(0.5 / r).min(1.0);
let rm = (r * ro).max(0.5);
@@ -163,8 +169,13 @@ impl BrushContext {
for xx in 0..w {
let x = rect.x0 + xx as i32;
let dx = x as f32 + 0.5 - cx;
// To y-up, rotate by -angle, flip.
let (ux, uy) = (dx, -dy);
// To y-up, project, rotate by -angle, flip.
let (mut ux, mut uy) = (dx, -dy);
if let Some((ax, ay, k)) = proj {
let t = (ux * ax + uy * ay) * k;
ux += t * ax;
uy += t * ay;
}
let u = (ux * cs + uy * sn) * fx;
let v = (-ux * sn + uy * cs) * fy;
let (mut val, rn) = match mips {
+2
View File
@@ -53,6 +53,8 @@ pub mod stream {
pub const DUAL_X: u64 = 16;
pub const DUAL_Y: u64 = 17;
pub const DUAL_FLIP: u64 = 18;
pub const WET: u64 = 19;
pub const MIX: u64 = 20;
}
/// Seed derived from arbitrary bytes (FNV-1a then mixed), for commands without an explicit seed.
+23 -2
View File
@@ -366,6 +366,13 @@ struct CurveUi {
/// Dragging a point this far outside the graph removes it (it comes back if dragged back in).
pub const DRAG_OFF: f32 = 12.0;
const HIT_RADIUS: f32 = 9.0;
const CURVE_HANDLE_SIZE: f32 = 7.0;
const CURVE_GRAPH_PADDING: f32 = 5.0;
fn curve_graph(full: Rect, side: f32) -> Rect {
let base = Rect::from_min_size(full.min + vec2(14.0, 0.0), vec2(side - 14.0, side - 14.0));
Rect::from_min_max(base.min + vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING), base.max - vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING))
}
fn read_curve(v: &Value, key: &str) -> Vec<[f32; 2]> {
let pts: Vec<[f32; 2]> = v
@@ -403,7 +410,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
let side = ui.available_width().clamp(120.0, 300.0);
ui.add_space(4.0);
let (full, resp) = ui.allocate_exact_size(vec2(side, side + 14.0), Sense::click_and_drag());
let graph = Rect::from_min_size(full.min + vec2(14.0, 0.0), vec2(side - 14.0, side - 14.0));
let graph = curve_graph(full, side);
ui.data_mut(|d| d.insert_temp(cx.mem.with("curves-graph"), graph));
let to_scr = |q: [f32; 2]| pos2(graph.left() + q[0] / 255.0 * graph.width(), graph.bottom() - q[1] / 255.0 * graph.height());
let to_val =
@@ -535,7 +542,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
}
draw_curve(&pts, if ch == 0 { t.text } else { bar_color(&chan, &t) }, 1.5);
for (i, q) in pts.iter().enumerate() {
let r = Rect::from_center_size(to_scr(*q), vec2(7.0, 7.0));
let r = Rect::from_center_size(to_scr(*q), vec2(CURVE_HANDLE_SIZE, CURVE_HANDLE_SIZE));
if Some(i) == st.sel {
p.rect_filled(r, 0.0, t.text);
p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Outside);
@@ -1404,6 +1411,20 @@ mod tests {
assert_eq!(hit(&p, to_scr, Pos2::new(50.0, 50.0), 9.0), None);
}
#[test]
fn curve_endpoint_handles_fit_inside_the_allocated_area() {
for side in [120.0, 200.0, 300.0] {
let full = Rect::from_min_size(pos2(100.0, 50.0), vec2(side, side + 14.0));
let graph = curve_graph(full, side);
let to_scr = |q: [f32; 2]| pos2(graph.left() + q[0] / 255.0 * graph.width(), graph.bottom() - q[1] / 255.0 * graph.height());
for q in [[0.0, 0.0], [255.0, 255.0]] {
let r = Rect::from_center_size(to_scr(q), vec2(CURVE_HANDLE_SIZE, CURVE_HANDLE_SIZE)).expand(1.0);
assert!(r.left() >= full.left() && r.right() <= full.right());
assert!(r.top() >= full.top() && r.bottom() <= full.bottom());
}
}
}
#[test]
fn levels_helpers() {
for g in [0.3f32, 1.0, 1.8, 4.0] {
+5 -1
View File
@@ -41,7 +41,11 @@ fn app_harness(kind: &str, mode: &str, depth: u32) -> Harness<'static, Photocraf
s.execute("edit.fill", json!({"color": "#6a3020"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute(&format!("layer.newAdjustmentLayer.{kind}"), json!({})).unwrap();
PhotocraftApp::new(s, crate::Services::default())
let mut app = PhotocraftApp::new(s, crate::Services::default());
// The default Properties group gives way to Layers (#147) and scrolls taller editors;
// these tests drive every control without scrolling, so they size it like a user would.
app.ui.dock.heights.insert(crate::dock::Group::Properties, 560.0);
app
});
h.run_steps(8);
h
+127 -219
View File
@@ -8,11 +8,15 @@
//!
//! The panel never writes the session brush itself: each frame's edits become one
//! `tools.setBrush` call carrying only the changed fields (Rule 1), so the journal, the control
//! channel and the MCP server see exactly what the panel did and can do the same.
//! channel and the MCP server see exactly what the panel did and can do the same. A drag's calls
//! share a `coalesce` key, so a whole gesture is one journal entry ([`commit_gesture`]).
//!
//! The lock beside a section name keeps that section's settings when another preset is picked
//! (`locks` in the brush, applied by the engine). The Brushes tab lives in [`crate::brushes_tab`].
use egui::{Color32, CornerRadius, RichText, Sense, Stroke, vec2};
use photocraft_engine::BrushSettings;
use photocraft_engine::paint::{self, TipShape};
use photocraft_engine::paint;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
@@ -22,6 +26,7 @@ use crate::{PhotocraftApp, icons, widgets};
pub use crate::brush_preview::{preview_pixels, preview_sig};
pub use crate::brush_sections::section_body;
pub use photocraft_engine::brush_cmds::brush_patch;
/// Sections in Photoshop's order. The bool says whether the section has an enable box.
pub const SECTIONS: [(&str, bool); 13] = [
@@ -41,9 +46,31 @@ pub const SECTIONS: [(&str, bool); 13] = [
];
/// Panel width and the stroke strip's size.
const WIDTH: f32 = 540.0;
pub(crate) const WIDTH: f32 = 540.0;
const STRIP: (u32, u32) = (488, 76);
/// How the Brushes tab lists presets.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BrushesView {
/// Rows: tip, size, stroke preview, name.
#[default]
List,
/// Tip thumbnails with their sizes.
Grid,
}
/// A rename in progress in the Brushes tab (a preset, or a group when `group` is set).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Renaming {
pub group: bool,
/// The preset (or group) being renamed.
pub name: String,
/// The text typed so far.
pub text: String,
}
/// Brushes panel view state (serde, so the control channel can read and drive it).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -52,6 +79,8 @@ pub struct BrushesPanelState {
pub collapsed: Vec<String>,
/// Case-insensitive name filter.
pub filter: String,
pub view: BrushesView,
pub renaming: Option<Renaming>,
}
/// The enable flag behind section `i` (None for Brush Tip Shape and Smoothing).
@@ -72,63 +101,74 @@ pub fn section_flag(b: &mut BrushSettings, i: usize) -> Option<&mut bool> {
})
}
/// The brush as JSON with the bitmaps (sampled tips, pattern tiles) that `skip` names replaced by
/// placeholders: they can be megabytes of base64, and skipped ones are equal on both sides.
fn light_json(b: &BrushSettings, skip: (bool, bool, bool)) -> Value {
let mut c = b.clone();
if skip.0 {
c.tip = TipShape::Round;
}
if skip.1 {
c.dual_brush.tip = TipShape::Round;
}
if skip.2 {
c.texture.pattern = paint::Pattern::default();
}
serde_json::to_value(&c).unwrap_or(Value::Null)
}
/// Fields of `new` that differ from `old`, nested objects diffed key by key: a minimal
/// `tools.setBrush` patch (`{}` when nothing changed).
pub fn brush_patch(old: &BrushSettings, new: &BrushSettings) -> Value {
let skip = (old.tip == new.tip, old.dual_brush.tip == new.dual_brush.tip, old.texture.pattern == new.texture.pattern);
fn diff(a: &Value, b: &Value) -> Option<Value> {
match (a, b) {
(Value::Object(ao), Value::Object(bo)) => {
let mut out = Map::new();
for (k, bv) in bo {
match ao.get(k) {
Some(av) => {
if let Some(d) = diff(av, bv) {
out.insert(k.clone(), d);
}
}
None => {
out.insert(k.clone(), bv.clone());
}
}
}
(!out.is_empty()).then_some(Value::Object(out))
}
// Enums with payloads (tips, patterns) are replaced whole.
_ => (a != b).then(|| b.clone()),
}
}
diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({}))
/// The lock behind section `i` (every section but Brush Tip Shape has one, as in Photoshop).
pub fn section_lock(b: &mut BrushSettings, i: usize) -> Option<&mut bool> {
let l = &mut b.locks;
Some(match i {
1 => &mut l.shape_dynamics,
2 => &mut l.scattering,
3 => &mut l.texture,
4 => &mut l.dual_brush,
5 => &mut l.color_dynamics,
6 => &mut l.transfer,
7 => &mut l.pose,
8 => &mut l.noise,
9 => &mut l.wet_edges,
10 => &mut l.build_up,
11 => &mut l.smoothing,
12 => &mut l.protect_texture,
_ => return None,
})
}
/// Send the panel's edits (`before` → `after`) through `tools.setBrush`.
pub fn commit(app: &mut PhotocraftApp, before: &BrushSettings, after: &BrushSettings) {
send(app, before, after, None);
}
fn send(app: &mut PhotocraftApp, before: &BrushSettings, after: &BrushSettings, key: Option<String>) -> bool {
if before == after {
return;
return false;
}
let patch = brush_patch(before, after);
if patch.as_object().is_some_and(Map::is_empty) {
return;
return false;
}
if let Err(e) = app.run("tools.setBrush", json!({ "brush": patch })) {
let mut p = json!({ "brush": patch });
if let Some(k) = key {
p["coalesce"] = json!(k);
}
if let Err(e) = app.run("tools.setBrush", p) {
app.ui.status = e;
}
true
}
const GESTURE: &str = "brush-set-gesture";
/// The current brush-edit gesture number. A pointer press starts a new gesture (once per frame);
/// [`commit_gesture`] ends one after an edit made with the pointer up (a click, a typed value).
fn gesture(ctx: &egui::Context) -> u64 {
let frame = ctx.cumulative_pass_nr();
let pressed = ctx.input(|i| i.pointer.any_pressed());
ctx.data_mut(|d| {
let g = d.get_temp_mut_or_default::<(u64, u64)>(egui::Id::new(GESTURE));
if pressed && g.1 != frame {
g.0 += 1;
g.1 = frame;
}
g.0
})
}
/// [`commit`] as part of a gesture: every call of one drag carries the same `coalesce` key, so
/// the engine journals the drag as a single `tools.setBrush` (Rule 1: one command per gesture).
/// Call it every frame (it tracks presses even when nothing changed).
pub fn commit_gesture(app: &mut PhotocraftApp, ctx: &egui::Context, before: &BrushSettings, after: &BrushSettings) {
let g = gesture(ctx);
if send(app, before, after, Some(format!("brush-ui:{g}"))) && !ctx.input(|i| i.pointer.any_down()) {
ctx.data_mut(|d| d.get_temp_mut_or_default::<(u64, u64)>(egui::Id::new(GESTURE)).0 += 1);
}
}
/// Group label for presets saved without a group.
@@ -147,189 +187,41 @@ pub fn grouped_presets(presets: &[paint::BrushPreset]) -> Vec<(String, Vec<usize
out
}
/// Does the current brush match `preset` (everything but colour and size)? Cheap fields first:
/// the full comparison clones the preset and its tip.
/// Does the current brush match `preset` (everything but colour, size and the tool state that
/// picking a preset keeps: smoothing and the section locks)? Cheap fields first: the full
/// comparison clones the preset and its tip.
pub fn is_current(preset: &BrushSettings, brush: &BrushSettings) -> bool {
preset.hardness == brush.hardness
&& preset.spacing == brush.spacing
&& preset.tip == brush.tip
&& BrushSettings { color: brush.color, size: brush.size, background: brush.background, ..preset.clone() } == *brush
&& BrushSettings {
color: brush.color,
size: brush.size,
background: brush.background,
smoothing: brush.smoothing.clone(),
locks: brush.locks.clone(),
..preset.clone()
} == *brush
}
/// A fresh "Brush N" name.
fn new_preset_name(presets: &[paint::BrushPreset]) -> String {
pub(crate) fn new_preset_name(presets: &[paint::BrushPreset]) -> String {
(1..).map(|n| format!("Brush {n}")).find(|n| !presets.iter().any(|p| &p.name == n)).unwrap_or_else(|| "Brush".into())
}
fn run_or_status(app: &mut PhotocraftApp, id: &str, p: Value) {
pub(crate) fn run_or_status(app: &mut PhotocraftApp, id: &str, p: Value) {
if let Err(e) = app.run(id, p) {
app.ui.status = e;
}
}
fn full_uv() -> egui::Rect {
pub(crate) fn full_uv() -> egui::Rect {
egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0))
}
fn brushes_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
// Size of the current brush (Photoshop's Brushes panel slider).
let before = app.session.tools.brush.clone();
let mut b = before.clone();
ui.horizontal(|ui| {
ui.label(RichText::new("Size").color(t.text_dim));
let mut lv = b.size.max(1.0).ln();
ui.add_sized(vec2(WIDTH - 140.0, 18.0), |ui: &mut egui::Ui| {
let r = widgets::slider(ui, &mut lv, 0.0..=5000f32.ln(), None);
if r.changed() {
b.size = lv.exp().round().clamp(1.0, 5000.0);
}
r
});
let mut s = b.size;
if widgets::value_field(ui, &mut s, 1.0..=5000.0, "px", 74.0).changed() {
b.size = s.round().clamp(1.0, 5000.0);
}
});
commit(app, &before, &b);
ui.add_space(4.0);
ui.horizontal(|ui| {
icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint);
ui.add_space(20.0);
ui.add(egui::TextEdit::singleline(&mut app.ui.brushes_panel.filter).hint_text("Search Brushes").desired_width(WIDTH - 60.0));
});
ui.add_space(6.0);
let filter = app.ui.brushes_panel.filter.trim().to_lowercase();
let groups = grouped_presets(&app.session.tools.presets);
let brush = &app.session.tools.brush;
let mut clicked = None;
let mut toggle = None;
egui::ScrollArea::vertical().id_salt("brush-presets").max_height(400.0).auto_shrink([false, true]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 1.0;
for (group, items) in groups {
let items: Vec<usize> = items
.into_iter()
.filter(|i| filter.is_empty() || app.session.tools.presets.get(*i).is_some_and(|p| p.name.to_lowercase().contains(&filter)))
.collect();
if items.is_empty() {
continue;
}
let open = !filter.is_empty() || !app.ui.brushes_panel.collapsed.contains(&group);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, t.radius_sm, t.hover);
}
let x = r.left() + 4.0;
icons::paint(
ui,
egui::Rect::from_min_size(egui::pos2(x, r.center().y - 7.0), vec2(14.0, 14.0)),
if open { "chevron-down" } else { "chevron-right" },
12.0,
t.text_dim,
);
icons::paint(
ui,
egui::Rect::from_min_size(egui::pos2(x + 18.0, r.center().y - 8.0), vec2(16.0, 16.0)),
if open { "folder-open" } else { "folder" },
14.0,
t.icon,
);
ui.painter().text(egui::pos2(x + 40.0, r.center().y), egui::Align2::LEFT_CENTER, &group, theme::semibold(12.0), t.text);
ui.painter().text(
egui::pos2(r.right() - 8.0, r.center().y),
egui::Align2::RIGHT_CENTER,
items.len().to_string(),
theme::medium(11.0),
t.text_faint,
);
if resp.clicked() {
toggle = Some(group.clone());
}
if !open {
continue;
}
for i in items {
let Some(p) = app.session.tools.presets.get(i) else { continue };
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click());
if !ui.is_rect_visible(r) {
continue;
}
if is_current(&p.brush, brush) {
ui.painter().rect_filled(r, t.radius_sm, t.accent_soft);
} else if resp.hovered() {
ui.painter().rect_filled(r, t.radius_sm, t.hover);
}
let pb = &p.brush;
let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text);
let cell = egui::Rect::from_min_size(r.left_top() + vec2(22.0, 2.0), vec2(36.0, 30.0));
let tr = egui::Rect::from_center_size(cell.center(), vec2(30.0, 30.0) * crate::brush_sections::thumb_scale(pb.size));
ui.painter().image(tip.id(), tr, full_uv(), Color32::WHITE);
ui.painter().text(
egui::pos2(cell.center().x, r.bottom() - 1.0),
egui::Align2::CENTER_BOTTOM,
format!("{}", pb.size.round() as i64),
egui::FontId::proportional(9.0),
t.text_faint,
);
let stroke = brush_preview::stroke_texture(ui.ctx(), &format!("brushes-stroke:{}", p.name), pb, 170, 36, t.text);
let sr = egui::Rect::from_min_size(egui::pos2(cell.right() + 8.0, r.top() + 4.0), vec2(170.0, 36.0));
ui.painter().image(stroke.id(), sr, full_uv(), Color32::WHITE);
ui.painter().text(
egui::pos2(sr.right() + 12.0, r.center().y),
egui::Align2::LEFT_CENTER,
&p.name,
egui::FontId::proportional(12.0),
t.text_dim,
);
if resp.clicked() {
clicked = Some(p.name.clone());
}
}
ui.add_space(2.0);
}
});
if let Some(g) = toggle {
let c = &mut app.ui.brushes_panel.collapsed;
match c.iter().position(|x| *x == g) {
Some(i) => {
c.remove(i);
}
None => c.push(g),
}
}
if let Some(name) = clicked {
run_or_status(app, "tools.setBrush", json!({ "preset": name }));
}
ui.add_space(4.0);
widgets::hairline(ui);
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(RichText::new(format!("{} presets", app.session.tools.presets.len())).color(t.text_faint));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let current = app.session.tools.presets.iter().find(|p| is_current(&p.brush, &app.session.tools.brush)).map(|p| p.name.clone());
if ui.add_enabled_ui(current.is_some(), |ui| icons::button(ui, "trash", 24.0, false, "Delete brush")).inner.clicked()
&& let Some(name) = current
{
run_or_status(app, "brush.presets.delete", json!({ "name": name }));
}
if icons::button(ui, "square-plus", 24.0, false, "Create new brush from the current settings").clicked() {
let name = new_preset_name(&app.session.tools.presets);
run_or_status(app, "brush.presets.save", json!({ "name": name }));
}
if icons::button(ui, "folder-open", 24.0, false, "Import Brushes… (.abr)").clicked() {
app.open_dialog_file();
}
});
});
// Forget previews of presets that no longer exist.
let names: std::collections::HashSet<&str> = app.session.tools.presets.iter().map(|p| p.name.as_str()).collect();
brush_preview::with_cache(ui.ctx(), |c| {
c.retain(|slot| slot.split_once(':').is_none_or(|(_, n)| names.contains(n)));
});
}
/// The section list: enable boxes, names, the selection. Clicking a name shows the section and
/// turns it on (Photoshop); clicking a box only toggles it.
/// The section list: enable boxes, names, locks, the selection. Clicking a name shows the section
/// and turns it on (Photoshop); clicking a box only toggles it; the lock on the right keeps the
/// section when another preset is picked.
fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSettings) {
let t = Tokens::get(ui.ctx());
ui.vertical(|ui| {
@@ -355,9 +247,10 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting
} else {
ui.painter().rect_stroke(br, 2.0, Stroke::new(1.2, t.text_faint), egui::StrokeKind::Inside);
}
let on_lock = resp.interact_pointer_pos().is_some_and(|p| p.x > r.right() - 22.0);
if box_resp.clicked() {
*flag = !*flag;
} else if resp.clicked() {
} else if resp.clicked() && !on_lock {
*flag = true;
}
x += 20.0;
@@ -367,7 +260,22 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting
let color = if sel { t.text } else { t.text_dim };
let font = if i == 0 { theme::semibold(12.0) } else { theme::medium(12.0) };
ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, *name, font, color);
if resp.clicked() {
let mut lock_clicked = false;
if let Some(lock) = section_lock(b, i) {
let lr = egui::Rect::from_center_size(egui::pos2(r.right() - 11.0, r.center().y), vec2(18.0, 18.0));
let lresp = ui.interact(lr, ui.id().with(("brush-sec-lock", i)), Sense::click());
// Like Photoshop: the lock shows only when set or hovered.
if *lock || resp.hovered() || lresp.hovered() {
let tint = if *lock { t.text } else { t.text_faint };
icons::paint(ui, lr, if *lock { "lock" } else { "lock-open" }, 12.0, tint);
}
let tip = if *lock { "Unlock: picking a preset replaces these settings" } else { "Lock: keep these settings when picking another preset" };
if lresp.on_hover_text(tip).clicked() {
*lock = !*lock;
lock_clicked = true;
}
}
if resp.clicked() && !lock_clicked {
app.ui.brush_section = i;
}
if i == 0 {
@@ -411,11 +319,11 @@ fn settings_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
run_or_status(app, "brush.presets.save", json!({ "name": name, "brush": serde_json::to_value(&b).unwrap_or(Value::Null) }));
}
if icons::button(ui, "undo-2", 24.0, false, "Reset the brush to the defaults").clicked() {
b = BrushSettings { color: b.color, background: b.background, smoothing: b.smoothing.clone(), ..Default::default() };
b = BrushSettings { color: b.color, background: b.background, smoothing: b.smoothing.clone(), locks: b.locks.clone(), ..Default::default() };
}
});
});
commit(app, &before, &b);
commit_gesture(app, ui.ctx(), &before, &b);
}
pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
@@ -457,7 +365,7 @@ pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
widgets::hairline(ui);
ui.add_space(6.0);
if app.ui.brush_tab == 1 {
brushes_tab(app, ui);
crate::brushes_tab::show(app, ui);
} else {
settings_tab(app, ui);
}
+151 -3
View File
@@ -34,6 +34,7 @@ fn edit_section(i: usize, b: &mut BrushSettings) {
b.roundness = 0.4;
b.hardness = 0.25;
b.spacing = 0.6;
b.spacing_enabled = false;
}
1 => {
let sd = &mut b.shape_dynamics;
@@ -43,6 +44,8 @@ fn edit_section(i: usize, b: &mut BrushSettings) {
sd.roundness = dynamic(0.5, Control::PenTilt, 0.25);
sd.flip_x_jitter = true;
sd.flip_y_jitter = true;
sd.tilt_scale = 1.25;
sd.brush_projection = true;
}
2 => {
let sc = &mut b.scattering;
@@ -92,6 +95,9 @@ fn edit_section(i: usize, b: &mut BrushSettings) {
b.transfer.enabled = true;
b.transfer.opacity = dynamic(0.5, Control::PenPressure, 0.1);
b.transfer.flow = dynamic(0.25, Control::Fade, 0.3);
b.transfer.wetness = dynamic(0.35, Control::PenPressure, 0.1);
b.transfer.mix = dynamic(0.45, Control::Fade, 0.2);
b.mixer = paint::MixerSettings { wet: 0.9, load: 0.15, mix: 0.7, flow: 0.8, sample_all_layers: true };
}
7 => {
let p = &mut b.pose;
@@ -136,7 +142,7 @@ fn every_section_round_trips_through_set_brush() {
let (id, p) = last_journal(&app).expect("journaled");
assert_eq!(id, "tools.setBrush");
let patch = &p["brush"];
assert!(patch.as_object().is_some_and(|o| !o.is_empty() && o.len() <= 7), "section {i}: {patch}");
assert!(patch.as_object().is_some_and(|o| !o.is_empty() && o.len() <= 8), "section {i}: {patch}");
// And the engine's view (`brush.get`) reads back the same settings.
let got = app.session.execute("brush.get", json!({})).unwrap();
assert_eq!(serde_json::from_value::<BrushSettings>(got).unwrap(), after, "section {i}");
@@ -189,6 +195,144 @@ fn bad_patches_fail_without_touching_the_brush() {
}
}
#[test]
fn section_locks_round_trip_and_survive_preset_picks() {
let mut b = BrushSettings::default();
for (i, (name, _)) in SECTIONS.iter().enumerate() {
assert_eq!(section_lock(&mut b, i).is_some(), i > 0, "{name} has a lock");
}
let mut app = app();
let before = app.session.tools.brush.clone();
let mut after = before.clone();
edit_section(2, &mut after);
for i in [2, 6, 8] {
*section_lock(&mut after, i).unwrap() = true;
}
commit(&mut app, &before, &after);
let (_, p) = last_journal(&app).unwrap();
assert_eq!(p["brush"]["locks"], json!({"scattering": true, "transfer": true, "noise": true}));
// Picking a preset keeps the locked sections (and the locks), and the preset still reads as current.
let chalk = paint::presets::find(&app.session.tools.presets, "Chalk").unwrap().brush.clone();
app.run("tools.setBrush", json!({"preset": "Chalk"})).unwrap();
let b = &app.session.tools.brush;
assert_eq!(b.scattering, after.scattering);
assert!(b.locks.scattering && b.locks.noise);
assert_eq!(b.tip, chalk.tip);
}
/// A harness that shows the options bar with `tool` in the Studio (non-pro) theme.
fn options_bar_harness(tool: crate::state::Tool) -> Harness<'static, PhotocraftApp> {
let mut app = app();
app.ui.tool = tool;
let mut h = Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
crate::panels::options_bar(app, ui);
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
h.run_steps(4);
h
}
#[test]
fn options_bar_edits_are_one_set_brush_per_gesture() {
use egui_kittest::kittest::Queryable;
let mut h = options_bar_harness(crate::state::Tool::Brush);
let n = h.state().session.journal.len();
h.run_steps(3);
assert_eq!(h.state().session.journal.len(), n, "showing the bar issues nothing");
// Drag the Opacity field left: many frames, one journal entry.
let label = h.get_by_label("Opacity").rect();
let start = egui::pos2(label.right() + 40.0, label.center().y);
h.hover_at(start);
h.run();
h.drag_at(start);
h.run();
for k in 1..=8 {
h.hover_at(start - vec2(5.0 * k as f32, 0.0));
h.run();
}
h.drop_at(start - vec2(40.0, 0.0));
h.run_steps(2);
let opacity = h.state().session.tools.brush.opacity;
assert!(opacity < 0.95, "the drag lowered the opacity: {opacity}");
let j = &h.state().session.journal;
assert_eq!(j.len(), n + 1, "{:?}", &j[n..]);
let (id, p) = j.last().unwrap();
assert_eq!(id, "tools.setBrush");
assert_eq!(p["brush"]["opacity"].as_f64().map(|v| v as f32), Some(opacity));
// A click is its own gesture: the Pressure for Size switch sits left of its label.
let label = h.get_by_label("Pressure for Size").rect();
let was = h.state().session.tools.brush.pressure_size;
let at = egui::pos2(label.left() - 18.0, label.center().y);
h.hover_at(at);
h.run();
h.drag_at(at);
h.run();
h.drop_at(at);
h.run_steps(2);
assert_eq!(h.state().session.tools.brush.pressure_size, !was);
assert_eq!(h.state().session.journal.len(), n + 2);
assert_eq!(h.state().session.journal.last().unwrap().1["brush"], json!({"pressureSize": !was}));
// Replaying the journal gives the same brush.
let mut s = photocraft_engine::Session::new();
for (id, p) in &h.state().session.journal {
s.execute(id, p.clone()).unwrap();
}
assert_eq!(s.tools.brush, h.state().session.tools.brush);
}
#[test]
fn drop_targets_and_actions_reorder_presets() {
use crate::brushes_tab::{Action, apply, drop_target, group_key};
let mut app = app();
for (n, g) in [("A1", "Alpha"), ("A2", "Alpha"), ("A3", "Alpha"), ("B1", "Beta")] {
app.session.tools.presets.push(paint::BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: g.into() });
}
let p = &app.session.tools.presets;
// Dropping A1 on A3's lower half lands after A3 (index 2 once A1 is out); on its upper half, before it.
assert_eq!(drop_target(p, "A1", "A3", true), Some(("Alpha".into(), 2)));
assert_eq!(drop_target(p, "A1", "A3", false), Some(("Alpha".into(), 1)));
assert_eq!(drop_target(p, "A3", "A1", false), Some(("Alpha".into(), 0)));
assert_eq!(drop_target(p, "A2", "B1", true), Some(("Beta".into(), 1)));
assert_eq!(drop_target(p, "A2", "Nope", true), None);
assert_eq!(group_key(p, UNGROUPED), "");
let (group, index) = drop_target(p, "A1", "A3", true).unwrap();
apply(&mut app, vec![Action::Move { name: "A1".into(), group, index: Some(index) }]);
let names = |app: &PhotocraftApp, g: &str| app.session.tools.presets.iter().filter(|x| x.group == g).map(|x| x.name.clone()).collect::<Vec<_>>();
assert_eq!(names(&app, "Alpha"), ["A2", "A3", "A1"]);
apply(&mut app, vec![Action::Move { name: "A2".into(), group: "Beta".into(), index: None }]);
assert_eq!(names(&app, "Beta"), ["B1", "A2"]);
apply(&mut app, vec![Action::MoveGroup { group: "Beta".into(), before: Some("Alpha".into()) }]);
let order = photocraft_engine::brush_preset_cmds::group_order(&app.session.tools.presets);
assert!(order.iter().position(|g| g == "Beta") < order.iter().position(|g| g == "Alpha"));
// Rename and delete from the context menu.
apply(&mut app, vec![Action::Rename(Renaming { group: false, name: "A3".into(), text: "Third".into() })]);
assert!(paint::presets::find(&app.session.tools.presets, "Third").is_some());
assert!(app.ui.brushes_panel.renaming.is_none());
apply(&mut app, vec![Action::Rename(Renaming { group: true, name: "Beta".into(), text: "Bees".into() })]);
assert_eq!(names(&app, "Bees"), ["B1", "A2"]);
apply(&mut app, vec![Action::Delete("Third".into()), Action::DeleteGroup("Bees".into())]);
assert_eq!(names(&app, "Alpha"), ["A1"]);
assert!(names(&app, "Bees").is_empty());
// Every action went through a journaled command.
let ids: Vec<&str> = app.session.journal.iter().map(|(id, _)| id.as_str()).collect();
for id in [
"brush.presets.move",
"brush.presets.moveGroup",
"brush.presets.rename",
"brush.presets.renameGroup",
"brush.presets.delete",
"brush.presets.deleteGroup",
] {
assert!(ids.contains(&id), "{id}: {ids:?}");
}
}
fn harness(tab: usize, section: usize) -> Harness<'static, PhotocraftApp> {
let mut app = app();
app.ui.panels.brush_settings = true;
@@ -229,9 +373,13 @@ fn preview_strip_re_renders_only_when_the_brush_changes() {
#[test]
fn every_section_draws_without_issuing_commands() {
for tab in 0..2 {
for tab in 0..3 {
for section in 0..SECTIONS.len() {
let mut h = harness(tab, section);
// Tab 2: the Brushes tab in grid view.
let mut h = harness(tab.min(1), section);
if tab == 2 {
h.state_mut().ui.brushes_panel.view = BrushesView::Grid;
}
// A brush with every section on and a sampled tip.
let mut b = BrushSettings::default();
for i in 0..SECTIONS.len() {
+37 -3
View File
@@ -328,12 +328,22 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset])
ui.add_enabled_ui(matches!(b.tip, TipShape::Round), |ui| {
pct(ui, "Hardness", &mut b.hardness, 1.0);
});
signed_pct(ui, "Spacing", &mut b.spacing, 0.01, 10.0);
// Unchecked, the pointer's speed sets the spacing (Photoshop).
widgets::checkbox(ui, &mut b.spacing_enabled, "Spacing").on_hover_text("Off: the speed of the pointer sets the spacing");
ui.add_space(2.0);
let on = b.spacing_enabled;
ui.add_enabled_ui(on, |ui| signed_pct(ui, "Spacing", &mut b.spacing, 0.01, 10.0));
}
fn shape_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) {
let sd = &mut b.shape_dynamics;
dynamic(ui, "brush-size-ctl", "Size Jitter", &mut sd.size, 1.0, Some("Minimum Diameter"), false);
pct(ui, "Size Jitter", &mut sd.size.jitter, 1.0);
control_row(ui, "brush-size-ctl", &mut sd.size, false);
pct(ui, "Minimum Diameter", &mut sd.size.minimum, 1.0);
// Tilt Scale applies with the size control on Pen Tilt (greyed out otherwise, like Photoshop).
let tilt = sd.size.control == Control::PenTilt;
ui.add_enabled_ui(tilt, |ui| pct(ui, "Tilt Scale", &mut sd.tilt_scale, 2.0));
ui.add_space(6.0);
dynamic(ui, "brush-angle-ctl", "Angle Jitter", &mut sd.angle, 1.0, None, true);
dynamic(ui, "brush-round-ctl", "Roundness Jitter", &mut sd.roundness, 1.0, Some("Minimum Roundness"), false);
ui.horizontal(|ui| {
@@ -341,6 +351,8 @@ fn shape_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) {
ui.add_space(8.0);
widgets::checkbox(ui, &mut sd.flip_y_jitter, "Flip Y Jitter");
});
ui.add_space(2.0);
widgets::checkbox(ui, &mut sd.brush_projection, "Brush Projection").on_hover_text("Apply the pen's tilt and rotation to the tip shape");
}
fn scattering(ui: &mut egui::Ui, b: &mut BrushSettings) {
@@ -519,6 +531,25 @@ fn transfer(ui: &mut egui::Ui, b: &mut BrushSettings) {
let tr = &mut b.transfer;
dynamic(ui, "brush-opacity-ctl", "Opacity Jitter", &mut tr.opacity, 1.0, Some("Minimum"), false);
dynamic(ui, "brush-flow-ctl", "Flow Jitter", &mut tr.flow, 1.0, Some("Minimum"), false);
dynamic(ui, "brush-wet-ctl", "Wetness Jitter", &mut tr.wetness, 1.0, Some("Minimum"), false);
dynamic(ui, "brush-mix-ctl", "Mix Jitter", &mut tr.mix, 1.0, Some("Minimum"), false);
}
/// The Mixer Brush options the brush carries (Photoshop shows them in the Mixer Brush's options
/// bar; `paint.mixerBrush` uses them unless its params override them).
fn mixer_options(ui: &mut egui::Ui, b: &mut BrushSettings) {
let t = Tokens::get(ui.ctx());
ui.add_space(4.0);
widgets::hairline(ui);
ui.add_space(4.0);
ui.label(RichText::new("Mixer Brush").font(theme::semibold(11.5)).color(t.text_dim));
ui.add_space(2.0);
let m = &mut b.mixer;
pct(ui, "Wet", &mut m.wet, 1.0);
pct(ui, "Load", &mut m.load, 1.0);
pct(ui, "Mix", &mut m.mix, 1.0);
pct(ui, "Flow", &mut m.flow, 1.0);
widgets::checkbox(ui, &mut m.sample_all_layers, "Sample All Layers");
}
/// Tilt is stored in degrees (±90, W3C Pointer Events); Photoshop shows it as ±100 %.
@@ -564,7 +595,10 @@ pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets:
3 => texture(ui, b),
4 => dual_brush(ui, b, presets),
5 => color_dynamics(ui, b),
6 => transfer(ui, b),
6 => {
transfer(ui, b);
mixer_options(ui, b);
}
7 => pose(ui, b),
8 => note(ui, "Adds extra randomness to the soft edges of the brush tip. No options: turn it on in the list."),
9 => note(ui, "Builds up paint along the edges of the stroke for a watercolour look. No options: turn it on in the list."),
+415
View File
@@ -0,0 +1,415 @@
//! Window › Brushes: the brush presets in collapsible groups, as a list (tip, size, stroke
//! preview, name) or a grid of tips, with a size slider and a search field.
//!
//! Presets and groups are organised like Photoshop's panel: drag a preset within its group or
//! into another group (onto a preset, or onto a group's header to append), drag a group's header
//! to reorder groups, and right-click for Rename and Delete. Every change is a `brush.presets.*`
//! command, so it is journaled, drivable, and persisted by the preset store.
use egui::{Color32, RichText, Sense, Stroke, pos2, vec2};
use photocraft_engine::paint::BrushPreset;
use serde_json::json;
use crate::brush_panel::{BrushesView, Renaming, UNGROUPED, WIDTH, commit_gesture, full_uv, grouped_presets, is_current, new_preset_name, run_or_status};
use crate::brush_preview;
use crate::theme::{self, Tokens};
use crate::{PhotocraftApp, icons, widgets};
/// What a drag in the Brushes tab carries.
#[derive(Clone, Debug, PartialEq)]
pub enum BrushDrag {
Preset(String),
Group(String),
}
/// What the tab does after drawing (the presets are borrowed while it draws).
#[derive(Clone, Debug, PartialEq)]
pub enum Action {
Select(String),
ToggleGroup(String),
Move { name: String, group: String, index: Option<usize> },
MoveGroup { group: String, before: Option<String> },
Rename(Renaming),
Delete(String),
DeleteGroup(String),
}
/// The group key (`BrushPreset::group`) behind a panel label: ungrouped presets show as
/// [`UNGROUPED`].
pub fn group_key(presets: &[BrushPreset], label: &str) -> String {
if label == UNGROUPED && !presets.iter().any(|p| p.group == UNGROUPED) { String::new() } else { label.to_string() }
}
/// Where a preset dropped onto `target` lands: (group, index in that group after the dragged
/// preset is taken out). `after` = dropped on the target's lower (list) or right (grid) half.
pub fn drop_target(presets: &[BrushPreset], dragged: &str, target: &str, after: bool) -> Option<(String, usize)> {
let t = presets.iter().find(|p| p.name == target)?;
let members: Vec<&str> = presets.iter().filter(|p| p.group == t.group).map(|p| p.name.as_str()).collect();
let tp = members.iter().position(|n| *n == target)?;
let mut idx = tp + usize::from(after);
if let Some(dp) = members.iter().position(|n| *n == dragged)
&& dp < idx
{
idx -= 1;
}
Some((t.group.clone(), idx))
}
/// Turn the tab's actions into commands.
pub fn apply(app: &mut PhotocraftApp, acts: Vec<Action>) {
for a in acts {
match a {
Action::Select(name) => run_or_status(app, "tools.setBrush", json!({ "preset": name })),
Action::ToggleGroup(g) => {
let c = &mut app.ui.brushes_panel.collapsed;
match c.iter().position(|x| *x == g) {
Some(i) => drop(c.remove(i)),
None => c.push(g),
}
}
Action::Move { name, group, index } => {
let mut p = json!({ "name": name, "group": group });
if let Some(i) = index {
p["index"] = json!(i);
}
run_or_status(app, "brush.presets.move", p);
}
Action::MoveGroup { group, before } => run_or_status(app, "brush.presets.moveGroup", json!({ "group": group, "before": before })),
Action::Rename(r) => {
let text = r.text.trim().to_string();
if !text.is_empty() && text != r.name {
if r.group {
run_or_status(app, "brush.presets.renameGroup", json!({ "group": r.name, "newName": text }));
} else {
run_or_status(app, "brush.presets.rename", json!({ "name": r.name, "newName": text }));
}
}
app.ui.brushes_panel.renaming = None;
}
Action::Delete(name) => run_or_status(app, "brush.presets.delete", json!({ "name": name })),
Action::DeleteGroup(g) => run_or_status(app, "brush.presets.deleteGroup", json!({ "group": g })),
}
}
}
/// An accent insertion line on one edge of `r` while a drag hovers it.
fn drop_line(ui: &egui::Ui, r: egui::Rect, after: bool, vertical: bool, t: &Tokens) {
let s = Stroke::new(2.0, t.accent);
if vertical {
let x = if after { r.right() } else { r.left() };
ui.painter().line_segment([pos2(x, r.top() + 2.0), pos2(x, r.bottom() - 2.0)], s);
} else {
let y = if after { r.bottom() } else { r.top() };
ui.painter().line_segment([pos2(r.left() + 4.0, y), pos2(r.right() - 4.0, y)], s);
}
}
/// Is the pointer on the second half of `r` (lower in a list, right in a grid)?
fn second_half(ui: &egui::Ui, r: egui::Rect, grid: bool) -> bool {
ui.ctx().pointer_interact_pos().is_some_and(|p| if grid { p.x > r.center().x } else { p.y > r.center().y })
}
/// Drag, drop and context menu of one preset cell or row.
fn preset_interactions(ui: &egui::Ui, resp: &egui::Response, r: egui::Rect, p: &BrushPreset, presets: &[BrushPreset], grid: bool, acts: &mut Vec<Action>) {
let t = Tokens::get(ui.ctx());
resp.dnd_set_drag_payload(BrushDrag::Preset(p.name.clone()));
if let Some(d) = resp.dnd_hover_payload::<BrushDrag>()
&& matches!(&*d, BrushDrag::Preset(n) if *n != p.name)
{
drop_line(ui, r, second_half(ui, r, grid), grid, &t);
}
if let Some(d) = resp.dnd_release_payload::<BrushDrag>()
&& let BrushDrag::Preset(n) = &*d
&& *n != p.name
&& let Some((group, index)) = drop_target(presets, n, &p.name, second_half(ui, r, grid))
{
acts.push(Action::Move { name: n.clone(), group, index: Some(index) });
}
if resp.clicked() {
acts.push(Action::Select(p.name.clone()));
}
resp.context_menu(|ui| {
if ui.button("Rename Brush…").clicked() {
acts.push(Action::Rename(Renaming { group: false, name: p.name.clone(), text: String::new() }));
ui.close();
}
if ui.button("Delete Brush").clicked() {
acts.push(Action::Delete(p.name.clone()));
ui.close();
}
});
}
fn list_row(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushPreset], acts: &mut Vec<Action>) {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click_and_drag());
if !ui.is_rect_visible(r) {
return;
}
if current {
ui.painter().rect_filled(r, t.radius_sm, t.accent_soft);
} else if resp.hovered() {
ui.painter().rect_filled(r, t.radius_sm, t.hover);
}
let pb = &p.brush;
let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text);
let cell = egui::Rect::from_min_size(r.left_top() + vec2(22.0, 2.0), vec2(36.0, 30.0));
let tr = egui::Rect::from_center_size(cell.center(), vec2(30.0, 30.0) * crate::brush_sections::thumb_scale(pb.size));
ui.painter().image(tip.id(), tr, full_uv(), Color32::WHITE);
ui.painter().text(
pos2(cell.center().x, r.bottom() - 1.0),
egui::Align2::CENTER_BOTTOM,
format!("{}", pb.size.round() as i64),
egui::FontId::proportional(9.0),
t.text_faint,
);
let stroke = brush_preview::stroke_texture(ui.ctx(), &format!("brushes-stroke:{}", p.name), pb, 170, 36, t.text);
let sr = egui::Rect::from_min_size(pos2(cell.right() + 8.0, r.top() + 4.0), vec2(170.0, 36.0));
ui.painter().image(stroke.id(), sr, full_uv(), Color32::WHITE);
ui.painter().text(pos2(sr.right() + 12.0, r.center().y), egui::Align2::LEFT_CENTER, &p.name, egui::FontId::proportional(12.0), t.text_dim);
preset_interactions(ui, &resp, r, p, presets, false, acts);
}
fn grid_cell(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushPreset], acts: &mut Vec<Action>) {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(52.0, 58.0), Sense::click_and_drag());
if !ui.is_rect_visible(r) {
return;
}
if current {
ui.painter().rect_filled(r, 3.0, t.accent_soft);
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
} else if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
let pb = &p.brush;
let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text);
let ir = egui::Rect::from_center_size(pos2(r.center().x, r.top() + 23.0), vec2(38.0, 38.0) * crate::brush_sections::thumb_scale(pb.size));
ui.painter().image(tip.id(), ir, full_uv(), Color32::WHITE);
ui.painter().text(
pos2(r.center().x, r.bottom() - 7.0),
egui::Align2::CENTER_CENTER,
format!("{}", pb.size.round() as i64),
egui::FontId::proportional(9.5),
t.text_dim,
);
let resp = resp.on_hover_text(&p.name);
preset_interactions(ui, &resp, r, p, presets, true, acts);
}
/// A group's header: chevron, folder, name, count. Click toggles; drag reorders groups; a
/// preset dropped here moves to the end of the group.
fn group_header(ui: &mut egui::Ui, label: &str, key: &str, open: bool, count: usize, acts: &mut Vec<Action>) {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click_and_drag());
if resp.hovered() {
ui.painter().rect_filled(r, t.radius_sm, t.hover);
}
let x = r.left() + 4.0;
icons::paint(
ui,
egui::Rect::from_min_size(pos2(x, r.center().y - 7.0), vec2(14.0, 14.0)),
if open { "chevron-down" } else { "chevron-right" },
12.0,
t.text_dim,
);
icons::paint(
ui,
egui::Rect::from_min_size(pos2(x + 18.0, r.center().y - 8.0), vec2(16.0, 16.0)),
if open { "folder-open" } else { "folder" },
14.0,
t.icon,
);
ui.painter().text(pos2(x + 40.0, r.center().y), egui::Align2::LEFT_CENTER, label, theme::semibold(12.0), t.text);
ui.painter().text(pos2(r.right() - 8.0, r.center().y), egui::Align2::RIGHT_CENTER, count.to_string(), theme::medium(11.0), t.text_faint);
resp.dnd_set_drag_payload(BrushDrag::Group(key.to_string()));
if let Some(d) = resp.dnd_hover_payload::<BrushDrag>() {
match &*d {
BrushDrag::Group(g) if g != key => drop_line(ui, r, false, false, &t),
BrushDrag::Preset(_) => {
ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
}
_ => {}
}
}
if let Some(d) = resp.dnd_release_payload::<BrushDrag>() {
match &*d {
BrushDrag::Group(g) if g != key => acts.push(Action::MoveGroup { group: g.clone(), before: Some(key.to_string()) }),
BrushDrag::Preset(n) => acts.push(Action::Move { name: n.clone(), group: key.to_string(), index: None }),
_ => {}
}
}
if resp.clicked() {
acts.push(Action::ToggleGroup(label.to_string()));
}
resp.context_menu(|ui| {
if ui.button("Rename Group…").clicked() {
acts.push(Action::Rename(Renaming { group: true, name: key.to_string(), text: String::new() }));
ui.close();
}
if ui.button("Delete Group").clicked() {
acts.push(Action::DeleteGroup(key.to_string()));
ui.close();
}
});
}
/// The rename bar shown while a preset or group is being renamed. Enter or OK renames, Escape or
/// Cancel stops.
fn rename_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, acts: &mut Vec<Action>) {
let Some(r) = app.ui.brushes_panel.renaming.as_mut() else { return };
let t = Tokens::get(ui.ctx());
if r.text.is_empty() {
r.text = r.name.clone();
}
let mut cancel = false;
ui.horizontal(|ui| {
ui.label(RichText::new(if r.group { "Group name" } else { "Brush name" }).color(t.text_dim));
let resp = ui.add(egui::TextEdit::singleline(&mut r.text).desired_width(WIDTH - 230.0).id_salt("brush-rename"));
if !resp.has_focus() && !resp.lost_focus() {
resp.request_focus();
}
let enter = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if widgets::primary_button(ui, "OK", 52.0).clicked() || enter {
acts.push(Action::Rename(r.clone()));
}
if widgets::secondary_button(ui, "Cancel", 60.0).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
cancel = true;
}
});
if cancel {
app.ui.brushes_panel.renaming = None;
}
ui.add_space(4.0);
}
/// The Brushes tab.
pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
// Size of the current brush (Photoshop's Brushes panel slider).
let before = app.session.tools.brush.clone();
let mut b = before.clone();
ui.horizontal(|ui| {
ui.label(RichText::new("Size").color(t.text_dim));
let mut lv = b.size.max(1.0).ln();
ui.add_sized(vec2(WIDTH - 140.0, 18.0), |ui: &mut egui::Ui| {
let r = widgets::slider(ui, &mut lv, 0.0..=5000f32.ln(), None);
if r.changed() {
b.size = lv.exp().round().clamp(1.0, 5000.0);
}
r
});
let mut s = b.size;
if widgets::value_field(ui, &mut s, 1.0..=5000.0, "px", 74.0).changed() {
b.size = s.round().clamp(1.0, 5000.0);
}
});
commit_gesture(app, ui.ctx(), &before, &b);
ui.add_space(4.0);
ui.horizontal(|ui| {
icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint);
ui.add_space(20.0);
ui.add(egui::TextEdit::singleline(&mut app.ui.brushes_panel.filter).hint_text("Search Brushes").desired_width(WIDTH - 120.0));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let view = &mut app.ui.brushes_panel.view;
if icons::button(ui, "grid-2x2", 24.0, *view == BrushesView::Grid, "Grid view").clicked() {
*view = BrushesView::Grid;
}
if icons::button(ui, "align-justify", 24.0, *view == BrushesView::List, "List view").clicked() {
*view = BrushesView::List;
}
});
});
ui.add_space(6.0);
let mut acts = Vec::new();
rename_bar(app, ui, &mut acts);
let filter = app.ui.brushes_panel.filter.trim().to_lowercase();
let grid = app.ui.brushes_panel.view == BrushesView::Grid;
let presets = &app.session.tools.presets;
let groups = grouped_presets(presets);
let brush = &app.session.tools.brush;
let collapsed = &app.ui.brushes_panel.collapsed;
egui::ScrollArea::vertical().id_salt("brush-presets").max_height(400.0).auto_shrink([false, true]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 1.0;
for (label, items) in groups {
let items: Vec<usize> =
items.into_iter().filter(|i| filter.is_empty() || presets.get(*i).is_some_and(|p| p.name.to_lowercase().contains(&filter))).collect();
if items.is_empty() {
continue;
}
let key = group_key(presets, &label);
let open = !filter.is_empty() || !collapsed.contains(&label);
group_header(ui, &label, &key, open, items.len(), &mut acts);
if !open {
continue;
}
if grid {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(3.0, 3.0);
ui.add_space(20.0);
for i in items {
if let Some(p) = presets.get(i) {
grid_cell(ui, p, is_current(&p.brush, brush), presets, &mut acts);
}
}
});
} else {
for i in items {
if let Some(p) = presets.get(i) {
list_row(ui, p, is_current(&p.brush, brush), presets, &mut acts);
}
}
}
ui.add_space(2.0);
}
});
// The dragged preset or group follows the pointer.
if let Some(d) = egui::DragAndDrop::payload::<BrushDrag>(ui.ctx())
&& let Some(p) = ui.ctx().pointer_interact_pos()
{
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
let name = match &*d {
BrushDrag::Preset(n) => n.clone(),
BrushDrag::Group(g) => {
if g.is_empty() {
UNGROUPED.to_string()
} else {
g.clone()
}
}
};
egui::Area::new(egui::Id::new("brush-drag-label")).order(egui::Order::Tooltip).fixed_pos(p + vec2(12.0, 8.0)).interactable(false).show(
ui.ctx(),
|ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| ui.label(name));
},
);
}
apply(app, acts);
ui.add_space(4.0);
widgets::hairline(ui);
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(RichText::new(format!("{} presets", app.session.tools.presets.len())).color(t.text_faint));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let current = app.session.tools.presets.iter().find(|p| is_current(&p.brush, &app.session.tools.brush)).map(|p| p.name.clone());
if ui.add_enabled_ui(current.is_some(), |ui| icons::button(ui, "trash", 24.0, false, "Delete brush")).inner.clicked()
&& let Some(name) = current
{
run_or_status(app, "brush.presets.delete", json!({ "name": name }));
}
if icons::button(ui, "square-plus", 24.0, false, "Create new brush from the current settings").clicked() {
let name = new_preset_name(&app.session.tools.presets);
run_or_status(app, "brush.presets.save", json!({ "name": name }));
}
if icons::button(ui, "folder-open", 24.0, false, "Import Brushes… (.abr)").clicked() {
app.open_dialog_file();
}
});
});
// Forget previews of presets that no longer exist.
let names: std::collections::HashSet<&str> = app.session.tools.presets.iter().map(|p| p.name.as_str()).collect();
brush_preview::with_cache(ui.ctx(), |c| {
c.retain(|slot| slot.split_once(':').is_none_or(|(_, n)| names.contains(n)));
});
}
+37 -10
View File
@@ -1516,6 +1516,16 @@ fn draw_drag_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXfor
}
}
fn sample_eyedropper(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) {
if let Ok(v) = app.run("document.pixel", json!({"x": x.floor(), "y": y.floor()})) {
let color: Vec<f32> = serde_json::from_value(v).unwrap_or_default();
if color.len() == 4 && color[3] > 0.0 {
let key = if mods.alt { "background" } else { "foreground" };
let _ = app.run("tools.setColors", json!({ key: [color[0], color[1], color[2], 1.0] }));
}
}
}
/// Tool state machine. Shared by mouse input and automation.
pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) {
// View › Snap / Snap To and smart guides (snap_ui.rs).
@@ -1528,6 +1538,8 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
}
// Window › Modifier Keys: sticky Shift/⌘/⌥ act as held keys.
let mods = crate::workspace_ui::sticky_mods(app, mods);
// Move tool: ⇧ locks the axis, ⌥ duplicates (move_mods.rs).
let ev = crate::move_mods::filter_event(app, ev, mods);
// Ruler, Count and Note tools.
if crate::analysis_ui::pointer(app, ev, mods) {
return;
@@ -1541,6 +1553,15 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
return;
}
let tool = app.ui.tool;
if tool == Tool::Eyedropper {
match ev {
ToolEvent::Down { x, y, .. } | ToolEvent::Move { x, y, .. } => {
sample_eyedropper(app, x, y, mods);
return;
}
ToolEvent::Up { .. } => return,
}
}
// Move tool over a guide drags the guide (off the canvas deletes it).
match ev {
ToolEvent::Down { x, y, .. } if tool == Tool::Move => {
@@ -1573,16 +1594,6 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
}
match ev {
ToolEvent::Down { x, y, pressure } => {
if tool == Tool::Eyedropper {
if let Ok(v) = app.run("document.pixel", json!({"x": x.floor(), "y": y.floor()})) {
let c: Vec<f32> = serde_json::from_value(v).unwrap_or_default();
if c.len() == 4 && c[3] > 0.0 {
let key = if mods.alt { "background" } else { "foreground" };
let _ = app.run("tools.setColors", json!({ key: [c[0], c[1], c[2], 1.0] }));
}
}
return;
}
match tool {
Tool::Pen => {
crate::vector_ui::pen_down(app, x, y);
@@ -1659,6 +1670,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
app.stylus.record_point();
}
finish_gesture(app, d);
crate::move_mods::finish(app);
}
}
}
@@ -1856,6 +1868,21 @@ mod tests {
}
}
#[test]
fn eyedropper_drag_updates_the_sampled_colour() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 40, "height": 20, "background": "transparent"})).unwrap();
app.run("shape.create", json!({"kind": "rect", "rect": [0, 0, 20, 20], "fill": "#ff0000"})).unwrap();
app.run("shape.create", json!({"kind": "rect", "rect": [20, 0, 20, 20], "fill": "#00ff00"})).unwrap();
app.ui.tool = Tool::Eyedropper;
tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, egui::Modifiers::NONE);
assert!(app.session.tools.foreground[0] > 0.99 && app.session.tools.foreground[1] < 0.01);
tool_event(&mut app, ToolEvent::Move { x: 30.0, y: 10.0, pressure: 1.0 }, egui::Modifiers::NONE);
assert!(app.session.tools.foreground[1] > 0.99 && app.session.tools.foreground[0] < 0.01);
}
#[test]
fn brush_drag_shows_the_real_stroke_and_commits_it() {
// The drag used to draw a hard, flat stand-in and only showed the soft brush on release.
+6
View File
@@ -173,6 +173,12 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
if let Some(i) = u("brushTab") {
app.ui.brush_tab = (i as usize).min(1);
}
if let Some(v) = p.get("brushesView").cloned() {
match serde_json::from_value(v) {
Ok(v) => app.ui.brushes_panel.view = v,
Err(e) => return err(format!("brushesView: {e} (list, grid)")),
}
}
if let Some(size) = p.get("brushSize").and_then(Value::as_f64) {
app.session.tools.brush.size = size as f32;
}
+106 -10
View File
@@ -29,6 +29,8 @@ pub enum Group {
Color,
/// Properties | Adjustments.
Properties,
/// Character | Paragraph (not in the default workspace; Window › Character opens it, #150).
Character,
/// Navigator | Histogram | Info.
Navigator,
/// History | Actions | Layer Comps.
@@ -39,29 +41,54 @@ pub enum Group {
impl Group {
/// Photoshop Essentials order, top to bottom.
pub const ALL: [Group; 5] = [Group::Color, Group::Properties, Group::Navigator, Group::History, Group::Layers];
pub const ALL: [Group; 6] = [Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History, Group::Layers];
pub fn key(self) -> &'static str {
match self {
Group::Color => "color",
Group::Properties => "properties",
Group::Character => "character",
Group::Navigator => "navigator",
Group::History => "history",
Group::Layers => "layers",
}
}
/// Height (points, tab strip included) a group gets until the user resizes it.
/// Height (points, tab strip included) a group gets until the user resizes it, when the
/// column has room (see [`DockLayout::heights_for`]).
pub fn default_height(self) -> f32 {
match self {
Group::Color => 200.0,
Group::Properties => 340.0,
Group::Navigator => 230.0,
Group::History => 220.0,
Group::Color => 190.0,
Group::Properties => 250.0,
Group::Character => 270.0,
Group::Navigator => 210.0,
Group::History => 200.0,
Group::Layers => 320.0,
}
}
/// What a group left at its default height gives way down to so the filler (Layers) keeps
/// [`Group::preferred_fill`] in a short column (#147). Content taller than this scrolls.
pub fn compact_height(self) -> f32 {
match self {
Group::Color => 130.0,
Group::Properties => 160.0,
Group::Character => 160.0,
Group::Navigator => 140.0,
Group::History => 130.0,
Group::Layers => 200.0,
}
}
/// The height the filling group asks for before default-sized groups above it get their
/// full defaults: Layers wants room for about ten rows at 900 pt (#147).
pub fn preferred_fill(self) -> f32 {
match self {
Group::Layers => 500.0,
g => g.min_height(),
}
}
/// The smallest height an expanded group can be dragged or squeezed to.
pub fn min_height(self) -> f32 {
match self {
@@ -76,6 +103,7 @@ impl Group {
Group::Color if pro => &["Color", "Swatches", "Gradients", "Patterns"],
Group::Color => &["Swatches", "Color", "Gradients", "Patterns"],
Group::Properties => &["Properties", "Adjustments"],
Group::Character => &["Character", "Paragraph"],
Group::Navigator => &["Navigator", "Histogram", "Info"],
Group::History => &["History", "Actions", "Layer Comps"],
Group::Layers => &["Layers", "Channels", "Paths"],
@@ -91,6 +119,7 @@ impl Group {
match self {
Group::Color => &mut tabs.color,
Group::Properties => &mut tabs.properties,
Group::Character => &mut tabs.character,
Group::Navigator => &mut tabs.navigator,
Group::History => &mut tabs.history,
Group::Layers => &mut tabs.layers,
@@ -106,6 +135,7 @@ impl Group {
match self {
Group::Color => panels.color,
Group::Properties => panels.properties,
Group::Character => panels.character,
Group::Navigator => panels.navigator,
Group::History => panels.history,
Group::Layers => panels.layers,
@@ -116,6 +146,7 @@ impl Group {
match self {
Group::Color => &mut panels.color,
Group::Properties => &mut panels.properties,
Group::Character => &mut panels.character,
Group::Navigator => &mut panels.navigator,
Group::History => &mut panels.history,
Group::Layers => &mut panels.layers,
@@ -127,7 +158,8 @@ impl Group {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct DockLayout {
/// Top-to-bottom order; groups missing here keep their default place after the listed ones.
/// Top-to-bottom order; a group missing here (say one added after the layout was saved)
/// goes just below the nearest group that precedes it by default (or last).
pub order: Vec<Group>,
/// Heights the user dragged groups to (points, tab strip included). Unset = default.
pub heights: BTreeMap<Group, f32>,
@@ -144,11 +176,23 @@ impl DockLayout {
/// Every group once, in display order.
pub fn order(&self) -> Vec<Group> {
let mut out: Vec<Group> = Vec::with_capacity(Group::ALL.len());
for g in self.order.iter().chain(Group::ALL.iter()) {
for g in &self.order {
if !out.contains(g) {
out.push(*g);
}
}
// Missing groups slot in below their default predecessor, so a group new to an old
// saved layout (Character) never lands below Layers and takes over as the filler.
for (i, g) in Group::ALL.iter().enumerate() {
if out.contains(g) {
continue;
}
let prev = Group::ALL.iter().take(i).rev().find_map(|p| out.iter().position(|x| x == p));
match prev {
Some(at) => out.insert(at + 1, *g),
None => out.push(*g),
}
}
out
}
@@ -185,7 +229,9 @@ impl DockLayout {
/// Lay out the `shown` groups (in display order) in a column `avail` points tall with
/// `strip`-high tab strips. Returns each group's height. The last expanded group fills the
/// rest; when the column is too short the others give way down to their minimum heights.
/// rest. Groups the user never resized give way first, down to their compact heights, so
/// the filler gets its preferred height (Layers: ~10 rows, #147); when the column is still
/// too short every group gives way down to its minimum height.
pub fn heights_for(&self, shown: &[Group], avail: f32, strip: f32) -> Vec<(Group, f32)> {
let avail = if avail.is_finite() { avail.max(0.0) } else { 0.0 };
let filler = shown.iter().rposition(|g| !self.is_collapsed(*g));
@@ -205,6 +251,23 @@ impl DockLayout {
if let Some(f) = filler {
let gaps = GAP * shown.len().saturating_sub(1) as f32;
let min_fill = shown.get(f).map_or(0.0, |g| g.min_height());
let pref_fill = shown.get(f).map_or(0.0, |g| g.preferred_fill());
let used: f32 = hs.iter().sum::<f32>() + gaps;
let mut deficit = (used + pref_fill - avail).max(0.0);
for i in (0..f).rev() {
if deficit <= 0.0 {
break;
}
let Some(g) = shown.get(i) else { continue };
if self.is_collapsed(*g) || self.heights.contains_key(g) {
continue;
}
if let Some(h) = hs.get_mut(i) {
let give = (*h - g.compact_height()).max(0.0).min(deficit);
*h -= give;
deficit -= give;
}
}
let used: f32 = hs.iter().sum::<f32>() + gaps;
let mut deficit = (used + min_fill - avail).max(0.0);
// Squeeze the expanded groups nearest the filler first.
@@ -269,6 +332,27 @@ fn rects_id() -> egui::Id {
egui::Id::new("dock-group-rects")
}
/// A group's tab strip as drawn last frame (screen points), for tests and automation.
#[derive(Clone, Debug, PartialEq)]
pub struct StripRects {
pub group: Group,
/// `(tab index, rect)` of the tabs on the strip (the others are in the chevron menu).
pub tabs: Vec<(usize, Rect)>,
/// The panel menu button.
pub menu: Rect,
/// The » overflow button, when some tabs didn't fit.
pub chevron: Option<Rect>,
}
/// The tab strips drawn last frame.
pub fn last_strips(ctx: &egui::Context) -> Vec<StripRects> {
ctx.data(|d| d.get_temp::<Vec<StripRects>>(strips_id())).unwrap_or_default()
}
fn strips_id() -> egui::Id {
egui::Id::new("dock-strip-rects")
}
/// Draw the `shown` groups (any order; the layout decides) filling `ui`. `body` draws one
/// group's tab content.
pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut body: impl FnMut(&mut PhotocraftApp, &mut egui::Ui, Group, usize)) {
@@ -281,6 +365,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
let rects = rects_after_layout(&heights, area);
let mut actions: Vec<Action> = Vec::new();
let mut dragging: Option<Group> = None;
let mut strips: Vec<StripRects> = Vec::with_capacity(rects.len());
for (i, (g, rect)) in rects.iter().copied().enumerate() {
let collapsed = app.ui.dock.is_collapsed(g);
let mut child = ui.new_child(egui::UiBuilder::new().id_salt(("dock-group", g.key())).max_rect(rect));
@@ -302,6 +387,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
.show(ui, |ui| body(app, ui, g, tab));
}
});
strips.push(StripRects { group: g, tabs: resp.tabs.clone(), menu: resp.menu.rect, chevron: resp.chevron });
// The body may switch tabs itself (Adjustments jumps back to Properties).
if sel != before {
*g.tab_mut(&mut app.ui.dock_tabs) = sel.min(tabs.len().saturating_sub(1));
@@ -368,7 +454,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
};
ui.painter().line_segment([pos2(area.left(), line_y), pos2(area.right(), line_y)], Stroke::new(3.0, t.accent));
}
ui.ctx().data_mut(|d| d.insert_temp(rects_id(), rects));
ui.ctx().data_mut(|d| {
d.insert_temp(rects_id(), rects);
d.insert_temp(strips_id(), strips);
});
ui.advance_cursor_after_rect(area);
for a in actions {
match a {
@@ -422,6 +511,13 @@ fn resize(layout: &mut DockLayout, heights: &[(Group, f32)], i: usize, dy: f32)
let filler = heights.iter().rposition(|(g, _)| !layout.is_collapsed(*g));
let Some(j) = heights.iter().enumerate().skip(i + 1).find(|(_, (n, _))| !layout.is_collapsed(*n)).map(|(j, _)| j) else { return };
let Some(&(n, nh)) = heights.get(j) else { return };
// The first drag pins the other groups at the heights they show, so groups still at their
// defaults (which give way to the filler) don't shift while this one is resized.
for (k, (o, oh)) in heights.iter().enumerate() {
if Some(k) != filler && !layout.is_collapsed(*o) {
layout.heights.entry(*o).or_insert(*oh);
}
}
let new_h = (h + dy).clamp(g.min_height(), (h + nh - n.min_height()).max(g.min_height()));
layout.heights.insert(g, new_h);
if Some(j) != filler {
+229 -11
View File
@@ -12,15 +12,15 @@ const ESSENTIALS: [Group; 3] = [Group::Color, Group::Properties, Group::Layers];
#[test]
fn last_expanded_group_fills_the_column() {
let l = DockLayout::default();
let hs = l.heights_for(&ESSENTIALS, 800.0, 28.0);
let hs = l.heights_for(&ESSENTIALS, 1200.0, 28.0);
assert_eq!(hs[0], (Group::Color, Group::Color.default_height()));
assert_eq!(hs[1], (Group::Properties, Group::Properties.default_height()));
let total: f32 = hs.iter().map(|(_, h)| h).sum::<f32>() + 2.0 * GAP;
assert!((total - 800.0).abs() < 1e-3, "{hs:?}");
assert!((total - 1200.0).abs() < 1e-3, "{hs:?}");
// Collapsed groups shrink to the tab strip; the one above Layers keeps its height.
let mut l = DockLayout::default();
l.set_collapsed(Group::Color, true);
let hs = l.heights_for(&ESSENTIALS, 800.0, 28.0);
let hs = l.heights_for(&ESSENTIALS, 1000.0, 28.0);
assert_eq!(hs[0].1, 28.0);
assert_eq!(hs[1].1, Group::Properties.default_height());
// Layers collapsed: Properties becomes the filler.
@@ -33,10 +33,10 @@ fn last_expanded_group_fills_the_column() {
#[test]
fn short_columns_squeeze_groups_down_to_their_minimum_and_never_go_negative() {
let l = DockLayout::default();
let hs = l.heights_for(&ESSENTIALS, 500.0, 28.0);
let hs = l.heights_for(&ESSENTIALS, 400.0, 28.0);
assert_eq!(hs[2].1, Group::Layers.min_height(), "Layers keeps its minimum: {hs:?}");
assert_eq!(hs[0].1, Group::Color.default_height(), "the group farthest from Layers gives way last");
assert!(hs[1].1 < Group::Properties.default_height());
assert_eq!(hs[0].1, Group::Color.compact_height(), "the group farthest from Layers gives way last");
assert!(hs[1].1 < Group::Properties.compact_height());
for avail in [0.0, -50.0, 1.0, f32::NAN, f32::INFINITY, 1e9] {
for (g, h) in l.heights_for(&ESSENTIALS, avail, 28.0) {
assert!(h.is_finite() && h >= 0.0, "{g:?} at {avail}: {h}");
@@ -48,7 +48,7 @@ fn short_columns_squeeze_groups_down_to_their_minimum_and_never_go_negative() {
#[test]
fn bad_stored_values_are_sanitised() {
let mut l = DockLayout { order: vec![Group::Layers, Group::Layers, Group::Color], ..Default::default() };
assert_eq!(l.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Navigator, Group::History]);
assert_eq!(l.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]);
for bad in [f32::NAN, -10.0, f32::INFINITY, 1e12] {
l.heights.insert(Group::Color, bad);
let h = l.height(Group::Color);
@@ -56,7 +56,7 @@ fn bad_stored_values_are_sanitised() {
}
// Unknown fields, wrong types and old UI state all load.
let back: DockLayout = serde_json::from_value(json!({"order": ["layers"], "bogus": 1})).unwrap();
assert_eq!(back.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Navigator, Group::History]);
assert_eq!(back.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]);
let mut ui = serde_json::to_value(crate::state::UiState::default()).unwrap();
ui.as_object_mut().unwrap().remove("dock");
let ui: crate::state::UiState = serde_json::from_value(ui).unwrap();
@@ -150,11 +150,13 @@ fn dragging_the_splitter_resizes_and_survives_a_ui_state_round_trip() {
let props = rect_of(&h, Group::Properties);
let layers = rect_of(&h, Group::Layers);
let split = Pos2::new(props.center().x, props.bottom() + GAP / 2.0);
drag(&mut h, split, split - vec2(0.0, 90.0));
let color = rect_of(&h, Group::Color);
drag(&mut h, split, split - vec2(0.0, 60.0));
let props2 = rect_of(&h, Group::Properties);
let layers2 = rect_of(&h, Group::Layers);
assert!((props2.height() - (props.height() - 90.0)).abs() < 2.0, "{props:?} -> {props2:?}");
assert!((layers2.top() - (layers.top() - 90.0)).abs() < 2.0, "{layers:?} -> {layers2:?}");
assert!((props2.height() - (props.height() - 60.0)).abs() < 2.0, "{props:?} -> {props2:?}");
assert!((layers2.top() - (layers.top() - 60.0)).abs() < 2.0, "{layers:?} -> {layers2:?}");
assert_eq!(rect_of(&h, Group::Color), color, "the group above keeps its place");
assert_eq!(layers2.bottom(), layers.bottom());
let stored = h.state().ui.dock.heights.get(&Group::Properties).copied().unwrap();
// Dragging past the minimum stops at it.
@@ -348,3 +350,219 @@ fn clicking_around_the_ui_keeps_the_panels_put() {
h.state_mut().ui.dialogs.clear();
}
}
fn is_pro(theme: ThemeKind) -> bool {
matches!(theme, ThemeKind::Pro | ThemeKind::ProMedium)
}
/// Full app at `size` (1× scale) on a document with `n` layers named "Row 00", "Row 01", …
fn app_harness_rows(size: egui::Vec2, theme: ThemeKind, n: usize) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(size).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, theme);
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.ui.theme = theme;
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
for i in 0..n {
app.run("layer.new.layer", json!({"name": format!("Row {i:02}")})).unwrap();
}
app.sync_views();
app
});
h.run_steps(8);
h
}
/// Layer rows fully inside the Layers rows viewport.
fn rows_in_view(h: &Harness<'static, PhotocraftApp>, n: usize) -> usize {
use egui_kittest::kittest::Queryable;
let layers = rect_of(h, Group::Layers);
(0..n)
.filter(|i| {
h.query_all_by_label(&format!("Row {i:02}"))
.map(|q| q.rect())
.any(|r| r.left() >= layers.left() - 1.0 && r.top() >= layers.top() + 26.0 && r.bottom() <= layers.bottom() - 36.0)
})
.count()
}
/// #147: the default workspace gives Layers the column's spare height: at least ten rows on a
/// 900 pt window, and a usable list on a 720 pt one.
#[test]
fn default_layout_shows_ten_layer_rows_at_900pt() {
for (size, want) in [(vec2(1440.0, 900.0), 10), (vec2(1280.0, 720.0), 4)] {
let h = app_harness_rows(size, ThemeKind::ProMedium, 40);
let rows = rows_in_view(&h, 40);
let groups = last_rects(&h.ctx);
assert!(rows >= want, "{size:?}: {rows} rows visible, want ≥ {want}: {groups:?}");
// Color and Properties stay open, just not at the expense of Layers.
for g in [Group::Color, Group::Properties] {
assert!(rect_of(&h, g).height() >= g.compact_height() - 0.5, "{g:?} at {size:?}: {groups:?}");
}
}
}
#[test]
fn defaults_give_way_to_layers_but_user_sizes_stay() {
let l = DockLayout::default();
// A tall column: everyone at their defaults, Layers takes the rest.
let hs = l.heights_for(&ESSENTIALS, 1200.0, 28.0);
assert_eq!(hs[0].1, Group::Color.default_height());
assert_eq!(hs[1].1, Group::Properties.default_height());
// A short one: default-sized groups shrink toward their compact heights so Layers keeps
// its preferred height, the one nearest Layers first.
let hs = l.heights_for(&ESSENTIALS, 820.0, 28.0);
assert!(hs[2].1 >= Group::Layers.preferred_fill() - 0.5, "{hs:?}");
assert!(hs[1].1 < Group::Properties.default_height() && hs[1].1 >= Group::Properties.compact_height(), "{hs:?}");
// Heights the user dragged to (saved in prefs) are kept as they are.
let mut mine = DockLayout::default();
mine.heights.insert(Group::Properties, 340.0);
mine.heights.insert(Group::Color, 200.0);
let hs = mine.heights_for(&ESSENTIALS, 800.0, 28.0);
assert_eq!((hs[0].1, hs[1].1), (200.0, 340.0), "{hs:?}");
}
/// #150: Window › Character opens a Character | Paragraph group next to Properties (which
/// stays), and toggles closed again; old saved layouts place it above Layers.
#[test]
fn window_character_opens_its_own_group_and_keeps_properties() {
for theme in [ThemeKind::ProMedium, ThemeKind::Studio] {
let (app, _, _) = app_with_layers();
let mut h = harness(app, vec2(1440.0, 900.0), theme);
let ctx = h.ctx.clone();
crate::menus::invoke(h.state_mut(), &ctx, "window.panel.character", json!({})).unwrap();
h.run_steps(3);
let app = h.state();
assert!(app.ui.panels.character && app.ui.panels.properties && app.ui.dock_tabs.character == 0, "{theme:?}");
let ch = rect_of(&h, Group::Character);
assert!(ch.height() > 60.0, "{theme:?}: Character expanded: {ch:?}");
if is_pro(theme) {
let props = rect_of(&h, Group::Properties);
assert!(props.height() > 60.0 && props.bottom() <= ch.top(), "{theme:?}: Properties visible above Character");
}
assert!(rect_of(&h, Group::Layers).top() > ch.bottom(), "{theme:?}: Layers stays the filler at the bottom");
assert_eq!(crate::view_cmds::checked(h.state(), "window.panel.character"), Some(true));
// Paragraph is the group's second tab; choosing it again on its tab closes the group.
crate::menus::invoke(h.state_mut(), &ctx, "window.panel.paragraph", json!({})).unwrap();
assert!(h.state().ui.panels.character && h.state().ui.dock_tabs.character == 1);
crate::menus::invoke(h.state_mut(), &ctx, "window.panel.paragraph", json!({})).unwrap();
h.run_steps(3);
assert!(!h.state().ui.panels.character && h.state().ui.panels.properties, "{theme:?}");
assert!(!last_rects(&h.ctx).iter().any(|(g, _)| *g == Group::Character));
// Type › Panels › Character Panel always shows it; Reset Workspace closes it.
crate::menus::invoke(h.state_mut(), &ctx, "type.panels.character", json!({})).unwrap();
crate::menus::invoke(h.state_mut(), &ctx, "type.panels.character", json!({})).unwrap();
assert!(h.state().ui.panels.character);
crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.resetWorkspace", json!({})).unwrap();
assert!(!h.state().ui.panels.character && h.state().ui.panels.properties);
}
// A layout saved before the group existed keeps Layers last.
let old = DockLayout { order: vec![Group::Color, Group::Properties, Group::Navigator, Group::History, Group::Layers], ..Default::default() };
assert_eq!(old.order(), Group::ALL.to_vec());
let moved = DockLayout { order: vec![Group::Layers, Group::Color], ..Default::default() };
assert_eq!(moved.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]);
}
type StripProbe = Option<(Vec<(usize, Rect)>, Rect, Option<Rect>, Rect)>;
/// #151: at narrow widths and 2× scale, in every theme, tabs elide or overflow into a chevron
/// and never run under the panel menu button.
#[test]
fn tab_strips_never_overlap_the_menu_button() {
let overlap = |a: Rect, b: Rect| {
let i = a.intersect(b);
i.width() > 0.5 && i.height() > 0.5
};
for theme in ThemeKind::ALL {
for scale in [1.0, 2.0] {
for width in [180.0, 250.0, 290.0, 420.0] {
for g in Group::ALL {
let tabs = g.tabs(is_pro(theme));
for sel in 0..tabs.len() {
let mut h = Harness::builder().with_size(vec2(width, 200.0)).with_pixels_per_point(scale).build_ui_state(
move |ui, out: &mut StripProbe| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
let mut s = sel;
let r = crate::widgets::card_ex(ui, "t", tabs, &mut s, false, |ui, _| {
ui.label("body");
});
*out = Some((r.tabs, r.menu.rect, r.chevron, ui.max_rect()));
},
None,
);
PhotocraftApp::setup_context(&h.ctx, theme);
h.run_steps(3);
let (shown, menu, chevron, card) = h.state().clone().unwrap();
let what = format!("{theme:?} {scale}x {width}pt {g:?} selected {sel}");
assert!(shown.iter().any(|(i, _)| *i == sel), "{what}: the selected tab is on the strip: {shown:?}");
for (i, r) in &shown {
assert!(!overlap(*r, menu), "{what}: tab {i} {r:?} runs under the menu {menu:?}");
assert!(r.left() >= card.left() - 0.5 && r.right() <= card.right() + 0.5, "{what}: tab {i} outside the card");
}
for w in shown.windows(2) {
assert!(!overlap(w[0].1, w[1].1), "{what}: tabs overlap");
}
if let Some(c) = chevron {
assert!(!overlap(c, menu) && shown.iter().all(|(_, r)| !overlap(*r, c)), "{what}: chevron overlaps");
} else {
assert_eq!(shown.len(), tabs.len(), "{what}: a tab went missing without a chevron");
}
}
}
}
}
}
}
/// #151 in the real dock: every strip at 2× scale fits beside its menu button, and a tab in the
/// chevron menu can still be chosen.
#[test]
fn dock_strips_fit_and_the_chevron_menu_switches_tabs() {
for theme in ThemeKind::ALL {
let (mut app, _, _) = app_with_layers();
app.ui.panels.history = true;
app.ui.panels.navigator = true;
app.ui.panels.character = true;
let mut h = Harness::builder().with_size(vec2(900.0, 1000.0)).with_pixels_per_point(2.0).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
crate::panels::right_dock(app, ui);
egui::CentralPanel::default().show(ui, |_| {});
},
app,
);
PhotocraftApp::setup_context(&h.ctx, theme);
h.state_mut().ui.theme = theme;
h.run_steps(4);
let strips = last_strips(&h.ctx);
assert!(strips.len() >= 5, "{theme:?}: {strips:?}");
for s in &strips {
for (i, r) in &s.tabs {
assert!(r.intersect(s.menu).width() <= 0.5, "{theme:?} {:?}: tab {i} under the menu", s.group);
}
}
}
// A strip too narrow for its tabs: pick a hidden tab from the chevron menu.
let mut h = Harness::builder().with_size(vec2(150.0, 200.0)).build_ui_state(
|ui, sel: &mut usize| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
let _ = crate::widgets::card_ex(ui, "color", &["Color", "Swatches", "Gradients", "Patterns"], sel, false, |ui, _| {
ui.label("body");
});
},
0usize,
);
PhotocraftApp::setup_context(&h.ctx, ThemeKind::ProMedium);
h.run_steps(3);
use egui_kittest::kittest::Queryable;
h.get_by_label("More panels").click();
h.run_steps(3);
h.get_by_label("Patterns").click();
h.run_steps(3);
assert_eq!(*h.state(), 3, "Patterns chosen from the chevron menu");
}
+24 -8
View File
@@ -243,6 +243,10 @@ fn choice_label(v: &str) -> String {
label(v)
}
fn uses_logarithmic_slider(min: f32, max: f32) -> bool {
min > 0.0 && max / min > 500.0
}
/// Dialog body for filter commands.
pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let t = Tokens::get(ui.ctx());
@@ -268,16 +272,20 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
} else {
""
};
let logarithmic = min > 0.0 && max / min > 500.0;
if logarithmic {
// Scrub the value field directly; add a log-scaled slider for huge ranges.
let mut lv = v.max(min.max(0.1)).ln();
let r = crate::widgets::slider_row(ui, &label(&p.key), &mut v, min..=max, unit, None);
let _ = r;
let r2 = crate::widgets::slider(ui, &mut lv, min.max(0.1).ln()..=max.ln(), None);
if r2.changed() {
if uses_logarithmic_slider(min, max) {
// Keep one numeric field for direct entry and one log-scaled slider for
// wide ranges. slider_row would add a second, linear slider (#146).
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
crate::widgets::value_field(ui, &mut v, min..=max, unit, 74.0);
});
});
let mut lv = v.clamp(min, max).ln();
if crate::widgets::slider(ui, &mut lv, min.ln()..=max.ln(), None).changed() {
v = lv.exp();
}
ui.add_space(4.0);
} else {
crate::widgets::slider_row(ui, &label(&p.key), &mut v, min..=max, unit, None);
}
@@ -421,6 +429,14 @@ mod tests {
);
}
#[test]
fn wide_positive_ranges_use_the_logarithmic_slider_path() {
assert!(uses_logarithmic_slider(0.1, 1000.0), "Gaussian Blur radius");
assert!(uses_logarithmic_slider(1.0, 9999.0));
assert!(!uses_logarithmic_slider(1.0, 500.0));
assert!(!uses_logarithmic_slider(0.0, 1000.0));
}
#[test]
fn new_filters_have_dialogs_or_run_directly() {
for id in [
+5
View File
@@ -124,6 +124,11 @@ impl GpuCanvas {
/// The canvas texture format for a document of `depth` and `size`: `Rgba16Float` for 16/32-bit
/// documents when the adapter supports it and the document fits [`F16_BUDGET_PX`], else
/// `Rgba8Unorm`.
/// The device's largest 2D texture side (egui textures can use it too).
pub fn max_texture_side(&self) -> usize {
self.rs.device.limits().max_texture_dimension_2d as usize
}
pub fn format_for(&self, depth: photocraft_doc::SampleType, size: [u32; 2]) -> wgpu::TextureFormat {
let px = size[0] as u64 * size[1] as u64;
let high = match self.high {
+64
View File
@@ -0,0 +1,64 @@
//! Reveal the active layer in the Layers panel (#152), like Photoshop: whenever the active
//! layer changes, by whatever means (a click on the canvas with Auto-Select, Select › layer
//! commands, ⌥[ / ⌥], undo, automation), its closed parent groups open (`layer.setExpanded`)
//! and the panel scrolls just enough to show its row, unless the row is already in view.
//!
//! Only a change of the active layer reveals: closing the group that holds it, or scrolling
//! away, is left alone until the next change.
use egui::{Rect, pos2};
use photocraft_doc::{LayerContent, LayerId};
use serde_json::json;
use crate::PhotocraftApp;
/// Last active layer the panel saw: (document index, layer).
#[derive(Clone, Copy, PartialEq)]
struct Seen(usize, Option<LayerId>);
fn seen_id() -> egui::Id {
egui::Id::new("layers-reveal-seen")
}
/// Called once per Layers panel frame, before its rows are listed. When the active layer
/// changed since the last frame, opens its closed parent groups and returns it: the row to
/// scroll into view (see [`scroll_to_row`]). The first frame of a document only records it.
pub fn track(app: &mut PhotocraftApp, ctx: &egui::Context) -> Option<LayerId> {
let index = app.session.active_index()?;
let active = app.session.active()?.active_layer;
let now = Seen(index, active);
let before = ctx.data(|d| d.get_temp::<Seen>(seen_id()));
ctx.data_mut(|d| d.insert_temp(seen_id(), now));
match before {
Some(Seen(i, prev)) if i == index && prev != active => {}
_ => return None,
}
let id = active?;
for group in closed_ancestors(app, id) {
// A view change (no history step); a failure just leaves the group closed.
let _ = app.session.execute("layer.setExpanded", json!({"layer": group.0, "expanded": true}));
}
Some(id)
}
/// The groups containing `id` that are closed, outermost first.
fn closed_ancestors(app: &PhotocraftApp, id: LayerId) -> Vec<LayerId> {
let Some(doc) = app.session.active().map(|s| &s.doc) else { return Vec::new() };
let Some(path) = doc.path_of(id) else { return Vec::new() };
(1..path.len()).filter_map(|n| doc.layer_at(path.get(..n)?)).filter(|l| matches!(&l.content, LayerContent::Group(g) if !g.expanded)).map(|l| l.id).collect()
}
/// After drawing the row that started at `top` in the Layers scroll area, scroll it into view
/// if any of it is hidden (no animation: Photoshop jumps).
pub fn scroll_to_row(ui: &egui::Ui, top: f32) {
let bottom = ui.min_rect().bottom().max(top);
let row = Rect::from_min_max(pos2(ui.min_rect().left(), top), pos2(ui.min_rect().right(), bottom));
let view = ui.clip_rect();
if row.top() < view.top() - 0.5 || row.bottom() > view.bottom() + 0.5 {
ui.scroll_to_rect_animation(row, None, egui::style::ScrollAnimation::none());
}
}
#[cfg(test)]
#[path = "layer_reveal_tests.rs"]
mod tests;
+132
View File
@@ -0,0 +1,132 @@
//! #152: a new active layer opens its parent groups and is scrolled into view.
use egui::{Rect, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use photocraft_doc::LayerId;
use serde_json::json;
use crate::PhotocraftApp;
use crate::dock::{Group, last_rects};
use crate::theme::ThemeKind;
/// 30 layers above a closed group holding `inner` (near the bottom of the panel).
fn tall_doc() -> (PhotocraftApp, LayerId, LayerId, LayerId, LayerId) {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
app.run("layer.new.layer", json!({"name": "Deep inside"})).unwrap();
let active = |app: &PhotocraftApp| app.session.active().unwrap().active_layer.unwrap();
let inner = active(&app);
app.run("layer.groupLayers", json!({})).unwrap();
let group = app.session.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.is_group()).map(|(_, _, l)| l.id).unwrap();
app.run("layer.setExpanded", json!({"layer": group.0, "expanded": false})).unwrap();
app.run("layer.select", json!({"layer": group.0})).unwrap();
let mut stack = Vec::new();
for i in 0..30 {
app.run("layer.new.layer", json!({"name": format!("Stack {i:02}")})).unwrap();
stack.push(active(&app));
}
app.sync_views();
(app, inner, group, stack[29], stack[28])
}
fn harness(app: PhotocraftApp, theme: ThemeKind) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
crate::panels::right_dock(app, ui);
egui::CentralPanel::default().show(ui, |_| {});
},
app,
);
PhotocraftApp::setup_context(&h.ctx, theme);
h.state_mut().ui.theme = theme;
h.run_steps(4);
h
}
/// The Layers panel row of `name`, if drawn.
fn row(h: &Harness<'static, PhotocraftApp>, name: &str) -> Option<Rect> {
let layers = last_rects(&h.ctx).into_iter().find(|(g, _)| *g == Group::Layers)?.1;
h.query_all_by_label(name).map(|n| n.rect()).find(|r| layers.contains(r.center()) || (r.left() >= layers.left() && r.right() <= layers.right()))
}
/// Fully inside the rows viewport (below the strip and the blend/lock rows, above the footer).
fn in_view(h: &Harness<'static, PhotocraftApp>, r: Rect) -> bool {
let layers = last_rects(&h.ctx).into_iter().find(|(g, _)| *g == Group::Layers).unwrap().1;
r.top() >= layers.top() + 26.0 && r.bottom() <= layers.bottom() - 36.0
}
fn expanded(app: &PhotocraftApp, group: LayerId) -> bool {
matches!(&app.session.active().unwrap().doc.layer(group).unwrap().content, photocraft_doc::LayerContent::Group(g) if g.expanded)
}
#[test]
fn a_new_active_layer_opens_its_groups_and_scrolls_into_view() {
for theme in [ThemeKind::ProMedium, ThemeKind::Studio] {
let (app, inner, group, top, below_top) = tall_doc();
let mut h = harness(app, theme);
let top_row = row(&h, "Stack 29").expect("top row drawn");
assert!(in_view(&h, top_row), "{theme:?}: the panel starts at the top");
assert!(row(&h, "Deep inside").is_none(), "{theme:?}: hidden in its closed group");
// Select › layer, Auto-Select, automation: all end in a new active layer.
h.state_mut().run("layer.select", json!({"layer": inner.0})).unwrap();
h.run_steps(4);
assert!(expanded(h.state(), group), "{theme:?}: the parent group opened");
let r = row(&h, "Deep inside").expect("row listed once its group is open");
assert!(in_view(&h, r), "{theme:?}: scrolled into view: {r:?}");
assert!(!in_view(&h, row(&h, "Stack 29").unwrap()), "{theme:?}: the top scrolled away");
// Back to the top layer: scrolls back up.
h.state_mut().run("layer.select", json!({"layer": top.0})).unwrap();
h.run_steps(4);
assert!(in_view(&h, row(&h, "Stack 29").unwrap()), "{theme:?}: scrolled back to the top");
// A layer already in view doesn't move the list.
let before = row(&h, "Stack 29").unwrap();
h.state_mut().run("layer.select", json!({"layer": below_top.0})).unwrap();
h.run_steps(4);
assert_eq!(row(&h, "Stack 29").unwrap(), before, "{theme:?}: no scroll for a visible row");
// Closing the group by hand is respected until the active layer changes again.
h.state_mut().run("layer.select", json!({"layer": inner.0})).unwrap();
h.run_steps(3);
h.state_mut().run("layer.setExpanded", json!({"layer": group.0, "expanded": false})).unwrap();
h.run_steps(4);
assert!(!expanded(h.state(), group), "{theme:?}: stays closed");
// Undo and other commands that change the active layer reveal it too: undoing a new
// layer deep in the list makes the top layer active again.
h.state_mut().run("layer.select", json!({"layer": top.0})).unwrap();
h.run_steps(3);
h.state_mut().run("layer.select", json!({"layer": inner.0})).unwrap();
h.run_steps(3);
assert!(expanded(h.state(), group));
h.state_mut().run("layer.new.layer", json!({"name": "Scratch"})).unwrap();
h.run_steps(4);
assert!(!in_view(&h, row(&h, "Stack 29").unwrap()));
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run_steps(4);
assert_eq!(h.state().session.active().unwrap().active_layer, Some(top), "{theme:?}: undo made the top layer active");
assert!(in_view(&h, row(&h, "Stack 29").unwrap()), "{theme:?}: undo revealed it");
}
}
#[test]
fn reveal_survives_odd_documents() {
// No document, a document without layers in groups, a layer deleted between frames.
let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let mut h = harness(app, ThemeKind::ProMedium);
h.run_steps(2);
h.state_mut().run("file.new", json!({"width": 20, "height": 20})).unwrap();
h.state_mut().sync_views();
h.run_steps(3);
h.state_mut().run("layer.new.layer", json!({})).unwrap();
h.run_steps(3);
h.state_mut().run("layer.delete", json!({})).unwrap();
h.run_steps(3);
h.state_mut().run("file.close", json!({})).ok();
h.state_mut().sync_views();
h.run_steps(3);
}
+8
View File
@@ -17,6 +17,7 @@ pub mod artboard_ui;
pub mod brush_panel;
pub mod brush_preview;
pub mod brush_sections;
pub mod brushes_tab;
pub mod camera_raw_ui;
pub mod canvas;
pub mod channel_view;
@@ -44,6 +45,7 @@ mod icon_data;
pub mod icons;
pub mod layer_menu_ui;
pub mod layer_props_ui;
mod layer_reveal;
pub mod layer_row_ui;
pub mod layer_style;
pub mod layer_tree_ui;
@@ -52,6 +54,7 @@ pub mod liquify_ui;
pub mod menu_catalog;
pub mod menu_nav;
pub mod menus;
pub mod move_mods;
pub mod new_doc_ui;
pub mod notices;
pub mod outline;
@@ -76,9 +79,11 @@ pub mod smart_ui;
pub mod snap_ui;
pub mod state;
pub mod stylus;
mod tab_strip;
pub mod theme;
mod timeline_ui;
pub mod tone;
pub mod transform_tex;
pub mod transform_tool;
pub mod type_panels_ui;
pub mod type_tool;
@@ -248,6 +253,8 @@ pub struct PhotocraftApp {
pub(crate) doc_hist: Option<(DocId, u64, f64, std::sync::Arc<tone::Histograms>)>,
/// Free Transform preview (document without the moving pixels + their texture).
pub(crate) transform_preview: Option<transform_tool::TransformPreview>,
/// Move-tool ⇧/⌥ drag state (move_mods).
pub(crate) move_mods: move_mods::MoveDrag,
/// Live Layer Style dialog preview: (key over revision + style fields, document with the style applied).
pub(crate) style_preview: Option<(u64, Option<std::sync::Arc<Document>>)>,
/// Liquify dialog, Puppet Warp and Perspective Warp sessions (distort_ui).
@@ -341,6 +348,7 @@ impl PhotocraftApp {
clip_external: false,
clip_read_for_paste: false,
transform_preview: None,
move_mods: Default::default(),
style_preview: None,
distort: Default::default(),
camera_raw: None,
+30
View File
@@ -169,12 +169,14 @@ impl LiquifyDialog {
let d = self.field.apply_stroke(&s);
self.strokes.push(s);
self.mark(d, true);
self.render_dirty();
}
fn rebuild(&mut self) {
self.field = LiquifyField::from_strokes(self.canvas, self.cell, &self.strokes);
self.dirty = Some([0, 0, self.proxy.w, self.proxy.h]);
self.mask_dirty = true;
self.render_dirty();
}
fn undo(&mut self) {
@@ -754,6 +756,34 @@ mod tests {
assert_eq!(app.session.active().unwrap().history.past_len(), before + 1);
}
#[test]
fn undo_rerenders_the_proxy_before_the_next_texture_upload() {
let ctx = egui::Context::default();
let mut app = app_with_layer();
open(&mut app, &ctx).unwrap();
control(&mut app, &json!({"tool": "forwardWarp", "size": 40})).unwrap();
for ev in [
ToolEvent::Down { x: 35.0, y: 40.0, pressure: 1.0 },
ToolEvent::Move { x: 55.0, y: 40.0, pressure: 1.0 },
ToolEvent::Up { x: 55.0, y: 40.0 },
ToolEvent::Down { x: 70.0, y: 40.0, pressure: 1.0 },
ToolEvent::Move { x: 90.0, y: 40.0, pressure: 1.0 },
ToolEvent::Up { x: 90.0, y: 40.0 },
] {
pointer(&mut app, ev, egui::Modifiers::NONE);
}
let before = app.distort.liquify.as_ref().unwrap().out.clone();
control(&mut app, &json!({"undo": true})).unwrap();
let d = app.distort.liquify.as_ref().unwrap();
assert_eq!(d.strokes.len(), 1);
assert_ne!(d.out, before, "undo must update the pixels shown by the existing texture");
let mut expected = vec![[0u8; 4]; d.proxy.w * d.proxy.h];
d.proxy.render(&d.field, [0, 0, d.proxy.w, d.proxy.h], &mut expected);
assert_eq!(d.out, expected, "dirty upload source must match the rebuilt field");
}
#[test]
fn shortcut_handler_undoes_liquify_even_when_egui_owns_keyboard_focus() {
let ctx = egui::Context::default();
+1
View File
@@ -849,6 +849,7 @@ pub fn apply_workspace(app: &mut PhotocraftApp) {
p.layers = layers;
p.history = history;
p.properties = props;
p.character = false;
}
#[cfg(test)]
+339
View File
@@ -0,0 +1,339 @@
//! Move-tool modifier keys, as in Photoshop (#90):
//!
//! - ⇧ while dragging locks the move to the dominant axis (horizontal or vertical). The axis is
//! re-evaluated as the drag goes on, so moving far enough the other way switches it; a small
//! hysteresis keeps it from flickering near the diagonal. Free Transform uses the same rule with
//! eight directions ([`constrain`]).
//! - ⌥ held when the drag starts duplicates the selected layer(s) (groups included) once the
//! pointer actually moves, and the drag then moves the copies. Duplicate and move land as one
//! history step ("Duplicate + Move"). ⇧⌥ combines both.
//! - Arrow keys nudge the selected layers 1 px (⇧: 10 px); ⌥ duplicates first. While Free
//! Transform is active they nudge the box instead.
//!
//! The hooks are small and local: [`filter_event`] rewrites pointer events before the Move tool
//! sees them and [`finish`] folds the history after the drag, so the Move drag pipeline itself is
//! untouched.
use serde_json::json;
use crate::PhotocraftApp;
use crate::canvas::ToolEvent;
use crate::state::Tool;
/// Directions ⇧ locks a Move-tool drag to: horizontal and vertical (Photoshop's Move tool).
pub const MOVE_DIRECTIONS: u32 = 4;
/// Directions ⇧ locks a Free Transform body drag to: the axes and the 45° diagonals.
pub const TRANSFORM_DIRECTIONS: u32 = 8;
/// Extra angle (degrees) the pointer must pass the half-way line by before the locked axis
/// switches, so it doesn't flicker when dragging near the diagonal.
const HYSTERESIS_DEG: f64 = 8.0;
/// ⌥-drag duplicates only once the pointer has moved this far (screen points), so an ⌥-click
/// without a drag doesn't make a copy (Photoshop).
const DUPLICATE_THRESHOLD: f64 = 2.0;
/// History label of an ⌥-drag / ⌥-nudge.
pub const DUPLICATE_MOVE_LABEL: &str = "Duplicate + Move";
/// Locks the drag offset `d` to the nearest of `n` evenly spaced directions (0° = right). `prev`
/// is the previously locked offset: its direction is kept until `d` is clearly nearer another one.
pub fn constrain(d: [f64; 2], prev: Option<[f64; 2]>, n: u32) -> [f64; 2] {
let len = d[0].hypot(d[1]);
if n == 0 || !len.is_finite() || len == 0.0 {
return d;
}
let step = std::f64::consts::TAU / f64::from(n);
let a = d[1].atan2(d[0]);
let mut k = (a / step).round();
if let Some(p) = prev.filter(|p| p[0].hypot(p[1]) > 0.0) {
let kp = (p[1].atan2(p[0]) / step).round();
// Angular distance from `d` to the previous direction, wrapped to [0, π].
let off = (a - kp * step).rem_euclid(std::f64::consts::TAU);
let off = off.min(std::f64::consts::TAU - off);
if off < step / 2.0 + HYSTERESIS_DEG.to_radians() {
k = kp;
}
}
let (s, c) = (k * step).sin_cos();
let t = (d[0] * c + d[1] * s).max(0.0);
// Snap tiny float noise so axis-locked moves stay exactly on the axis.
let clean = |v: f64| if v.abs() < 1e-9 { 0.0 } else { v };
[clean(t * c), clean(t * s)]
}
/// Per-drag state (one Move-tool gesture at a time).
#[derive(Clone, Debug, Default)]
pub struct MoveDrag {
/// Where the drag started (document pixels); `None` when no Move drag is in progress.
start: Option<[f64; 2]>,
/// ⌥ was held when the drag started.
alt: bool,
/// The last offset after ⇧ locking (keeps the locked axis stable).
last: Option<[f64; 2]>,
/// History length before the ⌥-drag duplicated (set once it has).
dup_from: Option<usize>,
}
fn past_len(app: &PhotocraftApp) -> Option<usize> {
app.session.active().map(|st| st.history.past_len())
}
fn moving(app: &PhotocraftApp) -> bool {
app.drag.as_ref().is_some_and(|d| d.tool == Tool::Move)
}
/// Duplicates the selected layers (the copies become the selection), remembering the history
/// length before so [`fold_history`] can merge the copy and the move into one step.
fn duplicate(app: &mut PhotocraftApp) -> Option<usize> {
let before = past_len(app)?;
match app.run("layer.duplicate", json!({})) {
Ok(_) => Some(before),
Err(e) => {
app.ui.status = e;
None
}
}
}
/// Merges every history step after `from` into one "Duplicate + Move" step.
fn fold_history(app: &mut PhotocraftApp, from: usize) {
let Some(st) = app.session.active_mut() else { return };
while st.history.past_len() > from + 1 {
if !st.history.purge_last() {
break;
}
}
if st.history.past_len() == from + 1 {
st.history.set_current_label(DUPLICATE_MOVE_LABEL);
}
}
/// Rewrites a Move-tool pointer event for the held modifiers: ⇧ locks it to an axis, and the
/// first real movement of an ⌥-drag duplicates the layers being moved. Other tools pass through.
pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> ToolEvent {
if app.ui.tool != Tool::Move || app.ui.transform.is_some() {
app.move_mods = MoveDrag::default();
return ev;
}
match ev {
ToolEvent::Down { x, y, .. } => {
app.move_mods = MoveDrag { start: Some([x, y]), alt: mods.alt, last: None, dup_from: None };
ev
}
ToolEvent::Move { x, y, pressure } => {
let [x, y] = drag_to(app, [x, y], mods);
ToolEvent::Move { x, y, pressure }
}
ToolEvent::Up { x, y } => {
let [x, y] = drag_to(app, [x, y], mods);
ToolEvent::Up { x, y }
}
}
}
fn drag_to(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2] {
let Some(start) = app.move_mods.start.filter(|_| moving(app)) else { return p };
let mut d = [p[0] - start[0], p[1] - start[1]];
if mods.shift {
d = constrain(d, app.move_mods.last, MOVE_DIRECTIONS);
}
app.move_mods.last = Some(d);
let zoom = f64::from(app.current_zoom().max(0.01));
if app.move_mods.alt && app.move_mods.dup_from.is_none() && d[0].hypot(d[1]) * zoom >= DUPLICATE_THRESHOLD {
app.move_mods.dup_from = duplicate(app);
// Only once per drag, even if duplicating failed.
app.move_mods.alt = false;
}
[start[0] + d[0], start[1] + d[1]]
}
/// Call after the Move tool finished its drag: an ⌥-drag's copy and move become one step.
pub fn finish(app: &mut PhotocraftApp) {
let st = std::mem::take(&mut app.move_mods);
if let Some(from) = st.dup_from {
fold_history(app, from);
}
}
/// Arrow keys with the Move tool (or while Free Transform is active): nudge 1 px, ⇧ 10 px;
/// ⌥ duplicates the layers first. Returns true when a key was used.
pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
if app.ui.tool != Tool::Move && app.ui.transform.is_none() {
return false;
}
use egui::{Key, Modifiers};
let keys = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)];
for (key, ux, uy) in keys {
for mods in [Modifiers::NONE, Modifiers::SHIFT, Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT] {
if ctx.input_mut(|i| i.consume_key(mods, key)) {
let k = if mods.shift { 10.0 } else { 1.0 };
nudge(app, ux * k, uy * k, mods.alt);
return true;
}
}
}
false
}
/// Moves the selected layers (or the Free Transform box) by `(dx, dy)` pixels.
pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) {
if let Some(t) = app.ui.transform.as_mut() {
if t.warp.is_none() {
t.quad = t.quad.map(|q| [q[0] + dx, q[1] + dy]);
t.pivot = [t.pivot[0] + dx, t.pivot[1] + dy];
}
return;
}
if app.drag.is_some() {
return;
}
let from = if duplicate_first { duplicate(app) } else { None };
if let Err(e) = app.run("layer.translate", json!({"dx": dx, "dy": dy})) {
app.ui.status = e;
}
if let Some(from) = from {
fold_history(app, from);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn near(a: [f64; 2], b: [f64; 2]) -> bool {
(a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9
}
#[test]
fn shift_picks_the_dominant_axis() {
assert!(near(constrain([10.0, 3.0], None, 4), [10.0, 0.0]));
assert!(near(constrain([-2.0, -9.0], None, 4), [0.0, -9.0]));
assert!(near(constrain([0.0, 0.0], None, 4), [0.0, 0.0]));
// Eight directions: a near-diagonal drag lands on 45°.
let d = constrain([10.0, 9.0], None, 8);
assert!((d[0] - d[1]).abs() < 1e-9 && d[0] > 9.0, "{d:?}");
assert!(near(constrain([10.0, 1.0], None, 8), [10.0, 0.0]));
// Hostile input never panics and stays finite or passes through.
let _ = constrain([f64::NAN, 1.0], None, 4);
let _ = constrain([f64::INFINITY, 1.0], Some([f64::NAN, 0.0]), 0);
}
#[test]
fn shift_axis_switches_once_the_pointer_is_clearly_nearer_the_other_one() {
let h = constrain([20.0, 2.0], None, 4);
assert!(near(h, [20.0, 0.0]));
// Just past the diagonal: hysteresis keeps horizontal.
let still = constrain([20.0, 21.0], Some(h), 4);
assert!(near(still, [20.0, 0.0]), "{still:?}");
// Far enough the other way: switches to vertical.
let v = constrain([20.0, 40.0], Some(h), 4);
assert!(near(v, [0.0, 40.0]), "{v:?}");
// And back.
assert!(near(constrain([30.0, 20.0], Some(v), 4), [30.0, 0.0]));
}
fn app_with_layer() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
app.sync_views();
app.session.execute("layer.new.layer", json!({})).unwrap();
app.session
.edit("paint", |doc, a| {
doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(8, 8, 24, 24), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
app.ui.tool = Tool::Move;
app.ui.tool_options.move_auto_select = false;
// Snapping (to the document centre with a 1:1 test view) would shift the expected offsets.
app.ui.extras.snap = false;
app.ui.view.show.smart_guides = false;
app
}
fn bounds(app: &PhotocraftApp, id: photocraft_doc::LayerId) -> photocraft_geom::Rect {
app.session.active().unwrap().doc.layer(id).unwrap().surface().unwrap().content_bounds()
}
fn drag(app: &mut PhotocraftApp, pts: &[[f64; 2]], mods: egui::Modifiers) {
crate::canvas::tool_event(app, ToolEvent::Down { x: pts[0][0], y: pts[0][1], pressure: 1.0 }, mods);
for p in &pts[1..pts.len() - 1] {
crate::canvas::tool_event(app, ToolEvent::Move { x: p[0], y: p[1], pressure: 1.0 }, mods);
}
let e = pts[pts.len() - 1];
crate::canvas::tool_event(app, ToolEvent::Up { x: e[0], y: e[1] }, mods);
}
#[test]
fn shift_drag_moves_along_one_axis_only() {
let mut app = app_with_layer();
let id = app.session.active().unwrap().active_layer.unwrap();
drag(&mut app, &[[16.0, 16.0], [22.0, 18.0], [26.0, 19.0]], egui::Modifiers::SHIFT);
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(18, 8, 34, 24));
// Switching axis mid-drag: the end point decides.
drag(&mut app, &[[16.0, 16.0], [22.0, 17.0], [18.0, 30.0], [17.0, 36.0]], egui::Modifiers::SHIFT);
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(18, 28, 34, 44));
}
#[test]
fn alt_drag_duplicates_and_moves_the_copy_in_one_step() {
let mut app = app_with_layer();
let orig = app.session.active().unwrap().active_layer.unwrap();
let n0 = app.session.active().unwrap().doc.layers.len();
let h0 = app.session.active().unwrap().history.past_len();
drag(&mut app, &[[16.0, 16.0], [20.0, 16.0], [26.0, 21.0]], egui::Modifiers::ALT);
let st = app.session.active().unwrap();
assert_eq!(st.doc.layers.len(), n0 + 1, "one copy");
let copy = st.active_layer.unwrap();
assert_ne!(copy, orig);
assert_eq!(bounds(&app, orig), photocraft_geom::Rect::new(8, 8, 24, 24), "original untouched");
assert_eq!(bounds(&app, copy), photocraft_geom::Rect::new(18, 13, 34, 29));
let st = app.session.active().unwrap();
assert_eq!(st.history.past_len(), h0 + 1, "one undo step");
assert_eq!(st.history.undo_label(), Some(DUPLICATE_MOVE_LABEL));
assert!(app.session.undo());
assert_eq!(app.session.active().unwrap().doc.layers.len(), n0);
assert_eq!(bounds(&app, orig), photocraft_geom::Rect::new(8, 8, 24, 24));
// ⌥-click without a drag makes no copy.
drag(&mut app, &[[16.0, 16.0], [16.0, 16.0]], egui::Modifiers::ALT);
assert_eq!(app.session.active().unwrap().doc.layers.len(), n0);
// ⇧⌥: a copy moved along one axis.
drag(&mut app, &[[16.0, 16.0], [30.0, 19.0]], egui::Modifiers::SHIFT | egui::Modifiers::ALT);
let st = app.session.active().unwrap();
assert_eq!(st.doc.layers.len(), n0 + 1);
assert_eq!(bounds(&app, st.active_layer.unwrap()), photocraft_geom::Rect::new(22, 8, 38, 24));
}
#[test]
fn alt_drag_duplicates_every_selected_layer() {
let mut app = app_with_layer();
let a = app.session.active().unwrap().active_layer.unwrap();
app.session.execute("layer.new.layer", json!({})).unwrap();
let b = app.session.active().unwrap().active_layer.unwrap();
app.session.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap();
let n0 = app.session.active().unwrap().doc.layers.len();
let h0 = app.session.active().unwrap().history.past_len();
drag(&mut app, &[[16.0, 16.0], [26.0, 16.0]], egui::Modifiers::ALT);
let st = app.session.active().unwrap();
assert_eq!(st.doc.layers.len(), n0 + 2);
assert_eq!(st.history.past_len(), h0 + 1);
assert_eq!(bounds(&app, a), photocraft_geom::Rect::new(8, 8, 24, 24));
assert!(st.doc.layer(b).is_some());
}
#[test]
fn nudge_moves_layers_or_the_transform_box() {
let mut app = app_with_layer();
let id = app.session.active().unwrap().active_layer.unwrap();
nudge(&mut app, 10.0, 0.0, false);
nudge(&mut app, 0.0, -1.0, false);
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(18, 7, 34, 23));
let n0 = app.session.active().unwrap().doc.layers.len();
nudge(&mut app, 1.0, 0.0, true);
assert_eq!(app.session.active().unwrap().doc.layers.len(), n0 + 1);
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(18, 7, 34, 23));
let ctx = egui::Context::default();
crate::transform_tool::begin(&mut app, &ctx).unwrap();
let q0 = app.ui.transform.as_ref().unwrap().quad;
nudge(&mut app, 0.0, 10.0, false);
assert_eq!(app.ui.transform.as_ref().unwrap().quad[0], [q0[0][0], q0[0][1] + 10.0]);
}
}
+4 -1
View File
@@ -110,7 +110,10 @@ pub fn show_picker(app: &mut PhotocraftApp, ctx: &egui::Context) {
// Keep the whole picker on screen (it is about 280 × 300 points).
let pos = egui::pos2(x.min(screen.right() - 284.0).max(screen.left()), y.min(screen.bottom() - 310.0).max(screen.top()));
let area = egui::Area::new(egui::Id::new("canvas-brush-picker")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| crate::panels::brush_picker_body(ui, &mut app.session.tools.brush));
let before = app.session.tools.brush.clone();
let mut b = before.clone();
egui::Frame::popup(ui.style()).show(ui, |ui| crate::panels::brush_picker_body(ui, &mut b));
crate::brush_panel::commit_gesture(app, ui.ctx(), &before, &b);
});
let outside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !area.response.rect.contains(p)));
if outside {
+17 -2
View File
@@ -360,8 +360,12 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
return;
}
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 {
brush_preset_chip(ui, &mut app.session.tools.brush);
let before = app.session.tools.brush.clone();
let mut b = before.clone();
brush_preset_chip(ui, &mut b);
crate::brush_panel::commit_gesture(app, ui.ctx(), &before, &b);
widgets::vline(ui, 22.0);
}
if crate::eraser_ui::options_bar(app, ui, tool)
@@ -373,7 +377,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
return;
}
crate::paint_mouse::sync_tool_smoothing(app);
let b = &mut app.session.tools.brush;
let brush_before = app.session.tools.brush.clone();
let mut brush = brush_before.clone();
let b = &mut brush;
match app.ui.tool {
Tool::Brush | Tool::Eraser if t.pro => {
brush_preset_chip(ui, b);
@@ -637,6 +643,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// Retouching and smart-selection tools draw their bar in `retouch_ui::options_bar`.
_ => {}
}
crate::brush_panel::commit_gesture(app, ui.ctx(), &brush_before, &brush);
});
});
}
@@ -767,6 +774,7 @@ fn dock_panels(app: &mut PhotocraftApp, ui: &mut egui::Ui, p: &crate::state::Pan
let shown: Vec<Group> = [
(Group::Color, p.color),
(Group::Properties, t.pro && p.properties),
(Group::Character, p.character),
(Group::Navigator, p.navigator),
(Group::History, p.history),
(Group::Layers, p.layers),
@@ -801,6 +809,7 @@ fn dock_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, group: crate::dock::Gro
(Group::Color, _) => color_picker(app, ui),
(Group::Properties, 0) => properties_body(app, ui),
(Group::Properties, _) => adjustments_grid(app, ui),
(Group::Character, tab) => crate::type_tool::character_panel(app, ui, tab == 1),
(Group::Navigator, 0) => navigator(app, ui),
(Group::Navigator, 1) => crate::tone::histogram_panel(app, ui),
(Group::Navigator, _) => info_panel(app, ui),
@@ -1046,6 +1055,8 @@ fn blend_options(groups: bool) -> Vec<(BlendMode, &'static str)> {
}
fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// A new active layer opens its parent groups and is scrolled into view (#152).
let reveal = crate::layer_reveal::track(app, ui.ctx());
let Some(st) = app.session.active() else {
empty(ui, "No document");
return;
@@ -1175,7 +1186,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
}
let row = RowSel { selected: selection.contains(&l.id), primary: active == Some(l.id), multi: selection.len() > 1 };
let top = ui.cursor().top();
layer_row(app, &ctx, ui, &doc, l, depth, row, &mut actions);
if reveal == Some(l.id) {
crate::layer_reveal::scroll_to_row(ui, top);
}
if !l.effects.items.is_empty() && fx_collapsed.iter().all(|id| *id != l.id) {
effect_rows(app, ui, l, depth);
}
@@ -111,10 +111,11 @@ fn put_focus(h: &mut Harness<'_, PhotocraftApp>, place: Place) {
}
Place::OpacityField => {
// The Layers panel's Opacity field: the DragValue in the right dock reading 100.
let layers = crate::dock::last_rects(&h.ctx).into_iter().find(|(g, _)| *g == crate::dock::Group::Layers).expect("Layers drawn").1;
let at = h
.query_all_by_role(Role::SpinButton)
.map(|n| n.rect())
.filter(|r| r.center().x > 1100.0 && r.center().y > 500.0)
.filter(|r| layers.contains(r.center()))
.min_by(|a, b| a.center().y.total_cmp(&b.center().y))
.expect("Layers opacity field")
.center();
+15 -6
View File
@@ -200,6 +200,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
return;
}
}
// Move tool / Free Transform: arrows nudge (⇧ ×10, ⌥ duplicates first).
if focus == Focus::None && crate::move_mods::arrow_keys(app, ctx) {
return;
}
// Pen path in progress: ↩ finishes (open path), Esc cancels.
if app.ui.pen.is_some() {
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)) {
@@ -263,12 +267,17 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
if pressed(Key::D) {
let _ = app.run("tools.defaultColors", json!({}));
}
let b = &mut app.session.tools.brush;
if pressed(Key::OpenBracket) {
b.size = (b.size / 1.25).max(1.0).round();
}
if pressed(Key::CloseBracket) {
b.size = (b.size * 1.25).min(2500.0).round().max(b.size + 1.0);
// [ and ] resize the brush through `tools.setBrush` (journaled, drivable).
let size = app.session.tools.brush.size;
let next = if pressed(Key::OpenBracket) {
(size / 1.25).max(1.0).round()
} else if pressed(Key::CloseBracket) {
(size * 1.25).min(2500.0).round().max(size + 1.0)
} else {
size
};
if next != size {
let _ = app.run("tools.setBrush", serde_json::json!({ "brush": { "size": next } }));
}
}
+6
View File
@@ -227,6 +227,9 @@ pub struct Panels {
/// Window › Brush Settings (F5): floating, like Photoshop's.
#[serde(default)]
pub brush_settings: bool,
/// Window › Character / Paragraph: the Character | Paragraph dock group (#150).
#[serde(default)]
pub character: bool,
}
impl Default for Panels {
@@ -241,6 +244,7 @@ impl Default for Panels {
options_bar: true,
status_bar: true,
brush_settings: false,
character: false,
}
}
}
@@ -510,6 +514,8 @@ pub struct DockTabs {
pub navigator: usize,
/// History | Actions.
pub history: usize,
/// Character | Paragraph.
pub character: usize,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+275
View File
@@ -0,0 +1,275 @@
//! Dock tab strips that fit any width (#151), like Photoshop's: when the tabs don't fit beside
//! the panel menu button they shrink, eliding their labels with '…', and once they are at their
//! minimum width the ones that still don't fit move into a » chevron menu at the end of the
//! strip. The selected tab always stays on the strip, and no tab ever runs under the menu button.
use egui::{CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use crate::theme::{self, Tokens};
/// Which tabs a strip shows, at what widths, and which overflow into the chevron menu.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TabFit {
/// `(tab index, width)` for the tabs on the strip, in tab order.
pub shown: Vec<(usize, f32)>,
/// Tabs in the chevron menu, in tab order (empty: no chevron).
pub overflow: Vec<usize>,
}
/// Fit tabs with `natural` widths into `avail` points. Tabs shrink (never below `min_w`, or
/// their natural width if smaller) before any overflow; overflowing tabs leave `chevron_w`
/// for the chevron. `selected` is kept on the strip whatever happens.
pub fn fit(natural: &[f32], selected: usize, avail: f32, min_w: f32, chevron_w: f32) -> TabFit {
let clean = |w: f32| if w.is_finite() { w.max(0.0) } else { 0.0 };
let avail = clean(avail);
let min_w = clean(min_w);
let natural: Vec<f32> = natural.iter().map(|w| clean(*w)).collect();
let floor = |w: f32| w.min(min_w);
let all: Vec<usize> = (0..natural.len()).collect();
if natural.iter().sum::<f32>() <= avail {
return TabFit { shown: all.iter().map(|&i| (i, natural.get(i).copied().unwrap_or(0.0))).collect(), overflow: Vec::new() };
}
if natural.iter().map(|w| floor(*w)).sum::<f32>() <= avail {
return TabFit { shown: squeeze(&natural, &all, avail, min_w), overflow: Vec::new() };
}
// Overflow: the selected tab first, then the others in order, while they fit at their minimum.
let budget = (avail - clean(chevron_w)).max(0.0);
let sel = selected.min(natural.len().saturating_sub(1));
let mut keep: Vec<usize> = Vec::new();
let mut used = 0.0;
for i in std::iter::once(sel).chain(all.iter().copied().filter(|i| *i != sel)) {
let w = natural.get(i).map_or(0.0, |w| floor(*w));
if used + w <= budget || (i == sel && !natural.is_empty()) {
keep.push(i);
used += w;
}
}
keep.sort_unstable();
let mut shown = squeeze(&natural, &keep, budget, min_w);
// A strip too narrow even for the selected tab at its minimum: it takes what there is.
if used > budget
&& let Some(s) = shown.iter_mut().find(|(i, _)| *i == sel)
{
s.1 = budget;
}
let overflow = all.into_iter().filter(|i| !keep.contains(i)).collect();
TabFit { shown, overflow }
}
/// Widths for the `idx` tabs filling `total`: the widest shrink first (a common cap), none
/// below `min(natural, min_w)`.
fn squeeze(natural: &[f32], idx: &[usize], total: f32, min_w: f32) -> Vec<(usize, f32)> {
let ws: Vec<f32> = idx.iter().map(|&i| natural.get(i).copied().unwrap_or(0.0)).collect();
let sum_at = |cap: f32| ws.iter().map(|w| w.min(cap.max(w.min(min_w)))).sum::<f32>();
let (mut lo, mut hi) = (min_w, ws.iter().copied().fold(min_w, f32::max));
if sum_at(hi) > total {
for _ in 0..32 {
let mid = (lo + hi) / 2.0;
if sum_at(mid) <= total {
lo = mid;
} else {
hi = mid;
}
}
} else {
lo = hi;
}
idx.iter().zip(ws).map(|(&i, w)| (i, w.min(lo.max(w.min(min_w))))).collect()
}
/// A label laid out on one row no wider than `max_w`, cut with '…' when it doesn't fit.
pub fn elided(ui: &Ui, text: &str, font: egui::FontId, color: egui::Color32, max_w: f32) -> std::sync::Arc<egui::Galley> {
let mut job = egui::text::LayoutJob::simple_singleline(text.to_owned(), font, color);
job.wrap = egui::text::TextWrapping { max_width: max_w.max(1.0), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
ui.painter().layout_job(job)
}
/// What a strip reported this frame.
pub struct StripOut {
pub double_clicked: bool,
/// Rects of the tabs on the strip, `(tab index, rect)`.
pub tabs: Vec<(usize, Rect)>,
/// The » overflow button, when some tabs didn't fit.
pub chevron: Option<Rect>,
}
/// Width of the » overflow button.
const CHEVRON_W: f32 = 18.0;
/// Draw the tabs of a strip in `area` (the strip minus the menu button). `paint_tab` draws one
/// tab: (ui, rect, index, label galley, response, active).
#[allow(clippy::too_many_arguments)]
fn tabs_in(
ui: &mut Ui,
id: egui::Id,
area: Rect,
tabs: &[&str],
selected: &mut usize,
font: egui::FontId,
pad: f32,
min_w: f32,
active: impl Fn(usize) -> bool,
mut paint_tab: impl FnMut(&Ui, Rect, usize, std::sync::Arc<egui::Galley>, &Response, bool),
) -> StripOut {
let t = Tokens::get(ui.ctx());
let natural: Vec<f32> = tabs.iter().map(|n| ui.painter().layout_no_wrap((*n).to_owned(), font.clone(), t.text).size().x + pad).collect();
let f = fit(&natural, *selected, area.width(), min_w, CHEVRON_W);
let mut x = area.left();
let mut out = StripOut { double_clicked: false, tabs: Vec::with_capacity(f.shown.len()), chevron: None };
for &(i, w) in &f.shown {
let Some(name) = tabs.get(i) else { continue };
let r = Rect::from_min_size(pos2(x, area.top()), vec2(w, area.height()));
let resp = ui.interact(r, id.with(("tab", i)), Sense::click());
// The padding gives way (down to a third) before the label is cut.
let galley = elided(ui, name, font.clone(), t.text, (w - pad / 3.0).max(1.0));
let cut = galley.size().x + pad + 0.5 < natural.get(i).copied().unwrap_or(0.0) && galley.size().x + pad / 3.0 >= w - 0.5;
paint_tab(ui, r, i, galley, &resp, active(i));
let resp = if cut { resp.on_hover_text(*name) } else { resp };
out.double_clicked |= resp.double_clicked();
if resp.clicked() {
*selected = i;
}
out.tabs.push((i, r));
x = r.right();
}
if !f.overflow.is_empty() {
let r = Rect::from_min_size(pos2(x, area.top()), vec2(CHEVRON_W, area.height()));
let resp = ui.interact(r, id.with("tab-overflow"), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(1.0, 3.0)), t.radius_sm, t.hover.gamma_multiply(0.6));
}
crate::icons::paint(ui, r, "chevrons-right", 12.0, if resp.hovered() { t.text } else { t.text_dim });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "More panels"));
let resp = resp.on_hover_text("More panels");
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(140.0);
for &i in &f.overflow {
if let Some(name) = tabs.get(i)
&& ui.button(*name).clicked()
{
*selected = i;
ui.close();
}
}
});
out.chevron = Some(r);
}
out
}
/// Photoshop-grammar strip (Pro themes): flat tabs on the dark strip; `menu_left` is where the
/// panel menu button starts.
pub fn pro_tabs(ui: &mut Ui, id: egui::Id, strip: Rect, menu_left: f32, tabs: &[&str], selected: &mut usize, collapsed: bool) -> StripOut {
let t = Tokens::get(ui.ctx());
let area = Rect::from_min_max(strip.min, pos2(menu_left.max(strip.left()), strip.bottom()));
let sel = *selected;
tabs_in(
ui,
id,
area,
tabs,
selected,
egui::FontId::proportional(11.5),
22.0,
40.0,
|i| i == sel && !collapsed,
|ui, r, i, galley, resp, active| {
if active {
ui.painter().rect_filled(r, CornerRadius { nw: if i == 0 { 3 } else { 0 }, ne: 0, sw: 0, se: 0 }, t.card);
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.4));
}
let color = if active {
t.text
} else if resp.hovered() {
t.text_dim
} else {
t.text_faint
};
ui.painter().galley_with_override_text_color(r.center() - galley.size() / 2.0, galley, color);
},
)
}
/// Studio strip: pill tabs in `area` (the row minus the menu button).
pub fn pill_tabs(ui: &mut Ui, id: egui::Id, area: Rect, tabs: &[&str], selected: &mut usize) -> StripOut {
let t = Tokens::get(ui.ctx());
let sel = *selected;
tabs_in(
ui,
id,
area,
tabs,
selected,
theme::medium(12.5),
20.0,
44.0,
|i| i == sel,
|ui, r, _, galley, resp, active| {
// 2 pt between pills, as the old horizontal layout had.
let r = Rect::from_min_max(r.min, pos2((r.right() - 2.0).max(r.left()), r.bottom()));
if active {
crate::widgets::surface(ui, r, t.hover, true);
if !t.bevel {
ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
}
} else if resp.hovered() {
ui.painter().rect_filled(r, t.radius_sm, t.hover.gamma_multiply(0.6));
}
let color = if active { t.text } else { t.text_dim };
ui.painter().galley_with_override_text_color(r.center() - galley.size() / 2.0, galley, color);
},
)
}
#[cfg(test)]
mod tests {
use super::*;
fn total(f: &TabFit) -> f32 {
f.shown.iter().map(|(_, w)| w).sum()
}
#[test]
fn tabs_that_fit_keep_their_widths() {
let f = fit(&[60.0, 70.0, 80.0], 0, 400.0, 40.0, 18.0);
assert_eq!(f.shown, vec![(0, 60.0), (1, 70.0), (2, 80.0)]);
assert!(f.overflow.is_empty());
}
#[test]
fn narrow_strips_shrink_the_widest_tabs_first() {
let f = fit(&[50.0, 70.0, 120.0], 0, 200.0, 40.0, 18.0);
assert!(f.overflow.is_empty());
assert!((total(&f) - 200.0).abs() < 0.1, "{f:?}");
assert_eq!(f.shown[0], (0, 50.0), "a tab narrower than the cap keeps its width");
assert_eq!(f.shown[1], (1, 70.0));
assert!((f.shown[2].1 - 80.0).abs() < 0.1, "only the widest shrank: {f:?}");
}
#[test]
fn overflow_keeps_the_selected_tab_and_leaves_room_for_the_chevron() {
let natural = [80.0, 90.0, 85.0, 95.0];
for sel in 0..4 {
let f = fit(&natural, sel, 120.0, 40.0, 18.0);
assert!(f.shown.iter().any(|(i, _)| *i == sel), "{sel}: {f:?}");
assert!(!f.overflow.is_empty() && !f.overflow.contains(&sel));
assert!(total(&f) <= 120.0 - 18.0 + 1e-3, "{f:?}");
assert_eq!(f.shown.len() + f.overflow.len(), 4);
assert!(f.shown.windows(2).all(|w| w[0].0 < w[1].0), "tab order kept");
}
}
#[test]
fn hostile_inputs_never_panic_or_go_negative() {
for avail in [0.0, -10.0, 5.0, f32::NAN, f32::INFINITY] {
for natural in [&[][..], &[f32::NAN, 50.0][..], &[1e9, -5.0, 30.0][..]] {
for sel in [0, 1, 99] {
let f = fit(natural, sel, avail, 40.0, 18.0);
assert!(f.shown.iter().all(|(_, w)| w.is_finite() && *w >= 0.0), "{avail} {natural:?} {f:?}");
assert_eq!(f.shown.len() + f.overflow.len(), natural.len());
}
}
}
}
}
+240
View File
@@ -0,0 +1,240 @@
//! Textures for the Free Transform live preview (#91).
//!
//! The moving pixels are uploaded at full resolution (up to the GPU's texture limit) and drawn on
//! a mesh through the box's homography. What the preview samples depends on how big a source
//! pixel ends up on screen (view zoom × transform scale):
//!
//! - shrinking (< ½ screen px per source px): a box-filtered level of a lazily built mip chain, so
//! zoomed-out previews neither shimmer nor alias;
//! - around 1:1 or enlarging: the full-resolution level, bilinear;
//! - Nearest Neighbor interpolation, or a view zoomed in to ≥ 200% on pixels that aren't
//! enlarged by the transform: the full-resolution level with nearest filtering, so pixels stay
//! crisp like the canvas around them.
//!
//! Levels and filter variants are uploaded on first use and cached for the session.
use std::sync::{Arc, Mutex, PoisonError};
use egui::{Color32, ColorImage, TextureHandle, TextureId, TextureOptions};
/// One uploaded texture: its mip level, filter and the uv extent covering the source.
struct Level {
level: u32,
nearest: bool,
tex: TextureHandle,
uv: [f32; 2],
}
pub struct PreviewTextures {
name: String,
/// CPU copies of the levels built so far (level 0 = full resolution), premultiplied.
images: Mutex<Vec<Arc<ColorImage>>>,
/// uv extent of level 0 that covers the source exactly (< 1 when level 0 was padded).
base_uv: [f32; 2],
uploaded: Mutex<Vec<Level>>,
}
/// A source pixel shown at this many screen pixels or more counts as "zoomed in" (nearest).
const CRISP_ZOOM: f32 = 2.0;
impl PreviewTextures {
/// `image` covers the source with `base_uv` of its extent; `name` keys the texture in egui.
pub fn new(ctx: &egui::Context, name: String, image: ColorImage, base_uv: [f32; 2]) -> Self {
let image = Arc::new(image);
let tex = ctx.load_texture(format!("{name}-0-linear"), Arc::clone(&image), TextureOptions::LINEAR);
let base_uv = [base_uv[0].clamp(0.0, 1.0), base_uv[1].clamp(0.0, 1.0)];
Self { name, images: Mutex::new(vec![image]), base_uv, uploaded: Mutex::new(vec![Level { level: 0, nearest: false, tex, uv: base_uv }]) }
}
/// Size of the full-resolution level in texels.
pub fn size(&self) -> [usize; 2] {
self.images.lock().unwrap_or_else(PoisonError::into_inner).first().map_or([0, 0], |i| i.size)
}
/// The level and filter for a source pixel shown `screen_px` screen pixels wide, where the
/// transform itself scales by `scale` and the view zoom is `zoom`.
pub fn choose(zoom: f32, scale: f32, nearest_interpolation: bool) -> (u32, bool) {
let s = zoom * scale;
if !s.is_finite() || s <= 0.0 {
return (0, false);
}
if s < 0.5 {
// log2(1/s) levels down, at most 12 (4096× smaller).
return ((1.0 / s).log2().floor().clamp(0.0, 12.0) as u32, nearest_interpolation);
}
let crisp = nearest_interpolation || (zoom >= CRISP_ZOOM && scale <= 1.05);
(0, crisp)
}
/// Texture and uv extent to draw with (uploading a level / filter on first use).
pub fn pick(&self, ctx: &egui::Context, zoom: f32, scale: f32, nearest_interpolation: bool) -> (TextureId, [f32; 2]) {
let (level, nearest) = Self::choose(zoom, scale, nearest_interpolation);
let mut up = self.uploaded.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(l) = up.iter().find(|l| l.level == level && l.nearest == nearest) {
return (l.tex.id(), l.uv);
}
let Some((img, uv)) = self.level_image(level) else {
// Can't build it (shouldn't happen): fall back to the first upload.
return up.first().map_or((TextureId::default(), [1.0, 1.0]), |l| (l.tex.id(), l.uv));
};
let opts = if nearest { TextureOptions::NEAREST } else { TextureOptions::LINEAR };
let tex = ctx.load_texture(format!("{}-{level}-{}", self.name, if nearest { "nearest" } else { "linear" }), img, opts);
let id = tex.id();
up.push(Level { level, nearest, tex, uv });
(id, uv)
}
/// Level `n` of the mip chain (built on demand by 2×2 box filtering) and its uv extent.
fn level_image(&self, n: u32) -> Option<(Arc<ColorImage>, [f32; 2])> {
let mut imgs = self.images.lock().unwrap_or_else(PoisonError::into_inner);
while imgs.len() <= n as usize {
let prev = imgs.last()?;
if prev.size[0] <= 1 && prev.size[1] <= 1 {
break;
}
let next = Arc::new(halve(prev));
imgs.push(next);
}
let k = (imgs.len() - 1).min(n as usize);
let img = imgs.get(k)?.clone();
let base = imgs.first()?.size;
// Level k covers size·2^k base texels (the last row/column may be padding).
let f = (1u64 << k) as f32;
let uv = [self.base_uv[0] * base[0] as f32 / (img.size[0] as f32 * f).max(1.0), self.base_uv[1] * base[1] as f32 / (img.size[1] as f32 * f).max(1.0)];
Some((img, [uv[0].min(1.0), uv[1].min(1.0)]))
}
}
/// 2×2 box filter of a premultiplied image (odd edges repeat the last row/column).
fn halve(img: &ColorImage) -> ColorImage {
let [w, h] = img.size;
let (nw, nh) = (w.div_ceil(2).max(1), h.div_ceil(2).max(1));
let mut out = vec![Color32::TRANSPARENT; nw * nh];
let px = |x: usize, y: usize| img.pixels.get(y.min(h.saturating_sub(1)) * w + x.min(w.saturating_sub(1))).copied().unwrap_or_default();
for (y, row) in out.chunks_mut(nw).enumerate() {
for (x, o) in row.iter_mut().enumerate() {
let q = [px(2 * x, 2 * y), px(2 * x + 1, 2 * y), px(2 * x, 2 * y + 1), px(2 * x + 1, 2 * y + 1)];
let avg = |c: fn(&Color32) -> u8| ((q.iter().map(|p| u32::from(c(p))).sum::<u32>() + 2) / 4) as u8;
*o = Color32::from_rgba_premultiplied(avg(Color32::r), avg(Color32::g), avg(Color32::b), avg(Color32::a));
}
}
ColorImage::new([nw, nh], out)
}
/// Reads `b` of `surf` as premultiplied texels, at full resolution when it fits in `max_side`
/// (else box-filtered down by the smallest integer factor that fits). Returns the image and the
/// uv extent of it that covers `b`.
pub fn read_surface(surf: &photocraft_raster::Surface, b: photocraft_geom::Rect, max_side: usize) -> (ColorImage, [f32; 2]) {
let (w, h) = (b.width() as usize, b.height() as usize);
if w == 0 || h == 0 {
return (ColorImage::new([1, 1], vec![Color32::TRANSPARENT]), [1.0, 1.0]);
}
let k = w.max(h).div_ceil(max_side.max(1)).max(1);
let (tw, th) = (w.div_ceil(k), h.div_ceil(k));
let mut px = vec![Color32::TRANSPARENT; tw * th];
// One band of `k` source rows per output row, in parallel on native targets.
let band = |ty: usize, out: &mut [Color32]| {
let y0 = b.y0 + (ty * k) as i32;
let y1 = (y0 + k as i32).min(b.y1);
let rows = (y1 - y0).max(0) as usize;
let mut buf = vec![[0u8; 4]; w * rows];
surf.read_rgba8_into(photocraft_geom::Rect::new(b.x0, y0, b.x1, y1), &mut buf);
if k == 1 {
for (o, p) in out.iter_mut().zip(&buf) {
*o = if p[3] == 255 { Color32::from_rgb(p[0], p[1], p[2]) } else { Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]) };
}
return;
}
for (tx, o) in out.iter_mut().enumerate() {
let (x0, x1) = (tx * k, ((tx + 1) * k).min(w));
let mut acc = [0u32; 4];
let mut n = 0u32;
for r in 0..rows {
for p in buf.get(r * w + x0..r * w + x1).unwrap_or(&[]) {
let c = Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]);
for (a, v) in acc.iter_mut().zip(c.to_array()) {
*a += u32::from(v);
}
n += 1;
}
}
let n = n.max(1);
let v = acc.map(|a| ((a + n / 2) / n) as u8);
*o = Color32::from_rgba_premultiplied(v[0], v[1], v[2], v[3]);
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
px.par_chunks_mut(tw).enumerate().for_each(|(ty, out)| band(ty, out));
}
#[cfg(target_arch = "wasm32")]
for (ty, out) in px.chunks_mut(tw).enumerate() {
band(ty, out);
}
let uv = [w as f32 / (tw * k) as f32, h as f32 / (th * k) as f32];
(ColorImage::new([tw, th], px), uv)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn level_and_filter_follow_the_on_screen_size() {
assert_eq!(PreviewTextures::choose(1.0, 1.0, false), (0, false));
assert_eq!(PreviewTextures::choose(3.0, 1.0, false), (0, true), "zoomed in: crisp texels");
assert_eq!(PreviewTextures::choose(3.0, 2.0, false), (0, false), "enlarged by the transform: smooth");
assert_eq!(PreviewTextures::choose(1.0, 1.0, true), (0, true));
assert_eq!(PreviewTextures::choose(0.25, 1.0, false), (2, false));
assert_eq!(PreviewTextures::choose(0.3, 1.0, false), (1, false));
assert_eq!(PreviewTextures::choose(f32::NAN, 1.0, false), (0, false));
assert_eq!(PreviewTextures::choose(1e-9, 1.0, false).0, 12);
}
#[test]
fn full_resolution_unless_over_the_texture_limit() {
let mut s = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8);
// A one-pixel checkerboard: any downsampling would turn it grey.
for y in 0..300 {
for x in 0..500 {
if (x + y) % 2 == 0 {
s.fill_rect(photocraft_geom::Rect::new(x, y, x + 1, y + 1), &[1.0, 1.0, 1.0, 1.0]);
}
}
}
let r = photocraft_geom::Rect::new(0, 0, 500, 300);
let (img, uv) = read_surface(&s, r, 8192);
assert_eq!(img.size, [500, 300]);
assert_eq!(uv, [1.0, 1.0]);
assert_eq!(img.pixels[0], Color32::WHITE);
assert_eq!(img.pixels[1].a(), 0);
// Over the limit: box-filtered (grey-ish average), uv covers the padded edge.
let (small, uv) = read_surface(&s, r, 256);
assert_eq!(small.size, [250, 150]);
assert!((100..=155).contains(&small.pixels[0].a()), "{:?}", small.pixels[0]);
assert_eq!(uv, [1.0, 1.0]);
let (odd, uv) = read_surface(&s, photocraft_geom::Rect::new(0, 0, 301, 3), 256);
assert_eq!(odd.size, [151, 2]);
assert!(uv[0] < 1.0 && uv[1] < 1.0);
// Empty rect: a placeholder, no panic.
assert_eq!(read_surface(&s, photocraft_geom::Rect::new(5, 5, 5, 9), 256).0.size, [1, 1]);
}
#[test]
fn mip_levels_are_built_on_demand() {
let ctx = egui::Context::default();
let img = ColorImage::new([5, 3], vec![Color32::WHITE; 15]);
let t = PreviewTextures::new(&ctx, "t".into(), img, [1.0, 1.0]);
assert_eq!(t.size(), [5, 3]);
let (_, uv) = t.pick(&ctx, 0.5, 0.5, false);
// Level 2 is 2×1 texels covering 8×4 base texels.
assert!((uv[0] - 5.0 / 8.0).abs() < 1e-6 && (uv[1] - 3.0 / 4.0).abs() < 1e-6, "{uv:?}");
let (a, _) = t.pick(&ctx, 1.0, 1.0, false);
let (b, _) = t.pick(&ctx, 4.0, 1.0, false);
assert_ne!(a, b, "a nearest-filtered copy for zoomed-in views");
assert_eq!(t.pick(&ctx, 4.0, 1.0, false).0, b, "cached");
// Asking for more levels than exist stops at 1×1.
let _ = t.pick(&ctx, 1e-6, 1.0, false);
}
}
+207 -30
View File
@@ -2,8 +2,10 @@
//! engine's `edit.transform` (one quad for scale/rotate/skew/distort/perspective).
//!
//! Gestures follow Photoshop CC: corner drag scales proportionally (⇧ for free), edges scale one
//! axis, ⌥ scales about the reference point, ⌘-drag a corner distorts, drag outside rotates
//! (⇧ snaps to 15°), drag inside moves, the reference point can be dragged. ↩ commits, Esc cancels.
//! axis, ⌥ scales about the reference point, ⌘-drag a corner distorts (⌘⌥⇧: perspective), ⌘-drag
//! an edge skews (⇧ along the edge), drag outside rotates (⇧ snaps to 15°), drag inside moves
//! (⇧ locks to 8 directions), the reference point can be dragged and ⌥-click puts it under the
//! pointer. Arrow keys nudge the box (move_mods.rs). ↩ commits, Esc cancels.
use std::sync::Arc;
@@ -17,12 +19,12 @@ use crate::PhotocraftApp;
use crate::canvas::{ToolEvent, ViewXform};
use crate::state::TransformSession;
/// Preview state that isn't serialisable: the document without the transformed pixels, and a
/// texture of those pixels.
/// Preview state that isn't serialisable: the document without the transformed pixels, and
/// full-resolution textures of those pixels (transform_tex.rs).
pub struct TransformPreview {
pub session: u64,
pub doc: Arc<Document>,
pub texture: egui::TextureHandle,
pub texture: crate::transform_tex::PreviewTextures,
pub opacity: f32,
gesture: Option<Gesture>,
/// Warp-mode drag: (control point, pointer start, mesh points at the start).
@@ -61,8 +63,10 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
} else if let Some(l) = pd.layer_mut(id) {
l.visible = false;
}
let image = preview_image(&doc, id, lifted.as_ref(), b);
let texture = ctx.load_texture(format!("transform-{session}"), image, egui::TextureOptions::LINEAR);
// egui reports the renderer's limit in the app; offscreen harnesses only through the device.
let max_side = ctx.input(|i| i.max_texture_side).max(app.gpu.as_ref().map_or(1, |g| g.max_texture_side()));
let (image, uv) = preview_image(&doc, id, lifted.as_ref(), b, max_side);
let texture = crate::transform_tex::PreviewTextures::new(ctx, format!("transform-{session}"), image, uv);
app.transform_preview =
Some(TransformPreview { session, doc: Arc::new(pd), texture, opacity: layer.opacity * layer.fill_opacity, gesture: None, warp_drag: None });
app.ui.transform = Some(TransformSession {
@@ -102,7 +106,8 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(
px[ty * tw + tx] = Color32::from_rgba_unmultiplied(120, 170, 255, (m * 110.0) as u8);
}
}
let texture = ctx.load_texture(format!("transform-{session}"), egui::ColorImage::new([tw, th], px), egui::TextureOptions::LINEAR);
let uv = [w as f32 / (tw * k) as f32, h as f32 / (th * k) as f32];
let texture = crate::transform_tex::PreviewTextures::new(ctx, format!("transform-{session}"), egui::ColorImage::new([tw, th], px), uv);
let mut pd = (*doc).clone();
pd.selection = None;
app.transform_preview = Some(TransformPreview { session, doc: Arc::new(pd), texture, opacity: 1.0, gesture: None, warp_drag: None });
@@ -162,12 +167,16 @@ pub fn leave_warp(app: &mut PhotocraftApp) {
}
}
/// Texture of the moving pixels over `b`, at most 2048 px on the long side.
fn preview_image(doc: &Document, id: LayerId, lifted: Option<&photocraft_raster::Surface>, b: photocraft_geom::Rect) -> egui::ColorImage {
let (w, h) = (b.width() as usize, b.height() as usize);
let k = w.max(h).div_ceil(2048).max(1);
let (tw, th) = (w.div_ceil(k), h.div_ceil(k));
let mut px = vec![Color32::TRANSPARENT; tw * th];
/// Texels of the moving pixels over `b`: full resolution unless the long side exceeds the GPU's
/// `max_side` (#91: this used to be capped at 2048 px, so big layers previewed blurry), plus the
/// uv extent covering `b`.
fn preview_image(
doc: &Document,
id: LayerId,
lifted: Option<&photocraft_raster::Surface>,
b: photocraft_geom::Rect,
max_side: usize,
) -> (egui::ColorImage, [f32; 2]) {
let layer = doc.layer(id);
// Groups and type/fill layers preview from a flattened render of just that layer.
let rendered;
@@ -189,17 +198,8 @@ fn preview_image(doc: &Document, id: LayerId, lifted: Option<&photocraft_raster:
Some(&rendered)
}
};
let Some(surf) = surf else { return egui::ColorImage::new([tw, th], px) };
let mut row = vec![[0u8; 4]; w];
for ty in 0..th {
let y = b.y0 + (ty * k) as i32;
surf.read_rgba8_into(photocraft_geom::Rect::new(b.x0, y, b.x1, y + 1), &mut row);
for tx in 0..tw {
let p = row[(tx * k).min(w - 1)];
px[ty * tw + tx] = Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]);
}
}
egui::ColorImage::new([tw, th], px)
let Some(surf) = surf else { return (egui::ColorImage::new([1, 1], vec![Color32::TRANSPARENT]), [1.0, 1.0]) };
crate::transform_tex::read_surface(surf, b, max_side)
}
fn contains(l: &photocraft_doc::Layer, id: LayerId) -> bool {
@@ -303,7 +303,18 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
}
let Some(pv) = app.transform_preview.as_mut() else { return false };
match ev {
ToolEvent::Down { x, y, .. } => pv.gesture = Some(Gesture { hit: hit(&t, [x, y], tol), start: [x, y], quad0: t.quad, pivot0: t.pivot }),
ToolEvent::Down { x, y, .. } => {
let mut h = hit(&t, [x, y], tol);
// ⌥-click away from the handles moves the reference point there (and drags it).
if mods.alt
&& matches!(h, Hit::Inside | Hit::Outside)
&& let Some(s) = app.ui.transform.as_mut()
{
s.pivot = [x, y];
h = Hit::Pivot;
}
pv.gesture = Some(Gesture { hit: h, start: [x, y], quad0: t.quad, pivot0: t.pivot });
}
ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => {
let g = pv.gesture;
if matches!(ev, ToolEvent::Up { .. }) {
@@ -321,6 +332,14 @@ fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods: egui::Mod
let (dx, dy) = (p[0] - g.start[0], p[1] - g.start[1]);
match g.hit {
Hit::Inside => {
// ⇧ locks the move to the axes and diagonals; the current offset keeps the lock stable.
let (dx, dy) = if mods.shift {
let prev = [s.quad[0][0] - g.quad0[0][0], s.quad[0][1] - g.quad0[0][1]];
let d = crate::move_mods::constrain([dx, dy], Some(prev), crate::move_mods::TRANSFORM_DIRECTIONS);
(d[0], d[1])
} else {
(dx, dy)
};
s.quad = g.quad0.map(|q| [q[0] + dx, q[1] + dy]);
s.pivot = [g.pivot0[0] + dx, g.pivot0[1] + dy];
}
@@ -342,11 +361,52 @@ fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods: egui::Mod
[c[0] + x * cs - y * sn, c[1] + x * sn + y * cs]
});
}
Hit::Corner(i) if mods.command && mods.alt && mods.shift => {
// Perspective: the corner moves along the dominant axis and the corner sharing that
// side moves the opposite way.
s.quad = g.quad0;
let horizontal = dx.abs() >= dy.abs();
// Corners 0..3 = TL, TR, BR, BL: horizontal drags pair along the top/bottom edge,
// vertical ones along the left/right edge.
let j = match (i, horizontal) {
(0, true) => 1,
(1, true) => 0,
(2, true) => 3,
(3, true) => 2,
(0, false) => 3,
(3, false) => 0,
(1, false) => 2,
_ => 1,
};
let (mx, my) = if horizontal { (dx, 0.0) } else { (0.0, dy) };
s.quad[i] = [g.quad0[i][0] + mx, g.quad0[i][1] + my];
s.quad[j] = [g.quad0[j][0] - mx, g.quad0[j][1] - my];
}
Hit::Corner(i) if mods.command => {
// Distort: move the corner freely.
s.quad = g.quad0;
s.quad[i] = [g.quad0[i][0] + dx, g.quad0[i][1] + dy];
}
Hit::Edge(i) if mods.command => {
// Skew: the edge's two corners move together (⇧: only along the edge).
let (a, b) = (i, (i + 1) % 4);
let (mut mx, mut my) = (dx, dy);
if mods.shift {
let e = [g.quad0[b][0] - g.quad0[a][0], g.quad0[b][1] - g.quad0[a][1]];
let l2 = e[0] * e[0] + e[1] * e[1];
let t = if l2 > 0.0 { (dx * e[0] + dy * e[1]) / l2 } else { 0.0 };
(mx, my) = (t * e[0], t * e[1]);
}
s.quad = g.quad0;
s.quad[a] = [g.quad0[a][0] + mx, g.quad0[a][1] + my];
s.quad[b] = [g.quad0[b][0] + mx, g.quad0[b][1] + my];
if mods.alt {
// ⌥: the opposite edge skews the other way (about the centre).
let (c, d) = ((i + 2) % 4, (i + 3) % 4);
s.quad[c] = [g.quad0[c][0] - mx, g.quad0[c][1] - my];
s.quad[d] = [g.quad0[d][0] - mx, g.quad0[d][1] - my];
}
}
Hit::Corner(_) | Hit::Edge(_) => {
// Work in the box's own (unit) frame so rotated/skewed boxes scale along their axes.
let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], g.quad0) else { return };
@@ -524,13 +584,14 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform
if let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], t.quad) {
// A 24×24 grid keeps perspective previews straight.
let n = 24;
let mut mesh = egui::Mesh::with_texture(pv.texture.id());
let (tex, uv) = pv.texture.pick(painter.ctx(), xf.zoom * painter.ctx().pixels_per_point(), quad_scale(t), t.interpolation == "nearest");
let mut mesh = egui::Mesh::with_texture(tex);
let tint = Color32::from_white_alpha((pv.opacity.clamp(0.0, 1.0) * 255.0) as u8);
for j in 0..=n {
for i in 0..=n {
let (u, v) = (i as f64 / n as f64, j as f64 / n as f64);
let (x, y) = h.apply(u, v);
mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(x as f32, y as f32), uv: pos2(u as f32, v as f32), color: tint });
mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(x as f32, y as f32), uv: pos2(u as f32 * uv[0], v as f32 * uv[1]), color: tint });
}
}
for j in 0..n {
@@ -565,13 +626,14 @@ fn draw_warp(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: &
let scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32);
let r = t.rect;
let n = 32;
let mut mesh = egui::Mesh::with_texture(pv.texture.id());
let (tex, uv) = pv.texture.pick(painter.ctx(), xf.zoom * painter.ctx().pixels_per_point(), 1.0, t.interpolation == "nearest");
let mut mesh = egui::Mesh::with_texture(tex);
let tint = Color32::from_white_alpha((pv.opacity.clamp(0.0, 1.0) * 255.0) as u8);
for j in 0..=n {
for i in 0..=n {
let (u, v) = (i as f64 / n as f64, j as f64 / n as f64);
let (x, y) = w.map(r[0] + u * (r[2] - r[0]), r[1] + v * (r[3] - r[1]));
mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(x as f32, y as f32), uv: pos2(u as f32, v as f32), color: tint });
mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(x as f32, y as f32), uv: pos2(u as f32 * uv[0], v as f32 * uv[1]), color: tint });
}
}
for j in 0..n {
@@ -640,6 +702,16 @@ fn draw_warp(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: &
}
}
/// How much the box enlarges its pixels: the longest edge relative to the original (picks the
/// preview's texture level and filter).
fn quad_scale(t: &TransformSession) -> f32 {
let (w0, h0) = ((t.rect[2] - t.rect[0]).max(1e-9), (t.rect[3] - t.rect[1]).max(1e-9));
let len = |a: [f64; 2], b: [f64; 2]| (b[0] - a[0]).hypot(b[1] - a[1]);
let q = &t.quad;
let s = (len(q[0], q[1]) / w0).max(len(q[3], q[2]) / w0).max(len(q[0], q[3]) / h0).max(len(q[1], q[2]) / h0);
if s.is_finite() { s as f32 } else { 1.0 }
}
/// Scale (%), angle (°) and translation implied by the current quad (affine readout).
fn readout(t: &TransformSession) -> (f64, f64, f64, f64) {
let (w0, h0) = (t.rect[2] - t.rect[0], t.rect[3] - t.rect[1]);
@@ -914,6 +986,111 @@ mod tests {
assert!(app.ui.transform.is_none() && app.transform_preview.is_none());
}
fn app_with_square(size: u32, fill: photocraft_geom::Rect) -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": size, "height": size})).unwrap();
app.sync_views();
app.session.execute("layer.new.layer", json!({})).unwrap();
app.session
.edit("paint", |doc, a| {
doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(fill, &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
app
}
/// A context that reports a real GPU's texture limit (the default reports 2048).
fn gpu_ctx() -> egui::Context {
let ctx = egui::Context::default();
// Clear the font atlas delta: epaint panics on dropping unapplied deltas in debug builds.
let mut out = ctx.run_ui(egui::RawInput { max_texture_side: Some(16384), ..Default::default() }, |_| {});
out.textures_delta.clear();
ctx
}
#[test]
fn preview_texture_is_full_resolution() {
// #91: the preview used to be sampled down to 2048 px, so big layers looked blurry at 100%.
let mut app = app_with_square(3000, photocraft_geom::Rect::new(10, 20, 2810, 2420));
let ctx = gpu_ctx();
begin(&mut app, &ctx).unwrap();
let pv = app.transform_preview.as_ref().unwrap();
assert_eq!(pv.texture.size(), [2800, 2400], "one texel per layer pixel");
// At 100% the texture has at least as many texels as the box covers screen pixels.
let t = app.ui.transform.as_ref().unwrap();
assert!(pv.texture.size()[0] as f64 >= t.rect[2] - t.rect[0]);
}
#[test]
fn shift_body_drag_locks_to_eight_directions_and_switches() {
let mut s = session();
let g = Gesture { hit: Hit::Inside, start: [40.0, 20.0], quad0: s.quad, pivot0: s.pivot };
apply_drag(&mut s, g, [60.0, 23.0], egui::Modifiers::SHIFT);
assert!(close(s.quad, corners([20.0, 0.0, 120.0, 50.0])), "horizontal: {:?}", s.quad);
apply_drag(&mut s, g, [60.0, 39.0], egui::Modifiers::SHIFT);
assert!((s.quad[0][0] - s.quad[0][1]).abs() < 1e-9 && s.quad[0][0] > 15.0, "diagonal: {:?}", s.quad[0]);
apply_drag(&mut s, g, [41.0, 60.0], egui::Modifiers::SHIFT);
assert!(close(s.quad, corners([0.0, 40.0, 100.0, 90.0])), "vertical: {:?}", s.quad);
}
#[test]
fn alt_click_moves_the_reference_point_and_edges_skew() {
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));
let ctx = egui::Context::default();
begin(&mut app, &ctx).unwrap();
assert!(pointer(&mut app, ToolEvent::Down { x: 40.0, y: 40.0, pressure: 1.0 }, egui::Modifiers::ALT));
pointer(&mut app, ToolEvent::Up { x: 40.0, y: 40.0 }, egui::Modifiers::ALT);
let t = app.ui.transform.as_ref().unwrap();
assert_eq!(t.pivot, [40.0, 40.0]);
assert_eq!(t.quad, corners(t.rect), "the box didn't move");
// ⌘ on an edge skews: both corners of the top edge move.
let mut s = session();
drag(&mut s, [50.0, 0.0], [70.0, 5.0], egui::Modifiers::COMMAND);
assert_eq!((s.quad[0], s.quad[1], s.quad[2]), ([20.0, 5.0], [120.0, 5.0], [100.0, 50.0]));
// ⌘⇧: only along the edge.
let mut s = session();
drag(&mut s, [50.0, 0.0], [70.0, 5.0], egui::Modifiers::COMMAND | egui::Modifiers::SHIFT);
assert_eq!((s.quad[0], s.quad[1]), ([20.0, 0.0], [120.0, 0.0]));
// ⌘⌥⇧ on a corner: perspective (the paired corner mirrors).
let mut s = session();
drag(&mut s, [100.0, 0.0], [101.0, -10.0], egui::Modifiers::COMMAND | egui::Modifiers::ALT | egui::Modifiers::SHIFT);
assert_eq!((s.quad[1], s.quad[2]), ([100.0, -10.0], [100.0, 60.0]));
}
/// `cargo test --release -p photocraft-ui-egui transform_preview_bench -- --ignored --nocapture`
#[test]
#[ignore]
fn transform_preview_bench() {
let (w, h) = (6000, 4000);
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": w, "height": h})).unwrap();
app.sync_views();
app.session.execute("layer.new.layer", json!({})).unwrap();
app.session.execute("filter.render.clouds", json!({})).unwrap();
let ctx = gpu_ctx();
let t0 = std::time::Instant::now();
begin(&mut app, &ctx).unwrap();
let begin_ms = t0.elapsed().as_secs_f64() * 1e3;
let size = app.transform_preview.as_ref().unwrap().texture.size();
rotate_about_pivot(&mut app, 0.05);
let frame = |zoom: f32| {
let t0 = std::time::Instant::now();
let _ = ctx.run_ui(egui::RawInput { max_texture_side: Some(16384), ..Default::default() }, |ui| {
let painter = ui.ctx().layer_painter(egui::LayerId::background());
let xf = ViewXform { rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 1000.0)), zoom, center: [3000.0, 2000.0], flip: false };
draw_overlay(&app, &painter, &xf);
});
t0.elapsed().as_secs_f64() * 1e3
};
let first: Vec<f64> = [1.0, 0.25, 3.0].iter().map(|z| frame(*z)).collect();
let n = 50;
let steady = (0..n).map(|i| frame([1.0, 0.25, 3.0][i % 3])).sum::<f64>() / n as f64;
eprintln!(
"transform preview {w}x{h}: texture {size:?}, begin {begin_ms:.1} ms, first frame per level (1x, 0.25x, 3x) {first:.1?} ms, steady frame {steady:.3} ms"
);
}
#[test]
fn transform_selection_moves_only_the_outline() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
+149 -128
View File
@@ -803,6 +803,22 @@ fn align_glyph(p: &egui::Painter, r: egui::Rect, a: photocraft_doc::text::TextAl
/// Properties panel sections for a type layer: Character and Paragraph (Photoshop CC layout).
pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
type_sections(app, ui, true, true);
}
/// Window › Character / Paragraph (#150): the same controls as Properties, in their own dock
/// group. Without a type layer (or a type edit) there is nothing to show.
pub fn character_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool) {
if styles_at(app).is_none() {
let t = crate::theme::Tokens::get(ui.ctx());
ui.add_space(8.0);
ui.label(egui::RichText::new("Select a type layer to edit its character and paragraph settings.").color(t.text_dim));
return;
}
type_sections(app, ui, !paragraph, paragraph);
}
fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, paragraph: bool) {
let Some((c, para)) = styles_at(app) else { return };
let t = crate::theme::Tokens::get(ui.ctx());
let section = |ui: &mut egui::Ui, title: &str| {
@@ -810,141 +826,146 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.label(egui::RichText::new(title).font(crate::theme::semibold(12.0)).color(t.text));
ui.add_space(2.0);
};
section(ui, "Character");
let mut fam = c.font_family.clone();
ui.horizontal(|ui| {
if font_picker(ui, &mut fam) {
app.ui.tool_options.type_font = fam.clone();
let st = styles(&fam);
let style = if st.contains(&c.font_style) { c.font_style.clone() } else { st.first().cloned().unwrap_or_else(|| "Regular".into()) };
apply(app, ui.ctx(), json!({"font": fam, "fontStyle": style}));
}
});
ui.horizontal(|ui| {
let mut style = if c.font_style.is_empty() { "Regular".to_string() } else { c.font_style.clone() };
let opts: Vec<(String, String)> = styles(&fam).into_iter().map(|s| (s.clone(), s)).collect();
let opts_ref: Vec<(String, &str)> = opts.iter().map(|(a, b)| (a.clone(), b.as_str())).collect();
if crate::widgets::dropdown(ui, "props-type-style", &mut style, &opts_ref, 170.0) {
apply(app, ui.ctx(), json!({"fontStyle": style}));
}
});
let w = ((ui.available_width() - 70.0) / 2.0).clamp(50.0, 90.0);
ui.horizontal(|ui| {
let k = shown_scale(app);
if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt * k, 0.1..=1296.0, "pt", w) {
app.ui.tool_options.type_size = v;
apply(app, ui.ctx(), json!({"size": v / k}));
}
let lead = c.leading_pt.unwrap_or(c.size_pt * para.auto_leading.max(0.01)) * k;
if let Some(v) = num_field(ui, "A↕", "Leading (set to the font size × auto-leading when Auto)", lead, 0.1..=5000.0, "pt", w) {
apply(app, ui.ctx(), json!({"leading": v / k}));
}
});
ui.horizontal(|ui| {
let mut k = match c.kerning {
photocraft_doc::text::Kerning::Metrics => "metrics",
photocraft_doc::text::Kerning::Optical => "optical",
photocraft_doc::text::Kerning::Off => "off",
}
.to_string();
icon_label(ui, "text-cursor", "Kerning");
if crate::widgets::dropdown(
ui,
"props-kern",
&mut k,
&[("metrics".to_string(), "Metrics"), ("optical".to_string(), "Optical"), ("off".to_string(), "0")],
w,
) {
apply(app, ui.ctx(), json!({"kerning": k}));
}
if let Some(v) = num_field(ui, "VA", "Tracking (1/1000 em)", c.tracking, -1000.0..=10000.0, "", w) {
apply(app, ui.ctx(), json!({"tracking": v}));
}
});
ui.horizontal(|ui| {
if let Some(v) = num_field(ui, "↕T", "Vertical scale", c.vertical_scale * 100.0, 0.0..=1000.0, "%", w) {
apply(app, ui.ctx(), json!({"verticalScale": v}));
}
if let Some(v) = num_field(ui, "↔T", "Horizontal scale", c.horizontal_scale * 100.0, 0.0..=1000.0, "%", w) {
apply(app, ui.ctx(), json!({"horizontalScale": v}));
}
});
ui.horizontal(|ui| {
if let Some(v) = num_field(ui, "Aª", "Baseline shift", c.baseline_shift_pt, -1296.0..=1296.0, "pt", w) {
apply(app, ui.ctx(), json!({"baselineShift": v}));
}
// Colour chip.
ui.add_space(8.0);
ui.label(egui::RichText::new("Color:").color(t.text_dim).size(12.0));
let rgb = c.color.to_rgb();
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
let (rect, resp) = ui.allocate_exact_size(egui::vec2(40.0, 18.0), egui::Sense::click());
ui.painter().rect_filled(rect, 2.0, Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2])));
ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside);
egui::Popup::from_toggle_button_response(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
let mut col = Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2]));
if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) {
apply(app, ui.ctx(), json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
if character {
section(ui, "Character");
let mut fam = c.font_family.clone();
ui.horizontal(|ui| {
if font_picker(ui, &mut fam) {
app.ui.tool_options.type_font = fam.clone();
let st = styles(&fam);
let style = if st.contains(&c.font_style) { c.font_style.clone() } else { st.first().cloned().unwrap_or_else(|| "Regular".into()) };
apply(app, ui.ctx(), json!({"font": fam, "fontStyle": style}));
}
});
});
// Faux styles row: T (bold) T (italic) TT Tᴛ T̲ T̶
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let caps = c.caps;
let toggles: [(&str, &str, bool, serde_json::Value); 6] = [
("T", "Faux Bold", c.faux_bold, json!({"fauxBold": !c.faux_bold})),
("T", "Faux Italic", c.faux_italic, json!({"fauxItalic": !c.faux_italic})),
(
"TT",
"All Caps",
caps == photocraft_doc::text::Caps::AllCaps,
json!({"caps": if caps == photocraft_doc::text::Caps::AllCaps { "normal" } else { "allCaps" }}),
),
(
"Tᴛ",
"Small Caps",
caps == photocraft_doc::text::Caps::SmallCaps,
json!({"caps": if caps == photocraft_doc::text::Caps::SmallCaps { "normal" } else { "smallCaps" }}),
),
("T", "Underline", c.underline, json!({"underline": !c.underline})),
("T", "Strikethrough", c.strikethrough, json!({"strikethrough": !c.strikethrough})),
];
for (i, (glyph, tip, on, props)) in toggles.into_iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(egui::vec2(28.0, 24.0), egui::Sense::click());
let bg = if on {
t.accent_soft
} else if resp.hovered() {
t.hover
} else {
Color32::TRANSPARENT
};
ui.painter().rect_filled(r, 3.0, bg);
let font = if i == 0 { crate::theme::semibold(13.0) } else { egui::FontId::proportional(13.0) };
let col = if on { t.text } else { t.text_dim };
let g = ui.painter().layout_no_wrap(glyph.to_string(), font, col);
let pos = r.center() - g.size() / 2.0;
let gr = egui::Rect::from_min_size(pos, g.size());
if i == 1 {
// Faux italic: a slanted T drawn as strokes (no italic face is bundled).
let (top, bot, cx) = (gr.top() + 3.0, gr.bottom() - 3.0, gr.center().x);
let slant = (bot - top) * 0.25;
ui.painter().line_segment([egui::pos2(cx - 4.0 + slant / 2.0, top), egui::pos2(cx + 4.0 + slant / 2.0, top)], Stroke::new(1.3, col));
ui.painter().line_segment([egui::pos2(cx + slant / 2.0, top), egui::pos2(cx - slant / 2.0, bot)], Stroke::new(1.3, col));
} else {
ui.painter().galley(pos, g, col);
ui.horizontal(|ui| {
let mut style = if c.font_style.is_empty() { "Regular".to_string() } else { c.font_style.clone() };
let opts: Vec<(String, String)> = styles(&fam).into_iter().map(|s| (s.clone(), s)).collect();
let opts_ref: Vec<(String, &str)> = opts.iter().map(|(a, b)| (a.clone(), b.as_str())).collect();
if crate::widgets::dropdown(ui, "props-type-style", &mut style, &opts_ref, 170.0) {
apply(app, ui.ctx(), json!({"fontStyle": style}));
}
if i == 4 {
ui.painter().line_segment([egui::pos2(gr.left(), gr.bottom() - 2.0), egui::pos2(gr.right(), gr.bottom() - 2.0)], Stroke::new(1.0, col));
});
ui.horizontal(|ui| {
let k = shown_scale(app);
if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt * k, 0.1..=1296.0, "pt", w) {
app.ui.tool_options.type_size = v;
apply(app, ui.ctx(), json!({"size": v / k}));
}
if i == 5 {
ui.painter().line_segment([egui::pos2(gr.left() - 1.0, gr.center().y), egui::pos2(gr.right() + 1.0, gr.center().y)], Stroke::new(1.0, col));
let lead = c.leading_pt.unwrap_or(c.size_pt * para.auto_leading.max(0.01)) * k;
if let Some(v) = num_field(ui, "A↕", "Leading (set to the font size × auto-leading when Auto)", lead, 0.1..=5000.0, "pt", w) {
apply(app, ui.ctx(), json!({"leading": v / k}));
}
if resp.on_hover_text(tip).clicked() {
apply(app, ui.ctx(), props);
});
ui.horizontal(|ui| {
let mut k = match c.kerning {
photocraft_doc::text::Kerning::Metrics => "metrics",
photocraft_doc::text::Kerning::Optical => "optical",
photocraft_doc::text::Kerning::Off => "off",
}
}
});
.to_string();
icon_label(ui, "text-cursor", "Kerning");
if crate::widgets::dropdown(
ui,
"props-kern",
&mut k,
&[("metrics".to_string(), "Metrics"), ("optical".to_string(), "Optical"), ("off".to_string(), "0")],
w,
) {
apply(app, ui.ctx(), json!({"kerning": k}));
}
if let Some(v) = num_field(ui, "VA", "Tracking (1/1000 em)", c.tracking, -1000.0..=10000.0, "", w) {
apply(app, ui.ctx(), json!({"tracking": v}));
}
});
ui.horizontal(|ui| {
if let Some(v) = num_field(ui, "↕T", "Vertical scale", c.vertical_scale * 100.0, 0.0..=1000.0, "%", w) {
apply(app, ui.ctx(), json!({"verticalScale": v}));
}
if let Some(v) = num_field(ui, "↔T", "Horizontal scale", c.horizontal_scale * 100.0, 0.0..=1000.0, "%", w) {
apply(app, ui.ctx(), json!({"horizontalScale": v}));
}
});
ui.horizontal(|ui| {
if let Some(v) = num_field(ui, "Aª", "Baseline shift", c.baseline_shift_pt, -1296.0..=1296.0, "pt", w) {
apply(app, ui.ctx(), json!({"baselineShift": v}));
}
// Colour chip.
ui.add_space(8.0);
ui.label(egui::RichText::new("Color:").color(t.text_dim).size(12.0));
let rgb = c.color.to_rgb();
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
let (rect, resp) = ui.allocate_exact_size(egui::vec2(40.0, 18.0), egui::Sense::click());
ui.painter().rect_filled(rect, 2.0, Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2])));
ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside);
egui::Popup::from_toggle_button_response(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
let mut col = Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2]));
if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) {
apply(app, ui.ctx(), json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
}
});
});
// Faux styles row: T (bold) T (italic) TT Tᴛ T̲ T̶
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let caps = c.caps;
let toggles: [(&str, &str, bool, serde_json::Value); 6] = [
("T", "Faux Bold", c.faux_bold, json!({"fauxBold": !c.faux_bold})),
("T", "Faux Italic", c.faux_italic, json!({"fauxItalic": !c.faux_italic})),
(
"TT",
"All Caps",
caps == photocraft_doc::text::Caps::AllCaps,
json!({"caps": if caps == photocraft_doc::text::Caps::AllCaps { "normal" } else { "allCaps" }}),
),
(
"Tᴛ",
"Small Caps",
caps == photocraft_doc::text::Caps::SmallCaps,
json!({"caps": if caps == photocraft_doc::text::Caps::SmallCaps { "normal" } else { "smallCaps" }}),
),
("T", "Underline", c.underline, json!({"underline": !c.underline})),
("T", "Strikethrough", c.strikethrough, json!({"strikethrough": !c.strikethrough})),
];
for (i, (glyph, tip, on, props)) in toggles.into_iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(egui::vec2(28.0, 24.0), egui::Sense::click());
let bg = if on {
t.accent_soft
} else if resp.hovered() {
t.hover
} else {
Color32::TRANSPARENT
};
ui.painter().rect_filled(r, 3.0, bg);
let font = if i == 0 { crate::theme::semibold(13.0) } else { egui::FontId::proportional(13.0) };
let col = if on { t.text } else { t.text_dim };
let g = ui.painter().layout_no_wrap(glyph.to_string(), font, col);
let pos = r.center() - g.size() / 2.0;
let gr = egui::Rect::from_min_size(pos, g.size());
if i == 1 {
// Faux italic: a slanted T drawn as strokes (no italic face is bundled).
let (top, bot, cx) = (gr.top() + 3.0, gr.bottom() - 3.0, gr.center().x);
let slant = (bot - top) * 0.25;
ui.painter().line_segment([egui::pos2(cx - 4.0 + slant / 2.0, top), egui::pos2(cx + 4.0 + slant / 2.0, top)], Stroke::new(1.3, col));
ui.painter().line_segment([egui::pos2(cx + slant / 2.0, top), egui::pos2(cx - slant / 2.0, bot)], Stroke::new(1.3, col));
} else {
ui.painter().galley(pos, g, col);
}
if i == 4 {
ui.painter().line_segment([egui::pos2(gr.left(), gr.bottom() - 2.0), egui::pos2(gr.right(), gr.bottom() - 2.0)], Stroke::new(1.0, col));
}
if i == 5 {
ui.painter().line_segment([egui::pos2(gr.left() - 1.0, gr.center().y), egui::pos2(gr.right() + 1.0, gr.center().y)], Stroke::new(1.0, col));
}
if resp.on_hover_text(tip).clicked() {
apply(app, ui.ctx(), props);
}
}
});
}
if !paragraph {
return;
}
section(ui, "Paragraph");
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
+6 -3
View File
@@ -209,9 +209,10 @@ fn panel_tab(app: &PhotocraftApp, id: &str) -> Option<(&'static str, usize)> {
"window.panel.paths" => ("layers", 2),
"window.panel.layers" => ("layers", 0),
"window.panel.adjustments" => ("properties", 1),
"window.panel.properties" | "window.panel.character" | "window.panel.paragraph" | "type.panels.character" | "type.panels.paragraph" => {
("properties", 0)
}
"window.panel.properties" => ("properties", 0),
// Their own Character | Paragraph group, so Properties stays open (#150).
"window.panel.character" | "type.panels.character" => ("character", 0),
"window.panel.paragraph" | "type.panels.paragraph" => ("character", 1),
"window.panel.swatches" => ("color", usize::from(pro)),
"window.panel.color" => ("color", usize::from(!pro)),
_ => return None,
@@ -225,6 +226,7 @@ fn panel_state<'a>(app: &'a mut PhotocraftApp, panel: &str) -> (&'a mut bool, &'
"history" => (&mut p.history, &mut t.history),
"layers" => (&mut p.layers, &mut t.layers),
"color" => (&mut p.color, &mut t.color),
"character" => (&mut p.character, &mut t.character),
_ => (&mut p.properties, &mut t.properties),
}
}
@@ -356,6 +358,7 @@ pub fn checked(app: &PhotocraftApp, id: &str) -> Option<bool> {
"history" => (p.history, t.history),
"layers" => (p.layers, t.layers),
"color" => (p.color, t.color),
"character" => (p.character, t.character),
_ => (p.properties, t.properties),
};
return Some(vis && cur == tab);
+16 -47
View File
@@ -33,6 +33,11 @@ pub struct CardResponse {
pub menu: Response,
/// A tab was double-clicked (Photoshop collapses the group).
pub tab_double_clicked: bool,
/// Rects of the tabs on the strip, `(tab index, rect)`; tabs that don't fit are in the
/// chevron menu instead (#151).
pub tabs: Vec<(usize, Rect)>,
/// The » overflow button, when some tabs didn't fit.
pub chevron: Option<Rect>,
}
/// [`card`] that can be collapsed to its tab strip and reports strip and menu interactions.
@@ -52,29 +57,18 @@ pub fn card_ex(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, colla
// Registered before the tabs so they keep their clicks; drags fall through to it.
let strip_rect = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), 22.0));
let strip = ui.interact(strip_rect, ui.id().with((id, "strip")), Sense::click_and_drag());
let mut double = false;
let menu = ui
.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for (i, name) in tabs.iter().enumerate() {
let r = pill_tab(ui, name, *selected == i);
double |= r.double_clicked();
if r.clicked() {
*selected = i;
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 0.0;
crate::icons::button(ui, "ellipsis", 22.0, false, &format!("{} options", tabs.get(*selected).copied().unwrap_or(id)))
})
.inner
})
.inner;
// Pill tabs left of the menu button; they elide or overflow into a chevron (#151).
let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover());
let tip = format!("{} options", tabs.get(*selected).copied().unwrap_or(id));
let menu_rect = Rect::from_min_max(pos2(row.right() - 22.0, row.top() + 1.0), pos2(row.right(), row.bottom() - 1.0));
let menu = crate::icons::button(&mut ui.new_child(egui::UiBuilder::new().max_rect(menu_rect)), "ellipsis", 22.0, false, &tip);
let area = Rect::from_min_max(row.min, pos2((menu_rect.left() - 4.0).max(row.left()), row.bottom()));
let tabs_out = crate::tab_strip::pill_tabs(ui, ui.id().with((id, "tabs")), area, tabs, selected);
if !collapsed {
ui.add_space(6.0);
body(ui, *selected);
}
CardResponse { strip, menu, tab_double_clicked: double }
CardResponse { strip, menu, tab_double_clicked: tabs_out.double_clicked, tabs: tabs_out.tabs, chevron: tabs_out.chevron }
})
.inner;
ui.add_space(6.0);
@@ -91,34 +85,9 @@ fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, collaps
let strip_resp = ui.interact(strip, ui.id().with((id, "strip")), Sense::click_and_drag());
let rounding = if collapsed { CornerRadius::same(3) } else { CornerRadius { nw: 3, ne: 3, sw: 0, se: 0 } };
ui.painter().rect_filled(strip, rounding, t.tab_strip);
let mut x = strip.left();
let mut double = false;
for (i, name) in tabs.iter().enumerate() {
let galley = ui.painter().layout_no_wrap((*name).to_owned(), egui::FontId::proportional(11.5), t.text);
let r = Rect::from_min_size(pos2(x, strip.top()), vec2(galley.size().x + 22.0, strip.height()));
let resp = ui.interact(r, ui.id().with((id, "tab", i)), Sense::click());
let active = *selected == i && !collapsed;
if active {
ui.painter().rect_filled(r, CornerRadius { nw: if i == 0 { 3 } else { 0 }, ne: 0, sw: 0, se: 0 }, t.card);
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.4));
}
let color = if active {
t.text
} else if resp.hovered() {
t.text_dim
} else {
t.text_faint
};
ui.painter().galley_with_override_text_color(r.center() - galley.size() / 2.0, galley, color);
double |= resp.double_clicked();
if resp.clicked() {
*selected = i;
}
x = r.right();
}
// Panel menu (hamburger).
// Panel menu (hamburger); the tabs stay left of it, eliding or overflowing (#151).
let menu = Rect::from_center_size(pos2(strip.right() - 14.0, strip.center().y), vec2(20.0, 18.0));
let tabs_out = crate::tab_strip::pro_tabs(ui, ui.id().with((id, "tabs")), strip, menu.left(), tabs, selected, collapsed);
let mresp = ui.interact(menu, ui.id().with((id, "menu")), Sense::click());
let c = if mresp.hovered() { t.text } else { t.text_faint };
for k in 0..3 {
@@ -137,7 +106,7 @@ fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, collaps
});
}
ui.add_space(2.0);
CardResponse { strip: strip_resp, menu: mresp, tab_double_clicked: double }
CardResponse { strip: strip_resp, menu: mresp, tab_double_clicked: tabs_out.double_clicked, tabs: tabs_out.tabs, chevron: tabs_out.chevron }
}
pub fn pill_tab(ui: &mut Ui, label: &str, selected: bool) -> Response {
+53 -37
View File
@@ -30,42 +30,60 @@ readiness; treat it as an upper bound.
## Honest parity assessment (2026-10-05)
Written after the 0.2.0 release and the first wave of real user feedback. **We are far from real
Photoshop parity.** `parity.md` says 625/625 menu items are live, but that only means each item
dispatches *a* command. In the first day of public use, users hit broken basics that all counted as
"live": text selection offset from the cursor, shortcuts that didn't fire after clicking a panel
(214 failures in an audit), panels resizing themselves, an immovable crop frame, folders that
wouldn't collapse, and lag on layout-style PSDs.
This is the reference answer to "how close are we to Photoshop parity, really". Agents: read it
before picking work. Update it (with dated measurements) when the numbers move; don't restate the
menu-parity number in its place.
| Area | State |
|---|---|
| Core editing (layers, masks, selections, adjustments, filters, transforms, blend modes) | Broad coverage; engine quality decent; interaction/UI quality only started catching up after 0.2.0. |
| PSD fidelity | Composite oracle 113/170 (~66%). All adjustment layers and blend modes round-trip; Photoshop smart filters in PSDs, some effect shapes and text-engine details remain. psd-tools corpus being added (#81). |
| Tools | ~20 Photoshop tools missing entirely: Pencil, Mixer Brush (as a tool), Patch, Content-Aware Move, Red Eye, Pattern Stamp, Art History Brush, Freeform/Curvature Pen, anchor tools, Direct Selection, Magnetic Lasso, single row/column marquee, Color Sampler, Perspective Crop, Rotate View, vertical type and type masks, Frame. |
| Text | Engine works; advanced typography (OpenType features, text-on-path editing, full paragraph composer parity) and PSD text fidelity are partial. |
| Painting | Brush model close to Photoshop after #136/#174; no pen pressure on macOS/Linux (#79), no Mixer Brush tool, no Art History Brush. |
| AI / generative | ~0%: no Generative Fill/Expand (#41, deferred), Neural Filters or Remove tool. Classical Select Subject / Content-Aware Fill only. |
| Ecosystem | No .8BF plug-ins (by decision: sandboxed WebAssembly plug-ins instead), no ExtendScript/UXP/.atn actions, no Adobe Fonts / Libraries / cloud documents. |
| Camera RAW | DNG, CR2, Sony ARW (lossless + compressed), RW2, uncompressed ORF decode; Nikon compressed NEF, CR3, RAF fall back to the embedded preview (clean-room limits, #50). |
| Performance / robustness | Strong on large rasters (14k+ on the GPU, banded memory); layout-style documents were laggy (#125/#128 in progress); real-file testing has only just begun. |
**Bottom line.** Two days after 0.2.0 we had merged ~96 PRs and closed ~48 issues, but **real
Photoshop parity is still well below 50%**. The biggest gaps are AI, missing tools, professional
workflow depth and the plug-in ecosystem. Most fixes since 0.2.0 have passed our tests but have
**not yet been validated by users**, and this week proved our tests miss what users hit:
on first public use they found broken basics (text selection offset, 214 shortcut failures, panels
resizing themselves, an immovable crop frame, folders that wouldn't collapse, lag on layout PSDs)
that all counted as "live" in `parity.md`.
**Estimate.** Feature surface: roughly **55–70%** of what a typical Photoshop user touches exists in
some form. "A working professional could switch today": roughly **20–35%**; professionals live in
the details (exact tool behaviour, PSD fidelity on their own files, typography, AI, plug-ins).
True 1:1 parity is **many months** of focused work, and some areas need product decisions rather
than effort (generative AI backend, the 8BF ecosystem, clean-room RAW limits).
**Overall.** Feature surface: roughly **60–70%** of what a typical Photoshop user touches exists in
some form. "A professional could switch for daily work": roughly **25–35%**. True 1:1 parity is
**many months** of focused work, and some areas need product decisions rather than effort.
Confidence: moderate — the next users of 0.2.x will move these numbers either way.
**Making it measurable** (replace guesses with numbers; update this section as they land):
1. **Workflow acceptance tests:** 30–50 real tasks (e.g. retouch a portrait, build a social post
with text and effects, composite with masks and adjustment layers, prepare a CMYK print file),
each scripted end to end and checked against Photoshop's output, run on every build. The pass
rate becomes the headline parity number.
2. **Real-file corpus:** users' shareable PSDs plus the psd-tools set (#81), with pass-rate floors
that only go up (`crates/io/tests/corpus.rs`).
3. **Recurring visual QA:** screenshot sweeps of realistic documents (`cargo run -p photocraft-engine
--example designer_psd`). The first sweep found 14 defects (#147–#157) that unit tests missed.
4. **User reports:** fast turnaround from report → issue → fix; ask reporters for OS, document
size, layer count and a screenshot.
### By dimension
| Dimension | Measured / evidence (2026-10-05) | Grade | Notes |
|---|---|---|---|
| Menu wiring | 626/626 menu items dispatch a command (`parity.md`) | high but shallow | Says nothing about behaviour. |
| PSD fidelity (rendering) | Corpus oracle 115/170 (68%): 30 differ, 26 have no usable reference, 1 import error | medium | Push to 170/170 under way (effects/strokes, multi-instance effects, 16/32-bit and colour modes, references for skipped files). |
| PSD round trip | 169/169 re-import identically; every adjustment layer and blend mode round-trips | high (within corpus) | Floors in `crates/io/tests/corpus.rs`; raise, never lower. |
| Smart filters / text / effect shapes in PSDs | Not measured: the corpus barely contains them | unknown (probably low–medium) | A Photoshop-authored reference set is being built (`corpus/photoshop`). |
| Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. |
| UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157) | low–medium | Needs recurring visual QA with realistic documents. |
| Tools | ~20 Photoshop tools missing: Pencil, Mixer Brush (tool), Patch, Content-Aware Move, Red Eye, Pattern Stamp, Art History Brush, Freeform/Curvature Pen, anchor tools, Direct Selection, Magnetic Lasso, single row/column marquee, Color Sampler, Perspective Crop, Rotate View, vertical type and type masks, Frame | low–medium | Magic/Background Eraser added; live gradients in progress (#180). |
| Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pressure on Windows and web only | medium | macOS/Linux pressure blocked on the windowing layer (#79). |
| Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. |
| Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. |
| Performance | 14k+ px on the GPU at ~⅓ the memory; adjustment preview 285 ms → 4–9 ms; font-size edits 297 ms → 4.6 ms | medium-high on rasters | Complex layout documents still laggy (#125/#128); >16384 px GPU tiling in progress (#49). |
| Stability | Never-crash lint series, crash guard, `panic_hunt` fuzzing in the gate | medium-high | No field crash data yet. |
| Camera RAW | DNG, CR2, Sony ARW (lossless + compressed), RW2, uncompressed ORF | medium | Nikon compressed NEF, CR3, RAF blocked by clean-room limits (#50). |
| AI / generative | none | ~0% | Deferred by decision (#41). |
| Ecosystem | Sandboxed WebAssembly plug-ins instead of .8BF; no ExtendScript/UXP/.atn; no Adobe Fonts/Libraries/cloud docs | low | By design for 8BF; scripting compatibility open. |
| Platforms | macOS (notarized), Windows, Linux (AppImage/deb/rpm/Flatpak bundle), web | medium-high | Flathub later (#173); Windows signing material pending. |
### Where we're going (priority order)
1. **Ship the fixes:** cut 0.2.1 once the current batch lands, so users validate them.
2. **PSD fidelity to 170/170** with round trips, plus the Photoshop-authored reference set for smart
filters, the text engine and effect shapes; enforce floors in `corpus.rs`.
3. **Workflow acceptance tests:** 30–50 real tasks (retouch a portrait, social post with text and
effects, composite with masks and adjustment layers, CMYK print prep…) scripted end to end and
checked against Photoshop's output on every build. Their pass rate becomes the headline parity
number.
4. **Missing tools,** starting with the Pen variants and Direct Selection, Patch / Content-Aware
Move, Pencil, Rotate View, Perspective Crop.
5. **Complex-document performance** (#125/#128) and GPU tiling beyond the texture limit (#49).
6. **Recurring visual QA** (`cargo run -p photocraft-engine --example designer_psd`) and fast
turnaround on user reports (OS, document size, layer count, screenshot).
7. Later / needs decisions: generative AI backend (#41), scripting compatibility (ExtendScript /
UXP / .atn), Flathub (#173), macOS/Linux pen pressure (#79).
## Current focus (infrastructure before the long tail)
@@ -77,10 +95,8 @@ Landed on 2026-10-01:
(`docs/releasing.md`).
Next:
1. **Quality of what exists** (from user feedback after 0.2.0, see the assessment above): workflow
acceptance tests, real-file corpus, visual QA, and the open user issues. 0.2.0 shipped signed and
notarized on 2026-10-05 (`docs/releasing.md`); Windows code-signing material still needs to be
obtained.
1. **Follow "Where we're going" in the assessment above.** 0.2.0 shipped signed and notarized on
2026-10-05 (`docs/releasing.md`); Windows code-signing material still needs to be obtained.
2. **Fidelity**: the PSD oracle (113/170). Modern Brightness/Contrast and grayscale Levels
curves; chisel-soft / stroke-emboss bevel shapes; Photoshop's 8-bit blend rounding; non-Normal
modes in Lab documents; Photoshop smart filters in `SoLd`.