Liquify: no Twirl Counterclockwise tool, Alt with Twirl Clockwise does it

The reference application's Liquify has Twirl Clockwise only; holding
Alt (Option on macOS) while using it twirls counterclockwise. PhotoCraft
had an extra Twirl Counterclockwise tool on the strip, and #9598 had just
given it a mirrored icon.

Twirl Counterclockwise leaves the tool strip (LiquifyTool::ALL, 11 to
10 tools) and the rotate-ccw icon #9598 added goes with it (asset,
icon_data entry, ATTRIBUTION count back to 113). Alt already reversed
Twirl Clockwise at stroke-down; that stays. The TwirlCcw variant stays as
a stroke's direction: Alt-strokes record `twirlCcw`, and filter.liquify
strokes and older saved strokes naming it still replay as Twirl
Clockwise reversed (the command doc says so). Selecting it as the tool
(control channel `tool: "twirlCcw"`, or a preference saved by an older
version) is refused with an error that says to choose twirlCw and hold
Alt; the rest of the settings still apply and the dialog opens.

Tests: the_tool_strip_has_no_twirl_counterclockwise (no such tool, label
or missing icon on the strip, C is Twirl Clockwise, `twirlCcw` as the
tool is an Err and leaves the tool, an old saved preference naming it
opens); alt_with_twirl_clockwise_twirls_counterclockwise (the field
displacement a dab leaves right of the centre points up without Alt and
down, mirrored, with Alt). twirl_counterclockwise_has_its_own_mirrored_icon
and the twirlCcw row of alt_reverses_twirl_and_swaps_pucker_and_bloat
are removed. The i18n labels for Twirl Counterclockwise stay (the
variant's label).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Mann
2026-10-30 16:14:26 +01:00
parent b9613c6caa
commit d9cb0edd8d
7 changed files with 69 additions and 36 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ next to them.
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (114 files; `star-filled.svg` is `star.svg` with a solid fill, `rotate-ccw.svg` is `rotate-cw.svg` mirrored) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
-14
View File
@@ -1,14 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 12a9 9 0 1 0 9-9c-2.52 0-4.93 1-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>

Before

Width:  |  Height:  |  Size: 296 B

+6 -2
View File
@@ -33,6 +33,9 @@ pub enum LiquifyTool {
Reconstruct,
Smooth,
TwirlCw,
/// Twirl Clockwise with Alt (Option) held: a stroke's direction, not a tool of its own. The
/// reference application's Liquify has Twirl Clockwise only; strokes may still name
/// `twirlCcw` (Alt-strokes record it, and older scripts and saved strokes use it).
TwirlCcw,
Pucker,
Bloat,
@@ -49,12 +52,13 @@ pub enum LiquifyTool {
}
impl LiquifyTool {
pub const ALL: [LiquifyTool; 11] = [
/// The tool strip, in order. Twirl Counterclockwise is not on it: it is Twirl Clockwise with
/// Alt (Option) held.
pub const ALL: [LiquifyTool; 10] = [
LiquifyTool::ForwardWarp,
LiquifyTool::Reconstruct,
LiquifyTool::Smooth,
LiquifyTool::TwirlCw,
LiquifyTool::TwirlCcw,
LiquifyTool::Pucker,
LiquifyTool::Bloat,
LiquifyTool::PushLeft,
+1 -1
View File
@@ -381,7 +381,7 @@ fn perspective(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
}
pub fn specs() -> Vec<CommandSpec> {
const L: &str = r##"{"strokes":[{"tool":"forwardWarp|reconstruct|smooth|twirlCw|twirlCcw|pucker|bloat|pushLeft|freeze|thaw|lassoMask|reconstructAll","size":px=100,"density":0-100=50,"pressure":0-100=100,"rate":0-100=80,"points":[[x,y,pressure?]…],"amount":%? (reconstructAll; lassoMask: 1 freezes the polygon in points, 0 thaws it)}…],"meshSize":px? (field resolution, px per node; default 2, or 4 above 4 MP),"layer":id?} — strokes replay in order on a fresh field; a selection limits the effect; on a smart object it becomes a smart filter"##;
const L: &str = r##"{"strokes":[{"tool":"forwardWarp|reconstruct|smooth|twirlCw|twirlCcw|pucker|bloat|pushLeft|freeze|thaw|lassoMask|reconstructAll","size":px=100,"density":0-100=50,"pressure":0-100=100,"rate":0-100=80,"points":[[x,y,pressure?]…],"amount":%? (twirlCcw: twirlCw reversed, what an Alt-drag with Twirl Clockwise records; reconstructAll; lassoMask: 1 freezes the polygon in points, 0 thaws it)}…],"meshSize":px? (field resolution, px per node; default 2, or 4 above 4 MP),"layer":id?} — strokes replay in order on a fresh field; a selection limits the effect; on a smart object it becomes a smart filter"##;
const P: &str = r##"{"pins":[{"src":[x,y],"dst":[x,y],"rotate":deg?,"depth":n?}…],"mode":"rigid|normal|distort"="normal","density":"fewer|normal|more"="normal","expansion":px=2,"interpolation":"bicubic|bilinear|nearest","layer":id?} — mesh over the opaque region, as-rigid-as-possible; on a smart object it becomes a smart filter"##;
const Q: &str = r##"{"planes":[{"src":[[x,y]×4],"dst":[[x,y]×4]}…] (corners clockwise from top-left; corners that coincide in src are linked),"straighten":"horizontal|vertical|auto"?,"interpolation":"bicubic|bilinear|nearest","layer":id?}"##;
vec![
+9 -5
View File
@@ -127,7 +127,12 @@ impl LiquifyOpts {
};
}
if let Some(t) = v.get("tool") {
self.tool = serde_json::from_value(t.clone()).map_err(|e| format!("bad tool: {e}"))?;
let tool: LiquifyTool = serde_json::from_value(t.clone()).map_err(|e| format!("bad tool: {e}"))?;
// Strokes can twirl counterclockwise, but no tool does: hold Alt with Twirl Clockwise.
if !LiquifyTool::ALL.contains(&tool) {
return Err(format!("{t} is not a Liquify tool: choose twirlCw and hold Alt (Option) to twirl counterclockwise"));
}
self.tool = tool;
}
Ok(())
}
@@ -653,8 +658,9 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result<Value, String> {
Ok(d.describe())
}
/// Photoshop's Alt (Option) variant of a Liquify brush: the twirls reverse direction and Pucker
/// and Bloat swap. Other tools are unchanged.
/// The Alt (Option) variant of a Liquify brush: Twirl Clockwise twirls counterclockwise (the
/// reference application has no separate counterclockwise tool) and Pucker and Bloat swap.
/// Other tools are unchanged.
fn alt_tool(t: LiquifyTool) -> LiquifyTool {
match t {
LiquifyTool::TwirlCw => LiquifyTool::TwirlCcw,
@@ -794,8 +800,6 @@ fn tool_icon(t: LiquifyTool) -> &'static str {
LiquifyTool::Reconstruct => "brush-cleaning",
LiquifyTool::Smooth => "droplet",
LiquifyTool::TwirlCw => "rotate-cw",
// Twirl Clockwise's arrow mirrored: `undo-2` read as Undo (#9547).
LiquifyTool::TwirlCcw => "rotate-ccw",
LiquifyTool::Pucker => "scan",
LiquifyTool::Bloat => "maximize-2",
LiquifyTool::PushLeft => "chevrons-left",
+52 -12
View File
@@ -154,21 +154,62 @@ fn brush_settings_are_remembered_between_uses() {
assert!(open(&mut app, &ctx).is_ok());
}
/// Follow-up to #1494 / #1484: Alt is captured at stroke-down and stays recorded
/// through undo, redo and deterministic Liquify field reconstruction.
/// #9547: Twirl Counterclockwise draws Twirl Clockwise's arrow mirrored, not the Undo arrow,
/// and every Liquify tool's icon exists.
/// The reference application's Liquify has Twirl Clockwise only: no Twirl Counterclockwise
/// tool on the strip or a shortcut, and a script or saved setting naming it is refused with a
/// clear error instead of selecting it (Alt with Twirl Clockwise twirls counterclockwise).
#[test]
fn twirl_counterclockwise_has_its_own_mirrored_icon() {
assert_eq!(tool_icon(LiquifyTool::TwirlCcw), "rotate-ccw");
let svg = |name: &str| crate::icon_data::ICONS.iter().find(|(n, _)| *n == name).map(|(_, b)| String::from_utf8_lossy(b).into_owned());
let (cw, ccw) = (svg("rotate-cw").unwrap(), svg("rotate-ccw").expect("the rotate-ccw icon"));
assert!(ccw.contains("M3 12a9 9 0 1 0 9-9") && cw.contains("M21 12a9 9 0 1 1-9-9"), "mirror images");
for t in [LiquifyTool::ForwardWarp, LiquifyTool::TwirlCw, LiquifyTool::TwirlCcw, LiquifyTool::Pucker, LiquifyTool::Bloat] {
assert!(svg(tool_icon(t)).is_some(), "{t:?}: {}", tool_icon(t));
fn the_tool_strip_has_no_twirl_counterclockwise() {
assert!(!LiquifyTool::ALL.contains(&LiquifyTool::TwirlCcw));
assert!(LiquifyTool::ALL.contains(&LiquifyTool::TwirlCw));
for t in LiquifyTool::ALL {
assert_ne!(tl!(t.label()), tl!("Twirl Counterclockwise"), "{t:?}");
let svg = crate::icon_data::ICONS.iter().any(|(n, _)| *n == tool_icon(t));
assert!(svg, "{t:?}: {}", tool_icon(t));
}
assert_eq!(shortcut(LiquifyTool::TwirlCw), "C");
let ctx = egui::Context::default();
let mut app = app_with_layer();
open(&mut app, &ctx).unwrap();
control(&mut app, &json!({"tool": "pucker"})).unwrap();
let err = control(&mut app, &json!({"tool": "twirlCcw"})).unwrap_err();
assert!(err.contains("twirlCw") && err.contains("Alt"), "{err}");
assert_eq!(app.distort.liquify.as_ref().unwrap().opts.tool, LiquifyTool::Pucker, "the tool stays");
// A preference saved by an older version that names it opens, and on another tool.
cancel(&mut app);
app.session.prefs.edit(|p| p.dialogs.insert(REMEMBERED.into(), json!({"tool": "twirlCcw", "density": 70})));
open(&mut app, &ctx).unwrap();
let o = &app.distort.liquify.as_ref().unwrap().opts;
assert_ne!(o.tool, LiquifyTool::TwirlCcw);
assert_eq!(o.density, 70.0, "the rest of the saved settings still apply");
}
/// Alt held with Twirl Clockwise twirls the other way: at the same point, the displacement a
/// dab leaves points the opposite way from one without Alt.
#[test]
fn alt_with_twirl_clockwise_twirls_counterclockwise() {
let ctx = egui::Context::default();
let displacement = |mods: egui::Modifiers| {
let mut app = app_with_layer();
open(&mut app, &ctx).unwrap();
control(&mut app, &json!({"tool": "twirlCw", "size": 40, "rate": 80})).unwrap();
pointer(&mut app, ToolEvent::Down { x: 60.0, y: 40.0, pressure: 1.0 }, mods);
for _ in 0..4 {
pointer(&mut app, ToolEvent::Move { x: 60.0, y: 40.0, pressure: 1.0 }, mods);
}
pointer(&mut app, ToolEvent::Up { x: 60.0, y: 40.0 }, mods);
let d = app.distort.liquify.as_ref().unwrap();
assert_eq!(d.opts.tool, LiquifyTool::TwirlCw);
// Right of the centre: clockwise (y down) moves content down, so it samples from above.
d.field.sample(70.0, 40.0)
};
let (plain, alt) = (displacement(egui::Modifiers::NONE), displacement(egui::Modifiers::ALT));
assert!(plain[1] < -0.1, "clockwise samples from above: {plain:?}");
assert!(alt[1] > 0.1, "Alt twirls counterclockwise and samples from below: {alt:?}");
assert!((plain[1] + alt[1]).abs() < 0.05 * plain[1].abs(), "the same twirl, mirrored: {plain:?} vs {alt:?}");
}
/// Follow-up to #1494 / #1484: Alt is captured at stroke-down and stays recorded
/// through undo, redo and deterministic Liquify field reconstruction.
#[test]
fn alt_reversed_twirl_survives_replay_undo_and_redo() {
let ctx = egui::Context::default();
@@ -374,7 +415,6 @@ fn alt_reverses_twirl_and_swaps_pucker_and_bloat() {
};
for (tool, plain, alt) in [
("twirlCw", LiquifyTool::TwirlCw, LiquifyTool::TwirlCcw),
("twirlCcw", LiquifyTool::TwirlCcw, LiquifyTool::TwirlCw),
("pucker", LiquifyTool::Pucker, LiquifyTool::Bloat),
("bloat", LiquifyTool::Bloat, LiquifyTool::Pucker),
("forwardWarp", LiquifyTool::ForwardWarp, LiquifyTool::ForwardWarp),
-1
View File
@@ -127,7 +127,6 @@ pub static ICONS: &[(&str, &[u8])] = &[
("rectangle-horizontal", include_bytes!("../../../assets/icons/rectangle-horizontal.svg")),
("rectangle-vertical", include_bytes!("../../../assets/icons/rectangle-vertical.svg")),
("redo-2", include_bytes!("../../../assets/icons/redo-2.svg")),
("rotate-ccw", include_bytes!("../../../assets/icons/rotate-ccw.svg")),
("rotate-cw", include_bytes!("../../../assets/icons/rotate-cw.svg")),
("ruler", include_bytes!("../../../assets/icons/ruler.svg")),
("sampler", include_bytes!("../../../assets/icons/sampler.svg")),