collective

Browse / github.com/storytold/photocraft / patch

Toolbar: cycle non-Pen tool groups with Alt/Option-click (#8731)

Toolbar: cycle non-Pen tool groups with Alt/Option-click (#8731) Adds Alt-click (Windows/Linux) and Option-click (macOS) cycling for non-Pen groups in the main toolbar, addressing #8731. Each completed click selects the next visible flyout member and wraps: Brush → Pencil → Mixer Brush → Color Replacement → Brush. An inactive group starts from its remembered icon; hidden members are skipped and tools without a shortcut participate. The click uses the existing explicit tool-selection path, clears temporary overrides and keeps selected-tool persistence. Alt is latched on press. Holding/dragging retains the flyout gesture; a short modifier-click closes an o

Status
open
Score
0.00
Adopters
0 other operators
Author
chrisringrose (imported from pull request #9796, open)
Base
d42f9da94a421980d7130c63ca2953e47802cfbe
Size
+391 −6 in 4 files
Review
pending
Shared
2026-10-28
Id
1b6e6ca4c11c2d4a23dc18a3

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/panels/toolbar.rs crates/ui-egui/src/panels/toolbar/cycle_tests.rs docs/ui-design.md docs/ui-parity.md

diff --git a/crates/ui-egui/src/panels/toolbar.rs b/crates/ui-egui/src/panels/toolbar.rs--- a/crates/ui-egui/src/panels/toolbar.rs+++ b/crates/ui-egui/src/panels/toolbar.rs@@ -44,6 +44,17 @@ fn slot_tool(ui: &egui::Ui, current: Tool, slot: &[Tool], key: egui::Id) -> Tool     ui.data(|d| d.get_temp::<Tool>(key)).filter(|t| slot.contains(t)).unwrap_or(slot[0]) } +/// Advance in the visible flyout order, independently of shortcut-key groups.+fn next_toolbar_tool(slot: &[Tool], displayed: Tool) -> Option<Tool> {+    // Adobe documents three Pen exceptions but not their starting/destination semantics.+    // Preserve this slot until reference evidence resolves them (see docs/ui-design.md).+    if matches!(displayed, Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint) {+        return None;+    }+    let Some(index) = slot.iter().position(|tool| *tool == displayed) else { return slot.first().copied() };+    slot.get(index.saturating_add(1)).or_else(|| slot.first()).copied()+}+ /// [`TOOL_SECTIONS`] without the tools hidden in Edit › Toolbar (`hidden` holds tool names: the /// dialog writes `Tool` debug names, `edit.toolbar` takes any name `Tool::from_name` reads). A /// slot keeps only its visible tools, so its flyout lists just those; a slot with none left is@@ -120,8 +131,12 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {             ui.spacing_mut().item_spacing = vec2(2.0, 3.0);             let flyout_id = egui::Id::unique("tool-flyout");             let held_id = flyout_id.with("held");+            let alt_id = flyout_id.with("alt-press");             if ui.input(|i| i.pointer.any_pressed()) {-                ui.data_mut(|d| d.remove::<egui::Id>(held_id));+                ui.data_mut(|d| {+                    d.remove::<egui::Id>(held_id);+                    d.remove::<egui::Id>(alt_id);+                });             }             // No tool tooltips over an open flyout, or while a button is held to open one (#4450):             // Photoshop shows none there. (The flyout is our own Area, so egui's rule for popups@@ -167,16 +182,41 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {                                     if slot.len() > 1 {                                         paint_toolbar_flyout_indicator(ui, &t, &resp);                                     }-                                    if resp.clicked() && ui.data(|d| d.get_temp::<egui::Id>(held_id)) != Some(key) {-                                        crate::tool_memory::select(app, tool);+                                    // Latch the press event, not the release frame's modifiers. A hold or+                                    // drag still belongs to the flyout, whose held marker suppresses clicks.+                                    let alt_press = (resp.is_pointer_button_down_on() || resp.clicked())+                                        && ui.input(|i| {+                                            i.events.iter().any(|event| {+                                                matches!(event,+                                            egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers }+                                                if modifiers.alt && resp.rect.contains(*pos))+                                            })+                                        });+                                    if alt_press {+                                        ui.data_mut(|d| d.insert_temp(alt_id, key));+                                    }+                                    let click = resp.clicked() && ui.data(|d| d.get_temp::<egui::Id>(held_id)) != Some(key);+                                    let alt_click =+                                        click && ui.input(|i| i.pointer.primary_released()) && ui.data(|d| d.get_temp::<egui::Id>(alt_id)) == Some(key);+                                    if click {+                                        let chosen = if alt_click { next_toolbar_tool(slot, tool).unwrap_or(tool) } else { tool };+                                        crate::tool_memory::select(app, chosen);+                                        if alt_click {+                                            // Close before drawing any rows: this release makes one explicit+                                            // choice and cannot also select a stale flyout row.+                                            ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id));+                                        }+                                    }+                                    if ui.input(|i| i.pointer.primary_released()) && ui.data(|d| d.get_temp::<egui::Id>(alt_id)) == Some(key) {+                                        ui.data_mut(|d| d.remove::<egui::Id>(alt_id));                                     }                                     // Double-clicking the Hand tool fits the image on screen (Photoshop).-                                    if resp.double_clicked() && tool == Tool::Hand {+                                    if resp.double_clicked() && !alt_click && tool == Tool::Hand {                                         crate::tool_memory::select(app, tool);                                         // With no document open there is nothing to fit.                                         let _ = app.run("view.fitOnScreen", json!({}));                                     }-                                    if resp.double_clicked() && tool == Tool::Zoom {+                                    if resp.double_clicked() && !alt_click && tool == Tool::Zoom {                                         crate::tool_memory::select(app, tool);                                         let _ = app.run("view.actualPixels", json!({}));                                     }@@ -569,3 +609,6 @@ fn chip_command(app: &mut PhotocraftApp, id: &str) {         crate::type_tool::foreground_changed(app);     } }++#[cfg(test)]+mod cycle_tests;diff --git a/crates/ui-egui/src/panels/toolbar/cycle_tests.rs b/crates/ui-egui/src/panels/toolbar/cycle_tests.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/panels/toolbar/cycle_tests.rs@@ -0,0 +1,314 @@+//! Pointer regressions for the main toolbar, separate from shortcut-key groups.+use super::*;+use egui_kittest::{Harness, kittest::Queryable};++fn harness() -> Harness<'static, PhotocraftApp> {+    configured_harness(theme::ThemeKind::Studio, false, 1.0, false)+}++fn configured_harness(kind: theme::ThemeKind, double: bool, scale: f32, gpu: bool) -> Harness<'static, PhotocraftApp> {+    let mut ready = false;+    let mut builder = Harness::builder().with_size(vec2(1200.0, 1500.0)).with_pixels_per_point(scale).with_step_dt(1.0 / 60.0);+    if gpu {+        // Capture options-bar context as well as the toolbar. Its existing spin-button+        // naming is outside this gesture test; toolbar-only interaction fixtures keep+        // the accessibility checker enabled across every theme, layout and scale.+        builder = builder.wgpu().with_accessibility_check(false);+    }+    let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());+    app.ui.panels.toolbar_double = double;+    let mut h = builder.build_ui_state(+        move |ui, app: &mut PhotocraftApp| {+            if !ready {+                PhotocraftApp::setup_context(ui.ctx(), kind);+                ready = true;+                return;+            }+            if gpu {+                options_bar(app, ui);+            }+            toolbar(app, ui);+        },+        app,+    );+    h.run_steps(3);+    h+}++fn pointer(h: &mut Harness<'_, PhotocraftApp>, pos: Pos2, pressed: bool, modifiers: egui::Modifiers) {+    h.event(egui::Event::PointerMoved(pos));+    h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers });+    h.run_steps(1);+}++#[test]+fn alt_click_cycles_the_visible_group_and_wraps_once_per_click() {+    let mut h = harness();+    let at = h.get_by_label("Brush Tool  (B)").rect().center();+    for expected in [Tool::Pencil, Tool::MixerBrush, Tool::ColorReplacement, Tool::Brush, Tool::Pencil] {+        let before = h.state().ui.tool;+        pointer(&mut h, at, true, egui::Modifiers::ALT);+        assert_eq!(h.state().ui.tool, before, "selection waits for a completed click");+        pointer(&mut h, at, false, egui::Modifiers::ALT);+        assert_eq!(h.state().ui.tool, expected);+        h.run_steps(1);+        assert_eq!(h.state().ui.tool, expected, "the next frame cannot select the previous icon");+    }+}++#[test]+fn inactive_group_starts_from_its_remembered_icon() {+    let mut h = harness();+    crate::tool_memory::select(h.state_mut(), Tool::Pencil);+    h.run_steps(1);+    let at = h.get_by_label("Pencil Tool  (B)").rect().center();+    crate::tool_memory::select(h.state_mut(), Tool::Move);+    h.run_steps(1);+    pointer(&mut h, at, true, egui::Modifiers::ALT);+    pointer(&mut h, at, false, egui::Modifiers::ALT);+    assert_eq!(h.state().ui.tool, Tool::MixerBrush);+}++#[test]+fn marquee_cycle_includes_tools_without_shortcuts_and_ignores_rebindings() {+    let mut h = harness();+    h.state_mut().session.prefs.edit(|p| {+        p.shortcuts.insert("tools.select.ellipseMarquee".into(), "F6".into());+        p.shortcuts.insert("tools.select.rectMarquee".into(), String::new());+    });+    let at = h.get_by_label("Rectangular Marquee Tool  (M)").rect().center();+    for expected in [Tool::EllipseMarquee, Tool::SingleRowMarquee, Tool::SingleColumnMarquee, Tool::RectMarquee] {+        pointer(&mut h, at, true, egui::Modifiers::ALT);+        pointer(&mut h, at, false, egui::Modifiers::ALT);+        assert_eq!(h.state().ui.tool, expected);+    }+}++#[test]+fn hidden_members_singletons_and_stale_memory_follow_the_visible_slot() {+    let mut h = harness();+    crate::tool_memory::select(h.state_mut(), Tool::Pencil);+    h.run_steps(1);+    crate::tool_memory::select(h.state_mut(), Tool::Move);+    h.state_mut().session.prefs.edit(|p| p.toolbar.hidden = vec!["Pencil".into(), "MixerBrush".into()]);+    h.run_steps(1);+    let at = h.get_by_label("Brush Tool  (B)").rect().center();+    pointer(&mut h, at, true, egui::Modifiers::ALT);+    pointer(&mut h, at, false, egui::Modifiers::ALT);+    assert_eq!(h.state().ui.tool, Tool::ColorReplacement, "hidden remembered Pencil falls back to Brush, then advances");+    h.state_mut().session.prefs.edit(|p| p.toolbar.hidden.push("ColorReplacement".into()));+    h.run_steps(1);+    let at = h.get_by_label("Brush Tool  (B)").rect().center();+    pointer(&mut h, at, true, egui::Modifiers::ALT);+    pointer(&mut h, at, false, egui::Modifiers::ALT);+    assert_eq!(h.state().ui.tool, Tool::Brush);+    h.state_mut().session.prefs.edit(|p| p.toolbar.hidden.push("Brush".into()));+    h.run_steps(1);+    assert!(h.query_by_label("Brush Tool  (B)").is_none());+}++#[test]+fn alt_is_latched_at_press_and_keyboard_activation_is_plain_selection() {+    let mut h = harness();+    let at = h.get_by_label("Brush Tool  (B)").rect().center();+    pointer(&mut h, at, true, egui::Modifiers::ALT);+    h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));+    h.run_steps(1);+    pointer(&mut h, at, false, egui::Modifiers::NONE);+    assert_eq!(h.state().ui.tool, Tool::Pencil);+    pointer(&mut h, at, true, egui::Modifiers::NONE);+    h.event(egui::Event::ModifiersChanged(egui::Modifiers::ALT));+    h.run_steps(1);+    pointer(&mut h, at, false, egui::Modifiers::ALT);+    assert_eq!(h.state().ui.tool, Tool::Pencil, "adding Alt after press cannot turn a normal click into cycling");+    h.get_by_label("Pencil Tool  (B)").focus();+    h.key_press(egui::Key::Space);+    h.run_steps(2);+    assert_eq!(h.state().ui.tool, Tool::Pencil);+}++#[test]+fn a_same_frame_click_uses_its_press_modifiers() {+    let mut h = harness();+    let at = h.get_by_label("Brush Tool  (B)").rect().center();+    h.input_mut().events.extend([+        egui::Event::PointerMoved(at),+        egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::ALT },+        egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE },+    ]);+    h.run_steps(1);+    assert_eq!(h.state().ui.tool, Tool::Pencil);+}++fn flyout_open(h: &Harness<'_, PhotocraftApp>) -> bool {+    h.ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::unique("tool-flyout"))).is_some()+}++#[test]+fn alt_hold_and_drag_keep_the_existing_flyout_gesture() {+    for drag in [false, true] {+        let mut h = harness();+        let at = h.get_by_label("Brush Tool  (B)").rect().center();+        pointer(&mut h, at, true, egui::Modifiers::ALT);+        if drag {+            h.event(egui::Event::PointerMoved(at + vec2(100.0, 0.0)));+            h.run_steps(2);+        } else {+            h.run_steps(24);+        }+        assert!(flyout_open(&h));+        pointer(&mut h, at, false, egui::Modifiers::ALT);+        assert_eq!(h.state().ui.tool, Tool::Brush, "a flyout-owned release must not cycle");+        assert!(flyout_open(&h));+    }+}++#[test]+fn alt_click_closes_an_open_flyout_and_cycles_only_once() {+    for other in [false, true] {+        let mut h = harness();+        let brush = h.get_by_label("Brush Tool  (B)").rect().center();+        let at = if other { h.get_by_label("Rectangular Marquee Tool  (M)").rect().center() } else { brush };+        h.event(egui::Event::PointerMoved(at));+        h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: true, modifiers: egui::Modifiers::NONE });+        h.run_steps(1);+        h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: false, modifiers: egui::Modifiers::NONE });+        h.run_steps(3);+        assert!(flyout_open(&h));+        pointer(&mut h, brush, true, egui::Modifiers::ALT);+        pointer(&mut h, brush, false, egui::Modifiers::ALT);+        assert_eq!(h.state().ui.tool, Tool::Pencil);+        assert!(!flyout_open(&h));+    }+}++#[test]+fn explicit_cycle_clears_temporary_tools_and_persists_without_document_changes() {+    for stylus in [false, true] {+        let mut h = harness();+        h.state_mut().run("file.new", json!({"width": 8, "height": 8})).unwrap();+        let original = h.state().session.active().unwrap().clone();+        let options = h.state().ui.tool_options.clone();+        if stylus {+            h.state_mut().ui.tool = Tool::Eraser;+            h.state_mut().stylus.tool_before_eraser = Some(Tool::Move);+        } else {+            crate::spring_tools::pressed(h.state_mut(), 'B', Tool::Move, true, 0.0);+            assert!(h.state().ui.spring.is_some());+        }+        let at = h.get_by_label("Brush Tool  (B)").rect().center();+        pointer(&mut h, at, true, egui::Modifiers::ALT);+        pointer(&mut h, at, false, egui::Modifiers::ALT);+        assert_eq!(h.state().ui.tool, Tool::Pencil);+        assert!(h.state().ui.spring.is_none());+        assert!(h.state().stylus.tool_before_eraser.is_none());+        crate::tool_memory::persist(h.state_mut(), &egui::Context::default());+        let prefs = h.state().session.prefs().clone();+        let doc = h.state().session.active().unwrap();+        assert!(std::sync::Arc::ptr_eq(&original.doc, &doc.doc));+        assert_eq!(doc.revision, original.revision);+        assert_eq!(doc.history.entries(), original.history.entries());+        assert_eq!(doc.history.past_len(), original.history.past_len());+        assert_eq!(doc.history.redo_labels().collect::<Vec<_>>(), original.history.redo_labels().collect::<Vec<_>>());+        assert_eq!(h.state().ui.tool_options, options);+        let mut restored = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());+        restored.session.prefs.edit(|p| {+            p.selected_tool = prefs.selected_tool;+            p.tool_options = prefs.tool_options;+        });+        crate::tool_memory::restore(&mut restored);+        assert_eq!(restored.ui.tool, Tool::Pencil);+        assert_eq!(restored.ui.tool_options, options);+    }+}++#[test]+fn pen_group_is_preserved_pending_reference_evidence_for_the_exceptions() {+    // Adobe lists three exceptions without saying whether they are skipped or suppress the+    // gesture from that starting tool. Do not encode an unverified interpretation as parity.+    for tool in [Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::AddAnchorPoint, Tool::DeleteAnchorPoint, Tool::ConvertPoint] {+        let mut h = harness();+        crate::tool_memory::select(h.state_mut(), tool);+        h.run_steps(1);+        let label = match crate::tool_shortcuts::shortcut(h.state(), tool) {+            Some(sc) => format!("{}  ({})", tool.label(), crate::shortcuts::pretty(&sc)),+            None => tool.label().to_string(),+        };+        let at = h.get_by_label(&label).rect().center();+        pointer(&mut h, at, true, egui::Modifiers::ALT);+        pointer(&mut h, at, false, egui::Modifiers::ALT);+        assert_eq!(h.state().ui.tool, tool);+    }+}++#[test]+fn cycling_uses_accessible_buttons_in_both_layouts_themes_scales_and_directions() {+    for kind in theme::ThemeKind::ALL {+        for double in [false, true] {+            for scale in [1.0, 1.5, 2.0] {+                for dir in [crate::direction::Dir::Ltr, crate::direction::Dir::Rtl] {+                    let _direction = crate::direction::direction_scope(dir);+                    let mut h = configured_harness(kind, double, scale, false);+                    let at = h.get_by_label("Brush Tool  (B)").rect().center();+                    pointer(&mut h, at, true, egui::Modifiers::ALT);+                    pointer(&mut h, at, false, egui::Modifiers::ALT);+                    assert_eq!(h.state().ui.tool, Tool::Pencil, "{kind:?}, double={double}, scale={scale}, {dir:?}");+                    h.run_steps(1); // The release frame painted the previous icon before selecting the next tool.+                    let rect = h.get_by_label("Pencil Tool  (B)").rect();+                    assert!(rect.contains(at), "cycling must keep its button in place");+                    assert!(rect.left() >= 0.0 && rect.right() <= 1200.0 && rect.top() >= 0.0 && rect.bottom() <= 1500.0);+                }+            }+        }+    }+}++/// Optional real native-wgpu evidence from the same pointer path as the ordinary regressions.+#[test]+fn gpu_capture_toolbar_cycle() {+    let Some(directory) = std::env::var_os("PHOTOCRAFT_TOOLBAR_CYCLE_SNAPSHOTS").map(std::path::PathBuf::from) else { return };+    let _gpu = photocraft_testkit::gpu_lock();+    std::fs::create_dir_all(&directory).unwrap();+    for kind in theme::ThemeKind::ALL {+        for double in [false, true] {+            for scale in [1.0, 1.5, 2.0] {+                let mut h = configured_harness(kind, double, scale, true);+                let at = h.get_by_label("Brush Tool  (B)").rect().center();+                let name = format!("{}-{}col-{scale}x", kind.id(), if double { 2 } else { 1 });+                h.render().unwrap().save(directory.join(format!("{name}-before.png"))).unwrap();+                pointer(&mut h, at, true, egui::Modifiers::ALT);+                pointer(&mut h, at, false, egui::Modifiers::ALT);+                assert_eq!(h.state().ui.tool, Tool::Pencil);+                h.run_steps(1);+                let image = h.render().unwrap();+                assert_eq!((image.width(), image.height()), ((1200.0 * scale) as u32, (1500.0 * scale) as u32));+                image.save(directory.join(format!("{name}-after.png"))).unwrap();+            }+        }+    }+}++#[test]+fn next_tool_handles_empty_and_stale_groups_without_indexing() {+    assert_eq!(next_toolbar_tool(&[], Tool::Brush), None);+    assert_eq!(next_toolbar_tool(&[Tool::Brush], Tool::Move), Some(Tool::Brush));+    assert_eq!(next_toolbar_tool(&[Tool::Brush], Tool::Brush), Some(Tool::Brush));+}++#[test]+fn rapid_alt_clicks_on_hand_and_a_singleton_do_not_run_double_click_view_actions() {+    let mut h = harness();+    h.state_mut().run("file.new", json!({"width": 8, "height": 8})).unwrap();+    h.state_mut().ui.views[0].fit_pending = false;+    h.state_mut().ui.views[0].zoom = 0.5;+    for (label, expected) in [("Hand Tool  (H)", vec![Tool::RotateView, Tool::Hand, Tool::RotateView]), ("Zoom Tool  (Z)", vec![Tool::Zoom; 3])] {+        let at = h.get_by_label(label).rect().center();+        for tool in expected {+            pointer(&mut h, at, true, egui::Modifiers::ALT);+            pointer(&mut h, at, false, egui::Modifiers::ALT);+            assert_eq!(h.state().ui.tool, tool);+            assert!(!h.state().ui.views[0].fit_pending);+            assert_eq!(h.state().ui.views[0].zoom, 0.5);+        }+    }+}diff --git a/docs/ui-design.md b/docs/ui-design.md--- a/docs/ui-design.md+++ b/docs/ui-design.md@@ -121,6 +121,32 @@ then send normal Tab and Space events through the app's shortcut dispatcher. The focus traversal and activation with that nondefault binding; default canvas Tab still toggles panels. These offscreen checks do not establish native window or platform input acceptance. +### Main-toolbar Alt/Option-click (2026-10-26, #8731)++A completed short Alt-click (Windows/Linux) or Option-click (macOS) advances in the+clicked slot's visible flyout order and wraps. An inactive slot starts from its displayed,+remembered member. Edit Toolbar's hidden members are omitted; a single visible member+selects itself. This is independent of shortcut assignments and includes tools without a+letter shortcut. The selection uses the same explicit path as ordinary toolbar and+`tools.select.*` choices, so temporary overrides are cleared and existing selected-tool+and tool-option persistence apply. No document or history entry changes.++PhotoCraft latches Alt/Option from the primary press and selects on release. Adding the+modifier after pressing does not cycle; releasing it before the button still does. A hold+or drag-away retains the existing flyout behavior and its concluding release does not+cycle. A short modifier-click closes an open flyout and makes only the cycling choice.+Keyboard activation remains ordinary selection; rapid modifier-clicks do not invoke the+Hand/Zoom double-click view actions.++Reference boundary: Adobe's [CS6 manual](https://helpx.adobe.com/pdf/cs6/photoshop_reference.pdf),+"Keys for selecting tools", documents the gesture with Add Anchor Point, Delete Anchor Point+and Convert Point exceptions. It does not establish whether those tools are skipped or+suppress cycling from particular displayed members. **The entire Pen slot retains its+existing click behavior pending that evidence.** This is a deferred part of #8731, not a+claim about Photoshop's Pen-group behavior. No current Photoshop build was available;+modifier timing and hold/drag arbitration above follow PhotoCraft conventions and remain+subject to reference-app comparison.+ ## Menus  Horizontal and vertical ruler ticks, labels and pointer markers are clipped to their respectivediff --git a/docs/ui-parity.md b/docs/ui-parity.md--- a/docs/ui-parity.md+++ b/docs/ui-parity.md@@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (12 of 178 preferences do nothing, from the scorecard; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Alt/Option-click cycles visible main-toolbar groups except the deferred Pen group, #8731; 12 of 178 preferences do nothing, from the scorecard; Art History Brush in the History group of the Y flyout, partial, TOOL-213-20, #1610; Histogram panel: Compact / Expanded / All Channels views, Luminosity and Colors channels, Source, range statistics, Uncached Refresh, live adjustment overlay, Cache Levels preference; the font menu opens on the current family, marked in the recent group when it is a recent one, #9160; Type options bar alignment and direction follow the target paragraph; a swatch click recolours selected type and shape layers with any tool, #9238; UI scale presets done: 105–120% steps, #3222; ⇧⌥ blend mode letters; painting tool Mode menus; ⌥ + an adjustment's shortcut reopens it at its last settings, ⌥⌘L / ⌥⌘M, #8900; Crop default mode: moving the image under the box snaps the frame's edges and centre, #8580; View › Extras hides the Move transform controls, #6699; adjustment clipping stays visible in compact Properties, #5231; Color panel modes from its panel menu, the same in every theme, UI-217-17, #6000; a picked colour recolours the selected type and shapes; Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests; Properties › Transform shows a shape's path geometry, without its stroke) · **Target:** Adobe Photoshop 2026  How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation,@@ -52,6 +52,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Letter shortcuts, ⇧+letter cycles the group, "Use Shift Key for Tool Switch" (on by default, as in Photoshop) | done | `shortcuts.rs:413` | | Group remembers the last tool used | partial | L always resets to Lasso (#2608) | | Press-and-hold or click-drag opens the flyout | missing | right-click only (#2301) |+| Alt/Option-click cycles the clicked visible toolbar group and wraps, starting from its displayed/remembered member | partial (2026-10-26) | `panels/toolbar.rs`, `panels/toolbar/cycle_tests.rs`; #8731. Hidden members omitted; tools without letter shortcuts included. Pen group deferred: Adobe names three exceptions but their precise behavior has not been verified. Press-time modifiers and existing hold/drag arbitration follow PhotoCraft conventions. No current Photoshop observation or score change. | | Edit Toolbar customization | done | `edit.toolbar` | | Last selected tool restored after quitting and relaunching; temporary tool switches excluded | done | `tool_memory.rs`; `prefs_ui::tests::selected_tool_survives_a_restart_without_option_changes`, `selected_tool_excludes_temporary_switches` | @@ -246,6 +247,7 @@ Paragraph; the rest open as in-app floating windows.  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Alt/Option-click cycles visible main-toolbar groups (#8731), with remembered starting tool, wrap, hidden-member filtering and pointer regressions; Pen group deferred pending precise exception evidence. Scores unchanged. | | 2026-10-25 | minor | Every preference takes effect: 12 of 178 do nothing, the scorecard's count (it still said 46 of 149) | | 2026-10-25 | minor | Art History Brush is in the History group of the Y flyout (partial, TOOL-213-20, #1610) | | 2026-10-24 | minor | Swatches: a click recolours the selected type layers and shape fills whatever the tool, as Photoshop 2025 does (measured with the Move and Brush tools); the Color panel still recolours them only with a Type, shape or Pen tool (#9238). Scores unchanged. |