collective

Browse / github.com/storytold/photocraft / patch

fix: dismiss context menu without triggering tool action on outside click

Add a right-click zoom context menu to the Zoom Tool In PhotoCraft 0.16 on Ubuntu 22.04, right-clicking with the Zoom Tool selected zooms further into the image instead of opening a context menu. The Hand Tool correctly displays a context menu, and Ctrl+0 successfully fits the image to the screen. Suggested improvement: Allow right-clicking with the Zoom Tool to open a context menu offering: * Fit on Screen * 100% Zoom * Zoom In * Zoom Out This would provide a more intuitive workflow for users accustomed to Adobe Photoshop, where these options are directly accessible from the Zoom Tool.

Status
open
Score
0.00
Adopters
0 other operators
Author
Kaedo17 (imported from pull request #9841, open)
Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035
Size
+115 −40 in 3 files
Review
pending
Shared
2026-10-28
Id
57d5dcf5bbb571d1cb037393

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/canvas.rs crates/ui-egui/src/canvas/canvas_tool_menu.rs crates/ui-egui/src/state.rs

diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs--- a/crates/ui-egui/src/canvas.rs+++ b/crates/ui-egui/src/canvas.rs@@ -1156,49 +1156,107 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:         if buttons.clicked             && let Some(p) = response.interact_pointer_pos()         {-            let click_button = if app.secondary_erase { PointerButton::Secondary } else { PointerButton::Primary };-            let click_mods = ui.input(|i| pointer_button_modifiers(&i.events, click_button)).unwrap_or(mods);-            let click_mods = if tool == Tool::ObjectSelection && click_button == PointerButton::Primary {-                ctx.data(|d| d.get_temp(tool_press)).unwrap_or(click_mods)-            } else {-                click_mods-            };-            let d = xf.to_doc(p);-            match tool {-                Tool::Zoom if !transform_owns_pointer => {-                    let nz = crate::zoom_levels::step(view.zoom, if zoom_out(click_mods.alt) { -1 } else { 1 }, view.doc_size);-                    let center = app.session.prefs().tools.zoom_clicked_point_to_center;-                    let doc_anchor = xf.to_doc(p);-                    let screen_anchor = p;-                    zoom_about(&mut view, &xf, p, nz, center, ppp);-                    view.request_zoom_animation(app.session.prefs().general.animated_zoom && nz != display_view.zoom);-                    view.zoom_anchor = (view.zoom_animating && !center).then_some((doc_anchor, [screen_anchor.x, screen_anchor.y]));-                }-                // A click with the (temporary) Hand does nothing, never the tool underneath.-                Tool::Hand | Tool::RotateView => {}-                // Double-clicking closes the polygonal lasso: the first click placed the last-                // vertex (or already closed it), so the second never starts a new polygon. egui-                // reports it as a triple click when a vertex went down shortly before.-                Tool::PolygonLasso if response.double_clicked() || response.triple_clicked() => commit_polygon(app),-                // The same for the Magnetic Lasso: along the edges, or straight with ⌥.-                Tool::MagneticLasso if response.double_clicked() || response.triple_clicked() => crate::magnetic_lasso_ui::close(app, mods.alt),-                // The first click placed (or grabbed) the point; the second toggles it.-                Tool::CurvaturePen if response.double_clicked() => crate::vector_ui::curvature_toggle(app, d[0], d[1]),-                // Double-clicking a guide opens Preferences › Guides, Grid & Slices, as in Photoshop.-                Tool::Move if response.double_clicked() && crate::rulers::guide_at(app, d[0], d[1]).is_some() => {-                    if let Err(e) = crate::menus::invoke(app, &ctx, "edit.preferences.guidesGridAndSlices", json!({})) {-                        app.ui.status = e;+            // If an outside press dismissed the canvas tool menu, consume this release click+            // so it doesn't also trigger a tool action (Photoshop behavior).+            if app.ui.canvas_tool_menu_dismissed {+                app.ui.canvas_tool_menu_dismissed = false;+            } else if let Some(menu_rect) = crate::canvas_tool_menu::menu_rect_if_open(app, &ctx) {+                // If a canvas tool menu is open and this click is outside it, the click will+                // dismiss the menu and should not also trigger a tool action (Photoshop behavior).+                if !menu_rect.contains(p) {+                    // Click is outside the open menu - it will dismiss the menu, don't trigger tool action.+                } else {+                    let click_button = if app.secondary_erase { PointerButton::Secondary } else { PointerButton::Primary };+                    let click_mods = ui.input(|i| pointer_button_modifiers(&i.events, click_button)).unwrap_or(mods);+                    let click_mods = if tool == Tool::ObjectSelection && click_button == PointerButton::Primary {+                        ctx.data(|d| d.get_temp(tool_press)).unwrap_or(click_mods)+                    } else {+                        click_mods+                    };+                    let d = xf.to_doc(p);+                    match tool {+                        Tool::Zoom if !transform_owns_pointer => {+                            let nz = crate::zoom_levels::step(view.zoom, if zoom_out(click_mods.alt) { -1 } else { 1 }, view.doc_size);+                            let center = app.session.prefs().tools.zoom_clicked_point_to_center;+                            let doc_anchor = xf.to_doc(p);+                            let screen_anchor = p;+                            zoom_about(&mut view, &xf, p, nz, center, ppp);+                            view.request_zoom_animation(app.session.prefs().general.animated_zoom && nz != display_view.zoom);+                            view.zoom_anchor = (view.zoom_animating && !center).then_some((doc_anchor, [screen_anchor.x, screen_anchor.y]));+                        }+                        // A click with the (temporary) Hand does nothing, never the tool underneath.+                        Tool::Hand | Tool::RotateView => {}+                        // Double-clicking closes the polygonal lasso: the first click placed the last+                        // vertex (or already closed it), so the second never starts a new polygon. egui+                        // reports it as a triple click when a vertex went down shortly before.+                        Tool::PolygonLasso if response.double_clicked() || response.triple_clicked() => commit_polygon(app),+                        // The same for the Magnetic Lasso: along the edges, or straight with ⌥.+                        Tool::MagneticLasso if response.double_clicked() || response.triple_clicked() => crate::magnetic_lasso_ui::close(app, mods.alt),+                        // The first click placed (or grabbed) the point; the second toggles it.+                        Tool::CurvaturePen if response.double_clicked() => crate::vector_ui::curvature_toggle(app, d[0], d[1]),+                        // Double-clicking a guide opens Preferences › Guides, Grid & Slices, as in Photoshop.+                        Tool::Move if response.double_clicked() && crate::rulers::guide_at(app, d[0], d[1]).is_some() => {+                            if let Err(e) = crate::menus::invoke(app, &ctx, "edit.preferences.guidesGridAndSlices", json!({})) {+                                app.ui.status = e;+                            }+                        }+                        _ => {+                            if tool == Tool::Move && app.ui.transform.is_none() {+                                begin_transform_controls_at(app, &ctx, &xf, p);+                            }+                            // If this tool was not handled on pointer-down, fall back to a+                            // click-sized gesture while preserving pen pressure (mouse stays at 1).+                            tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, click_mods);+                            tap_at_peak_pressure(app);+                            tool_event(app, ToolEvent::Up { x: d[0], y: d[1] }, click_mods);+                        }                     }                 }-                _ => {-                    if tool == Tool::Move && app.ui.transform.is_none() {-                        begin_transform_controls_at(app, &ctx, &xf, p);+            } else {+                let click_button = if app.secondary_erase { PointerButton::Secondary } else { PointerButton::Primary };+                let click_mods = ui.input(|i| pointer_button_modifiers(&i.events, click_button)).unwrap_or(mods);+                let click_mods = if tool == Tool::ObjectSelection && click_button == PointerButton::Primary {+                    ctx.data(|d| d.get_temp(tool_press)).unwrap_or(click_mods)+                } else {+                    click_mods+                };+                let d = xf.to_doc(p);+                match tool {+                    Tool::Zoom if !transform_owns_pointer => {+                        let nz = crate::zoom_levels::step(view.zoom, if zoom_out(click_mods.alt) { -1 } else { 1 }, view.doc_size);+                        let center = app.session.prefs().tools.zoom_clicked_point_to_center;+                        let doc_anchor = xf.to_doc(p);+                        let screen_anchor = p;+                        zoom_about(&mut view, &xf, p, nz, center, ppp);+                        view.request_zoom_animation(app.session.prefs().general.animated_zoom && nz != display_view.zoom);+                        view.zoom_anchor = (view.zoom_animating && !center).then_some((doc_anchor, [screen_anchor.x, screen_anchor.y]));+                    }+                    // A click with the (temporary) Hand does nothing, never the tool underneath.+                    Tool::Hand | Tool::RotateView => {}+                    // Double-clicking closes the polygonal lasso: the first click placed the last+                    // vertex (or already closed it), so the second never starts a new polygon. egui+                    // reports it as a triple click when a vertex went down shortly before.+                    Tool::PolygonLasso if response.double_clicked() || response.triple_clicked() => commit_polygon(app),+                    // The same for the Magnetic Lasso: along the edges, or straight with ⌥.+                    Tool::MagneticLasso if response.double_clicked() || response.triple_clicked() => crate::magnetic_lasso_ui::close(app, mods.alt),+                    // The first click placed (or grabbed) the point; the second toggles it.+                    Tool::CurvaturePen if response.double_clicked() => crate::vector_ui::curvature_toggle(app, d[0], d[1]),+                    // Double-clicking a guide opens Preferences › Guides, Grid & Slices, as in Photoshop.+                    Tool::Move if response.double_clicked() && crate::rulers::guide_at(app, d[0], d[1]).is_some() => {+                        if let Err(e) = crate::menus::invoke(app, &ctx, "edit.preferences.guidesGridAndSlices", json!({})) {+                            app.ui.status = e;+                        }+                    }+                    _ => {+                        if tool == Tool::Move && app.ui.transform.is_none() {+                            begin_transform_controls_at(app, &ctx, &xf, p);+                        }+                        // If this tool was not handled on pointer-down, fall back to a+                        // click-sized gesture while preserving pen pressure (mouse stays at 1).+                        tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, click_mods);+                        tap_at_peak_pressure(app);+                        tool_event(app, ToolEvent::Up { x: d[0], y: d[1] }, click_mods);                     }-                    // If this tool was not handled on pointer-down, fall back to a-                    // click-sized gesture while preserving pen pressure (mouse stays at 1).-                    tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, click_mods);-                    tap_at_peak_pressure(app);-                    tool_event(app, ToolEvent::Up { x: d[0], y: d[1] }, click_mods);                 }             }         }diff --git a/crates/ui-egui/src/canvas/canvas_tool_menu.rs b/crates/ui-egui/src/canvas/canvas_tool_menu.rs--- a/crates/ui-egui/src/canvas/canvas_tool_menu.rs+++ b/crates/ui-egui/src/canvas/canvas_tool_menu.rs@@ -1173,8 +1173,20 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) {         choose(app, ctx, &command);     } else if crate::canvas_popup::outside_pressed(ctx, id, area.response.rect) {         app.ui.canvas_tool_menu = None;+        app.ui.canvas_tool_menu_dismissed = true;     } } +/// Returns the menu rect if the canvas tool menu is currently open, for hit-testing+/// outside-click dismissal in the click handler.+pub fn menu_rect_if_open(app: &PhotocraftApp, ctx: &Context) -> Option<egui::Rect> {+    let menu = app.ui.canvas_tool_menu.as_ref()?;+    let id = menu_id();+    let screen = ctx.content_rect();+    let size = ctx.memory(|m| m.area_rect(id)).map_or(egui::vec2(210.0, 140.0), |r| r.size());+    let pos = egui::pos2(menu.pos[0].min(screen.right() - size.x).max(screen.left()), menu.pos[1].min(screen.bottom() - size.y).max(screen.top()));+    Some(egui::Rect::from_min_size(pos, size))+}+ #[cfg(test)] mod tests;diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs--- a/crates/ui-egui/src/state.rs+++ b/crates/ui-egui/src/state.rs@@ -287,6 +287,10 @@ pub struct UiState {     /// Selection-tool context menu opened by a plain canvas right-click.     #[serde(default)]     pub canvas_tool_menu: Option<crate::canvas_tool_menu::CanvasToolMenu>,+    /// An outside press dismissed the canvas tool menu; suppress the corresponding+    /// release click from triggering a tool action.+    #[serde(default)]+    pub canvas_tool_menu_dismissed: bool,     /// The guide a Move-tool click selected (`guide_select`): drawn blue, Delete removes it.     #[serde(default)]     pub selected_guide: Option<crate::guide_select::SelectedGuide>,@@ -507,6 +511,7 @@ impl Default for UiState {             brush_picker_size: None,             layer_menu: None,             canvas_tool_menu: None,+            canvas_tool_menu_dismissed: false,             selected_guide: None,             brush_tool: Tool::Brush,             tool_brushes: Vec::new(),