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
@@ -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;new file mode 100644@@ -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);+ }+ }+}@@ -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 respective@@ -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. |