From 38a68c3b85ffedf6092f3fc728a2d095de8a5fa1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 06:35:25 -0700 Subject: [PATCH 1/4] View: Escape returns full screen modes to Normal In Full Screen Mode and Full Screen Mode with Menu Bar, an unmodified Escape that nothing earlier in the Escape chain takes (a popup, dialog, field, drawer, tool gesture, edit mode, flyout or the view rotation reset) runs view.screenMode with mode 0. Presentation Mode and the F cycle are unchanged. --- crates/ui-egui/src/shortcuts.rs | 9 ++ crates/ui-egui/src/tests/screenmode.rs | 111 +++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 121 insertions(+) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 37d9e7099..e5f930a9b 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -245,6 +245,15 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { // Escape pressed twice resets a rotated view (with the Rotate View tool, once). let _ = app.run("view.rotateReset", json!({})); escape_pair = None; + } else if matches!(app.ui.screen_mode, 1 | 2) + && !egui::Popup::is_any_open(ctx) + && ctx.input(|i| { + i.events.iter().any(|e| { + matches!(e, egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, modifiers, .. } if *modifiers == Modifiers::NONE) + }) + }) + { + let _ = app.run("view.screenMode", json!({ "mode": 0 })); } } // An Escape that ended something can start a pair too. One that closed a popup cannot. diff --git a/crates/ui-egui/src/tests/screenmode.rs b/crates/ui-egui/src/tests/screenmode.rs index 0c7f2d2e8..6727d81d4 100644 --- a/crates/ui-egui/src/tests/screenmode.rs +++ b/crates/ui-egui/src/tests/screenmode.rs @@ -68,6 +68,117 @@ fn snapshot(app: &VectorcraftApp) -> impl Fn(&VectorcraftApp) -> bool + use<> { } } +fn in_screen_mode(ctx: &egui::Context, mode: u8) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 90, "y": 40, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.select_tool("selection"); + frame(&mut app, ctx, vec![]); + app.run("view.screenMode", json!({"mode": mode})).unwrap(); + frame(&mut app, ctx, vec![]); + app +} + +#[test] +fn escape_leaves_both_full_screen_modes_without_editing() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + let unchanged = snapshot(&app); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "mode {mode}"); + assert!(unchanged(&app), "mode {mode}: document, selection and undo history"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "Escape in Normal mode"); + } +} + +#[test] +fn modified_escape_keeps_full_screen_mode() { + for modifiers in [egui::Modifiers::SHIFT, egui::Modifiers::CTRL, egui::Modifiers::COMMAND, egui::Modifiers::ALT] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, 2); + let events = + [true, false].map(|pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); + frame(&mut app, &ctx, events); + assert_eq!(app.ui.screen_mode, 2, "{modifiers:?}"); + } +} + +#[test] +fn drawer_and_isolation_take_escape_before_full_screen() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.ui.tools_drawer = true; + frame(&mut app, &ctx, escape()); + assert!(!app.ui.tools_drawer); + assert_eq!(app.ui.screen_mode, mode, "drawer"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after drawer"); + + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.run("shape.rectangle", json!({"x": 130, "y": 40, "width": 20, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("object.group", json!({})).unwrap(); + app.run("object.isolate", json!({})).unwrap(); + assert!(app.session.active().unwrap().isolation.is_some()); + frame(&mut app, &ctx, escape()); + assert!(app.session.active().unwrap().isolation.is_none()); + assert_eq!(app.ui.screen_mode, mode, "isolation"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after isolation"); + } +} + +#[test] +fn a_tool_drag_takes_escape_before_full_screen_exits() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, 2); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 50.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 120.0, 70.0), view).unwrap(); + assert!(app.session.tool_busy()); + frame(&mut app, &ctx, escape()); + assert!(!app.session.tool_busy()); + assert_eq!(app.ui.screen_mode, 2); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0); +} + +#[test] +fn a_popup_takes_escape_before_full_screen_exits() { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, 2); + let pos = app.canvas_rect.unwrap().center(); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos), button(true)]); + frame(&mut app, &ctx, vec![button(false)]); + frame(&mut app, &ctx, vec![]); + assert!(egui::Popup::is_any_open(&ctx)); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 2); + assert!(!egui::Popup::is_any_open(&ctx)); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0); +} + +#[test] +fn a_rotated_view_resets_before_full_screen_exits() { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, 2); + app.select_tool("rotateView"); + app.run("view.rotate", json!({"angle": 30})).unwrap(); + frame(&mut app, &ctx, escape()); + assert_eq!(app.view().unwrap().rotation, 0.0); + assert_eq!(app.ui.screen_mode, 2); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0); +} + #[test] fn escape_leaves_presentation_mode() { let mut app = VectorcraftApp::new(Session::new(), crate::Services::default()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 453adabf0..610b859d8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -759,6 +759,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard, fits it and synchronizes the Artboard tool selection when that tool is active; ordinary object selection is preserved. Actual Next-button regressions cover keyboard/Properties deletion and Undo in `chrome.rs`. Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, `view.screenMode` from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible under the outlines (#1505, fixes #1486). In Presentation Mode, → and ← go to the next and the previous artboard, and a click anywhere goes to the next one, as the status bar navigator's Next and Previous do (`view.goToArtboard`): on the last artboard → and a click leave the view as it is, and so does ← on the first. The arrow keys do not move the selection there, and the tool does not receive a click, so a click selects and draws nothing (behavior reference: [View artwork](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)). Full Screen Mode and Presentation Mode put the window full screen, covering the taskbar and the window's frame, and Normal Screen Mode or Full Screen Mode with Menu Bar puts it back (#4365); a window the user made full screen stays so. + An unmodified Escape in either Full Screen Mode exits to Normal Screen Mode after any open popup, tool gesture, edit mode, drawer, flyout or view rotation has taken its Escape; a later Escape exits when that action is finished. This is a VectorCraft choice for both full screen variants, based on the public [shortcut list](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html); the list does not specify that priority or the return mode. Presentation Mode shows the active artboard fitted to the screen (fitted again as the window goes full screen) and hides the selection, guides, grids, slices and the artboards' edges and names, as [presentation mode](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/view-artwork-in-presentation-mode.html) describes. In Presentation Mode no key press other than ←, → and Escape runs a command or reaches the tool (tool keys, Delete, Enter, Tab, F, Shift+F and every command shortcut, the macOS menu bar's key equivalents included), a right click or a long touch opens no context menu, a tap of two or three fingers undoes or redoes nothing, and the pointer over the canvas is the default one instead of the tool's cursor. Align to Artboard uses the active artboard, the one the Artboards panel highlights (`object.align {artboard}`). A press with a selection tool inside an artboard makes that artboard active; with the Selection tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. One selected object is aligned to the active artboard too, whatever Align To says (a lone object is never the key object). When an action plays, its Align to Artboard, All on Active Artboard, Paste in Place, Paste in Front and Paste in Back steps that name no artboard use the artboard that is active then. From dcd4b437a510ffe93b84651eb00bd7787200d6c7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 10:41:04 -0700 Subject: [PATCH 2/4] View: Escape that ends a field, ruler or Tools move keeps full screen In Full Screen Mode and Full Screen Mode with Menu Bar, an Escape that leaves a focused text field, cancels a ruler-origin drag or cancels a floating Tools panel move no longer also returns to Normal Screen Mode; the next Escape does. Tests cover a dialog, a focused field, a tool that claims Escape, pattern edit, Global Edit, a tool flyout and the rotation reset pair in both modes. --- crates/ui-egui/src/app.rs | 3 + crates/ui-egui/src/app/input.rs | 3 +- crates/ui-egui/src/shortcuts.rs | 19 +++ crates/ui-egui/src/tests/screenmode.rs | 164 +++++++++++++++++++++++++ crates/ui-egui/src/tools_float.rs | 21 +++- docs/target-app-parity.md | 2 +- 6 files changed, 209 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 0e5475b66..923459f89 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -500,6 +500,9 @@ impl VectorcraftApp { titlebar::resize_zones(ui); } self.ui_fonts.frame(&ctx); + if self.ui.dialog.is_none() && !self.ui.palette_open { + shortcuts::remember_text_focus(&ctx); + } scrub::end_frame(self, &ctx); native_menu::sync(self, &ctx); // Observe once, after all of this pass's input, dialogs, floating panels, palette and diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index 986ec60ae..6bcfdfd3f 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -1,4 +1,4 @@ -use super::{Arc, Ordering, VectorcraftApp, graphics, native_menu, shortcuts, take_frame_once}; +use super::{Arc, Ordering, VectorcraftApp, graphics, native_menu, shortcuts, take_frame_once, tools_float}; #[cfg(not(target_arch = "wasm32"))] use super::{fileio, place}; @@ -15,6 +15,7 @@ impl VectorcraftApp { self.write_formats_before_text_input(ctx); native_menu::run(self, ctx); if self.fonts_ready { + tools_float::cancel_move_on_escape(self, ctx); shortcuts::handle(self, ctx); } // A Copy taken in this UI phase (an egui copy event, or a command it runs) is published in diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index e5f930a9b..2f40778d4 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -24,6 +24,22 @@ use typing::is_shortcut_chord; pub(crate) use typing::keep_marked_text; use typing::{drop_typing_presses, type_text}; +fn text_focus_frame_id() -> egui::Id { + egui::Id::unique("shortcut-text-focus-frame") +} + +pub(crate) fn remember_text_focus(ctx: &egui::Context) { + if ctx.egui_wants_keyboard_input() { + let frame = ctx.cumulative_frame_nr_for(ctx.viewport_id()); + ctx.data_mut(|d| d.insert_temp(text_focus_frame_id(), frame)); + } +} + +fn text_focused_in_previous_frame(ctx: &egui::Context) -> bool { + let previous = ctx.cumulative_frame_nr_for(ctx.viewport_id()).checked_sub(1); + ctx.data(|d| d.get_temp::(text_focus_frame_id())) == previous +} + /// Parse "Cmd+Shift+]" into an egui shortcut. `Cmd` is Command on macOS and Ctrl elsewhere. /// `+` and `=` name one chord key ([`Key::Equals`]): `+` is Shift+`=` on many layouts, so a /// `Cmd+=` chord also answers `+` however it is typed. @@ -247,6 +263,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { escape_pair = None; } else if matches!(app.ui.screen_mode, 1 | 2) && !egui::Popup::is_any_open(ctx) + && !text_focused_in_previous_frame(ctx) + && !crate::canvas::ruler_origin_dragging(ctx) + && !crate::tools_float::escape_cancelled_move(ctx) && ctx.input(|i| { i.events.iter().any(|e| { matches!(e, egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, modifiers, .. } if *modifiers == Modifiers::NONE) diff --git a/crates/ui-egui/src/tests/screenmode.rs b/crates/ui-egui/src/tests/screenmode.rs index 6727d81d4..121768422 100644 --- a/crates/ui-egui/src/tests/screenmode.rs +++ b/crates/ui-egui/src/tests/screenmode.rs @@ -149,6 +149,57 @@ fn a_tool_drag_takes_escape_before_full_screen_exits() { assert_eq!(app.ui.screen_mode, 0); } +#[test] +fn a_ruler_origin_drag_takes_escape_before_full_screen_exits() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.ui.view.rulers = true; + frame(&mut app, &ctx, vec![]); + let corner = ctx.read_response(egui::Id::unique("ruler-corner")).expect("the rulers' corner").rect.center(); + let to = app.canvas_rect.unwrap().center(); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(corner)]); + frame(&mut app, &ctx, vec![button(corner, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]); + assert!(crate::canvas::ruler_origin_dragging(&ctx), "mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, mode, "Escape cancels the drag in mode {mode}"); + frame(&mut app, &ctx, vec![button(to, false)]); + assert!(!crate::canvas::ruler_origin_dragging(&ctx), "mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "Escape after the drag in mode {mode}"); + } +} + +#[test] +fn a_floating_tools_move_takes_escape_before_full_screen_exits() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.run("window.panel.float", json!({"panel": "tools", "x": 120, "y": 140})).unwrap(); + frame(&mut app, &ctx, vec![]); + frame(&mut app, &ctx, vec![]); + let original = app.ui.toolbar_pos; + let [x, y] = original.expect("the Tools panel floats"); + let area = ctx.memory(|m| m.area_rect(egui::Id::unique("toolbar-floating"))).expect("the floating Tools area"); + let title = area.min + egui::vec2(20.0, 17.0); + let to = title + egui::vec2(100.0, 80.0); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(title), button(title, true)]); + for step in 1..=6 { + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(title + (to - title) * (step as f32 / 6.0))]); + } + assert_ne!(app.ui.toolbar_pos, original, "the Tools panel moved in mode {mode}: saved {x} {y}, area {area:?}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, mode, "Escape cancels the Tools move in mode {mode}"); + assert_eq!(app.ui.toolbar_pos, original, "Escape restores the Tools panel's position"); + frame(&mut app, &ctx, vec![button(to, false)]); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "Escape after the move in mode {mode}"); + } +} + #[test] fn a_popup_takes_escape_before_full_screen_exits() { let ctx = egui::Context::default(); @@ -179,6 +230,119 @@ fn a_rotated_view_resets_before_full_screen_exits() { assert_eq!(app.ui.screen_mode, 0); } +#[test] +fn a_dialog_takes_escape_before_full_screen_exits() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.ui.dialog = Some(crate::state::Dialog::new("rectangle", json!({"width": 20, "height": 20}))); + frame(&mut app, &ctx, vec![]); + frame(&mut app, &ctx, escape()); + assert!(app.ui.dialog.is_none(), "mode {mode}"); + assert_eq!(app.ui.screen_mode, mode, "mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after the dialog in mode {mode}"); + } +} + +#[test] +fn a_focused_field_keeps_full_screen_mode() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + let id = egui::Id::unique("screen-mode-field"); + let mut value = String::new(); + let mut field_frame = |app: &mut VectorcraftApp, events, focus| { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + let response = ui.add(egui::TextEdit::singleline(&mut value).id(id)); + if focus { + response.request_focus(); + } + app.ui(ui); + }); + out.textures_delta.clear(); + }; + field_frame(&mut app, vec![], true); + assert!(ctx.egui_wants_keyboard_input()); + field_frame(&mut app, escape(), false); + assert_eq!(app.ui.screen_mode, mode, "focused field in mode {mode}"); + } +} + +#[test] +fn a_claimed_tool_key_keeps_full_screen_mode() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.select_tool("intertwine"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, mode, "Intertwine claims Escape in mode {mode}"); + app.select_tool("selection"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after changing tools in mode {mode}"); + } +} + +#[test] +fn pattern_edit_and_global_edit_take_escape_before_full_screen() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.run("object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); + assert!(app.session.active().unwrap().doc.pattern_edit.is_some()); + frame(&mut app, &ctx, escape()); + assert!(app.session.active().unwrap().doc.pattern_edit.is_none()); + assert_eq!(app.ui.screen_mode, mode, "pattern edit in mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after pattern edit in mode {mode}"); + + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.run("shape.rectangle", json!({"x": 130, "y": 40, "width": 20, "height": 20})).unwrap(); + app.run("select.globalEdit", json!({})).unwrap(); + assert!(app.session.active().unwrap().global_edit.is_some()); + frame(&mut app, &ctx, escape()); + assert!(app.session.active().unwrap().global_edit.is_none()); + assert_eq!(app.ui.screen_mode, mode, "Global Edit in mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after Global Edit in mode {mode}"); + } +} + +#[test] +fn a_tool_flyout_takes_escape_before_full_screen() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.ui.flyout = Some(0); + frame(&mut app, &ctx, escape()); + assert!(app.ui.flyout.is_none()); + assert_eq!(app.ui.screen_mode, mode, "flyout in mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "after flyout in mode {mode}"); + } +} + +#[test] +fn a_rotation_pair_exits_full_screen_then_resets_with_selection_tool() { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.run("view.rotate", json!({"angle": 30})).unwrap(); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "first Escape in mode {mode}"); + assert_eq!(app.view().unwrap().rotation, 30.0); + frame(&mut app, &ctx, escape()); + assert_eq!(app.view().unwrap().rotation, 0.0, "second Escape after mode {mode}"); + } +} + #[test] fn escape_leaves_presentation_mode() { let mut app = VectorcraftApp::new(Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/tools_float.rs b/crates/ui-egui/src/tools_float.rs index 71101b710..25d86b69f 100644 --- a/crates/ui-egui/src/tools_float.rs +++ b/crates/ui-egui/src/tools_float.rs @@ -8,7 +8,7 @@ //! agents. Its position is kept with the preferences and in user workspaces, and a position saved //! on a bigger window is clamped into this one when drawn. -use egui::{CornerRadius, Id, Pos2, Rect, Stroke, Vec2, pos2, vec2}; +use egui::{CornerRadius, Id, Key, Pos2, Rect, Stroke, Vec2, pos2, vec2}; use serde_json::{Value, json}; use crate::VectorcraftApp; @@ -33,6 +33,14 @@ fn move_id() -> Id { Id::unique("tools-float-move") } +fn escape_cancel_id() -> Id { + Id::unique("tools-float-escape-cancel") +} + +pub(crate) fn escape_cancelled_move(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::(escape_cancel_id())) == Some(ctx.cumulative_frame_nr_for(ctx.viewport_id())) +} + /// The strip along the window's left edge where the Tools panel docks, as last laid out. pub(crate) fn tools_zone_id() -> Id { Id::unique("tools-float-zone") @@ -50,6 +58,13 @@ fn gesture_allowed(ctx: &egui::Context) -> bool { ctx.input(|input| input.focused && !input.key_pressed(egui::Key::Escape)) } +/// Cancel a move before shortcuts route the same Escape press. +pub(crate) fn cancel_move_on_escape(app: &mut VectorcraftApp, ctx: &egui::Context) { + if ctx.input(|i| i.key_pressed(Key::Escape)) { + track(app, ctx); + } +} + fn start(app: &VectorcraftApp, ctx: &egui::Context, grab: Vec2) { let Some(from) = ctx.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())) else { return }; ctx.data_mut(|d| d.insert_temp(move_id(), Move { grab, from, original: app.ui.toolbar_pos })); @@ -94,6 +109,10 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { if !gesture_allowed(ctx) { app.ui.toolbar_pos = m.original; ctx.data_mut(|d| d.remove::(move_id())); + if ctx.input(|i| i.key_pressed(Key::Escape)) { + let frame = ctx.cumulative_frame_nr_for(ctx.viewport_id()); + ctx.data_mut(|d| d.insert_temp(escape_cancel_id(), frame)); + } return; } let at = ctx.input(|i| i.pointer.interact_pos()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 610b859d8..cbf1e4590 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -759,7 +759,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard, fits it and synchronizes the Artboard tool selection when that tool is active; ordinary object selection is preserved. Actual Next-button regressions cover keyboard/Properties deletion and Undo in `chrome.rs`. Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, `view.screenMode` from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible under the outlines (#1505, fixes #1486). In Presentation Mode, → and ← go to the next and the previous artboard, and a click anywhere goes to the next one, as the status bar navigator's Next and Previous do (`view.goToArtboard`): on the last artboard → and a click leave the view as it is, and so does ← on the first. The arrow keys do not move the selection there, and the tool does not receive a click, so a click selects and draws nothing (behavior reference: [View artwork](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)). Full Screen Mode and Presentation Mode put the window full screen, covering the taskbar and the window's frame, and Normal Screen Mode or Full Screen Mode with Menu Bar puts it back (#4365); a window the user made full screen stays so. - An unmodified Escape in either Full Screen Mode exits to Normal Screen Mode after any open popup, tool gesture, edit mode, drawer, flyout or view rotation has taken its Escape; a later Escape exits when that action is finished. This is a VectorCraft choice for both full screen variants, based on the public [shortcut list](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html); the list does not specify that priority or the return mode. + An unmodified Escape in either Full Screen Mode exits to Normal Screen Mode after any open popup, focused field, tool gesture, ruler-origin drag, floating Tools panel move, edit mode, drawer, flyout or view rotation has taken its Escape; a later Escape exits when that action is finished. This is a VectorCraft choice for both full screen variants, based on the public [shortcut list](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html); the list does not specify that priority or the return mode. Presentation Mode shows the active artboard fitted to the screen (fitted again as the window goes full screen) and hides the selection, guides, grids, slices and the artboards' edges and names, as [presentation mode](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/view-artwork-in-presentation-mode.html) describes. In Presentation Mode no key press other than ←, → and Escape runs a command or reaches the tool (tool keys, Delete, Enter, Tab, F, Shift+F and every command shortcut, the macOS menu bar's key equivalents included), a right click or a long touch opens no context menu, a tap of two or three fingers undoes or redoes nothing, and the pointer over the canvas is the default one instead of the tool's cursor. Align to Artboard uses the active artboard, the one the Artboards panel highlights (`object.align {artboard}`). A press with a selection tool inside an artboard makes that artboard active; with the Selection tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. One selected object is aligned to the active artboard too, whatever Align To says (a lone object is never the key object). When an action plays, its Align to Artboard, All on Active Artboard, Paste in Place, Paste in Front and Paste in Back steps that name no artboard use the artboard that is active then. From 030baab798577776671e6ff8d8695f862d74c671 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 11:28:52 -0700 Subject: [PATCH 3/4] View: Escape that cancels a ruler or Tools move ends nothing else An Escape press that cancels a ruler-origin drag or a floating Tools panel move no longer also closes Edit Toolbar's drawer, leaves pattern edit, isolation or Global Edit, closes the tool flyout or resets a rotated view. A later Escape does that action as usual. --- crates/ui-egui/src/shortcuts.rs | 7 +- crates/ui-egui/src/shortcuts/presentation.rs | 5 +- crates/ui-egui/src/tests/screenmode.rs | 136 +++++++++++++++++++ 3 files changed, 142 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 2f40778d4..d8a238ed1 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -205,9 +205,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { presentation_keys(app, ctx, escape_pair); return; } + let escape_ended_gesture = crate::canvas::ruler_origin_dragging(ctx) || crate::tools_float::escape_cancelled_move(ctx); let typing = ctx.egui_wants_keyboard_input(); // Escape closes Edit Toolbar's drawer first (egui drops a drag from it on Escape already). - if app.ui.tools_drawer && !typing && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { + if app.ui.tools_drawer && !typing && !escape_ended_gesture && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { app.ui.tools_drawer = false; } let view = app.view_info(); @@ -248,7 +249,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, k)); } - if k == Key::Escape && !busy && !claimed { + if k == Key::Escape && !busy && !claimed && !escape_ended_gesture { if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) { let _ = app.run("object.pattern.done", json!({})); } else if app.session.active().is_some_and(|d| d.isolation.is_some()) { @@ -264,8 +265,6 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } else if matches!(app.ui.screen_mode, 1 | 2) && !egui::Popup::is_any_open(ctx) && !text_focused_in_previous_frame(ctx) - && !crate::canvas::ruler_origin_dragging(ctx) - && !crate::tools_float::escape_cancelled_move(ctx) && ctx.input(|i| { i.events.iter().any(|e| { matches!(e, egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, modifiers, .. } if *modifiers == Modifiers::NONE) diff --git a/crates/ui-egui/src/shortcuts/presentation.rs b/crates/ui-egui/src/shortcuts/presentation.rs index c93ca762e..da746cbf5 100644 --- a/crates/ui-egui/src/shortcuts/presentation.rs +++ b/crates/ui-egui/src/shortcuts/presentation.rs @@ -34,13 +34,14 @@ pub(super) fn escape_press(ctx: &egui::Context) -> Option { } /// Does this Escape reset the view's rotation? It does when the view is rotated, no popup is open, -/// the ruler origin is not being dragged, and the press is the second of a pair (`second`) or the -/// Rotate View tool is chosen. +/// neither a ruler-origin drag nor a floating Tools move is ending, and the press is the second of +/// a pair (`second`) or the Rotate View tool is chosen. pub(super) fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context, second: bool) -> bool { app.view().is_some_and(|v| v.rotation != 0.0) && (second || app.session.tool_id() == "rotateView") && !egui::Popup::is_any_open(ctx) && !crate::canvas::ruler_origin_dragging(ctx) + && !crate::tools_float::escape_cancelled_move(ctx) } /// The keys in Presentation Mode, where nothing is edited: Escape leaves the mode, and ← and → go to diff --git a/crates/ui-egui/src/tests/screenmode.rs b/crates/ui-egui/src/tests/screenmode.rs index 121768422..3d6cc8dd6 100644 --- a/crates/ui-egui/src/tests/screenmode.rs +++ b/crates/ui-egui/src/tests/screenmode.rs @@ -80,6 +80,80 @@ fn in_screen_mode(ctx: &egui::Context, mode: u8) -> VectorcraftApp { app } +fn begin_tools_move(app: &mut VectorcraftApp, ctx: &egui::Context) -> (Option<[f32; 2]>, egui::Pos2) { + app.run("window.panel.float", json!({"panel": "tools", "x": 120, "y": 140})).unwrap(); + frame(app, ctx, vec![]); + frame(app, ctx, vec![]); + let original = app.ui.toolbar_pos; + let area = ctx.memory(|m| m.area_rect(egui::Id::unique("toolbar-floating"))).expect("the floating Tools area"); + let title = area.min + egui::vec2(20.0, 17.0); + let to = title + egui::vec2(100.0, 80.0); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![egui::Event::PointerMoved(title), button(title, true)]); + for step in 1..=6 { + frame(app, ctx, vec![egui::Event::PointerMoved(title + (to - title) * (step as f32 / 6.0))]); + } + assert_ne!(app.ui.toolbar_pos, original, "the Tools panel moved"); + (original, to) +} + +fn begin_ruler_origin_drag(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Pos2 { + app.ui.view.rulers = true; + frame(app, ctx, vec![]); + let corner = ctx.read_response(egui::Id::unique("ruler-corner")).expect("the rulers' corner").rect.center(); + let to = app.canvas_rect.unwrap().center(); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![egui::Event::PointerMoved(corner)]); + frame(app, ctx, vec![button(corner, true)]); + frame(app, ctx, vec![egui::Event::PointerMoved(to)]); + assert!(crate::canvas::ruler_origin_dragging(ctx)); + to +} + +fn release_drag(app: &mut VectorcraftApp, ctx: &egui::Context, to: egui::Pos2) { + frame( + app, + ctx, + vec![egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }], + ); +} + +fn start_escape_action(app: &mut VectorcraftApp, ctx: &egui::Context, action: &str) { + match action { + "drawer" => app.ui.tools_drawer = true, + "pattern" => { + app.run("object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); + } + "isolation" => { + app.run("shape.rectangle", json!({"x": 130, "y": 40, "width": 20, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("object.group", json!({})).unwrap(); + app.run("object.isolate", json!({})).unwrap(); + } + "global" => { + app.run("shape.rectangle", json!({"x": 130, "y": 40, "width": 20, "height": 20})).unwrap(); + app.run("select.globalEdit", json!({})).unwrap(); + } + "flyout" => { + app.ui.flyout = Some(0); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("flyout-tools"), vec!["selection".to_string(), "directSelection".to_string()])); + } + _ => panic!("unknown Escape action"), + } + assert!(escape_action_active(app, action), "{action} starts"); +} + +fn escape_action_active(app: &VectorcraftApp, action: &str) -> bool { + match action { + "drawer" => app.ui.tools_drawer, + "pattern" => app.session.active().unwrap().doc.pattern_edit.is_some(), + "isolation" => app.session.active().unwrap().isolation.is_some(), + "global" => app.session.active().unwrap().global_edit.is_some(), + "flyout" => app.ui.flyout.is_some(), + _ => false, + } +} + #[test] fn escape_leaves_both_full_screen_modes_without_editing() { for mode in [1, 2] { @@ -200,6 +274,68 @@ fn a_floating_tools_move_takes_escape_before_full_screen_exits() { } } +#[test] +fn cancelling_tools_move_keeps_rotation_in_both_full_screen_modes() { + for mode in [1, 2] { + for tool in ["rotateView", "selection"] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + app.select_tool(tool); + app.run("view.rotate", json!({"angle": 30})).unwrap(); + if tool == "selection" { + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "first Escape leaves mode {mode}"); + assert_eq!(app.view().unwrap().rotation, 30.0); + } + let (original, to) = begin_tools_move(&mut app, &ctx); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.toolbar_pos, original, "{tool} in mode {mode}"); + assert_eq!(app.view().unwrap().rotation, 30.0, "{tool} in mode {mode}"); + release_drag(&mut app, &ctx, to); + frame(&mut app, &ctx, escape()); + assert_eq!(app.view().unwrap().rotation, 0.0, "later Escape resets rotation"); + } + } +} + +#[test] +fn cancelling_tools_move_keeps_the_next_escape_action() { + for mode in [0, 1, 2] { + for action in ["drawer", "pattern", "isolation", "global", "flyout"] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + let (original, to) = begin_tools_move(&mut app, &ctx); + start_escape_action(&mut app, &ctx, action); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.toolbar_pos, original, "move cancelled in mode {mode}, {action}"); + assert!(escape_action_active(&app, action), "Escape keeps {action} in mode {mode}"); + assert_eq!(app.ui.screen_mode, mode, "mode {mode}, {action}"); + release_drag(&mut app, &ctx, to); + frame(&mut app, &ctx, escape()); + assert!(!escape_action_active(&app, action), "later Escape ends {action} in mode {mode}"); + } + } +} + +#[test] +fn cancelling_ruler_origin_drag_keeps_the_next_escape_action() { + for mode in [0, 1, 2] { + for action in ["pattern", "isolation", "flyout"] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + let to = begin_ruler_origin_drag(&mut app, &ctx); + start_escape_action(&mut app, &ctx, action); + frame(&mut app, &ctx, escape()); + assert!(escape_action_active(&app, action), "Escape keeps {action} in mode {mode}"); + assert_eq!(app.ui.screen_mode, mode, "mode {mode}, {action}"); + release_drag(&mut app, &ctx, to); + assert!(!crate::canvas::ruler_origin_dragging(&ctx)); + frame(&mut app, &ctx, escape()); + assert!(!escape_action_active(&app, action), "later Escape ends {action} in mode {mode}"); + } + } +} + #[test] fn a_popup_takes_escape_before_full_screen_exits() { let ctx = egui::Context::default(); From d792557bbe14588e79f61dc3d2698f663bba9464 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 11:53:07 -0700 Subject: [PATCH 4/4] View: Escape during a canvas pan or zoom drag keeps full screen A Hand or Space drag, a middle-button pan, a Zoom tool drag, a Rotate View drag or a touch gesture is held in the canvas's drag state, which the tool busy check does not see. Escape pressed while one is held no longer leaves Full Screen Mode, closes the Edit Toolbar drawer or ends another action; an Escape after the release does. --- crates/ui-egui/src/canvas.rs | 5 +++ crates/ui-egui/src/shortcuts.rs | 7 ++-- crates/ui-egui/src/tests/screenmode.rs | 48 ++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 58815f2e7..cd2a142be 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -164,6 +164,11 @@ enum Drag { Present, } +/// Whether the canvas is holding a pointer drag across frames. +pub(crate) fn dragging(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::(drag_id()).is_some()) +} + /// Paint the document canvas, complete or cached artwork, navigation and tool overlays. pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index d8a238ed1..9f3a70515 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -205,10 +205,11 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { presentation_keys(app, ctx, escape_pair); return; } - let escape_ended_gesture = crate::canvas::ruler_origin_dragging(ctx) || crate::tools_float::escape_cancelled_move(ctx); + let escape_held_by_drag = + crate::canvas::dragging(ctx) || crate::canvas::ruler_origin_dragging(ctx) || crate::tools_float::escape_cancelled_move(ctx); let typing = ctx.egui_wants_keyboard_input(); // Escape closes Edit Toolbar's drawer first (egui drops a drag from it on Escape already). - if app.ui.tools_drawer && !typing && !escape_ended_gesture && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { + if app.ui.tools_drawer && !typing && !escape_held_by_drag && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { app.ui.tools_drawer = false; } let view = app.view_info(); @@ -249,7 +250,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, k)); } - if k == Key::Escape && !busy && !claimed && !escape_ended_gesture { + if k == Key::Escape && !busy && !claimed && !escape_held_by_drag { if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) { let _ = app.run("object.pattern.done", json!({})); } else if app.session.active().is_some_and(|d| d.isolation.is_some()) { diff --git a/crates/ui-egui/src/tests/screenmode.rs b/crates/ui-egui/src/tests/screenmode.rs index 3d6cc8dd6..783577ed4 100644 --- a/crates/ui-egui/src/tests/screenmode.rs +++ b/crates/ui-egui/src/tests/screenmode.rs @@ -223,6 +223,54 @@ fn a_tool_drag_takes_escape_before_full_screen_exits() { assert_eq!(app.ui.screen_mode, 0); } +fn navigation_drag_keeps_screen_mode(kind: &str) { + for mode in [1, 2] { + let ctx = egui::Context::default(); + let mut app = in_screen_mode(&ctx, mode); + let button = match kind { + "hand" => { + app.select_tool("hand"); + egui::PointerButton::Primary + } + "middle" => egui::PointerButton::Middle, + "zoom_box" => { + app.select_tool("zoom"); + app.session.prefs.animated_zoom = false; + egui::PointerButton::Primary + } + _ => panic!("unknown navigation drag"), + }; + frame(&mut app, &ctx, vec![]); + let start = app.canvas_rect.unwrap().center(); + let end = start + egui::vec2(35.0, 20.0); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), pointer(start, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(end)]); + assert!(crate::canvas::dragging(&ctx), "{kind} drag began in mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, mode, "{kind} drag holds Escape in mode {mode}"); + frame(&mut app, &ctx, vec![pointer(end, false)]); + assert!(!crate::canvas::dragging(&ctx), "{kind} drag ended in mode {mode}"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "Escape after {kind} drag in mode {mode}"); + } +} + +#[test] +fn a_hand_drag_keeps_both_full_screen_modes_until_release() { + navigation_drag_keeps_screen_mode("hand"); +} + +#[test] +fn a_middle_button_pan_keeps_both_full_screen_modes_until_release() { + navigation_drag_keeps_screen_mode("middle"); +} + +#[test] +fn a_zoom_box_drag_keeps_both_full_screen_modes_until_release() { + navigation_drag_keeps_screen_mode("zoom_box"); +} + #[test] fn a_ruler_origin_drag_takes_escape_before_full_screen_exits() { for mode in [1, 2] {