From 848a5ee9043409db5608f1449c8304404c177644 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 03:41:39 -0800 Subject: [PATCH] Zoom: Space moves a text-edit zoom marquee after Cmd is let go While the Type tool edits text, a zoom marquee started with Cmd+Space read Space as up once Cmd was let go. Space pressed again moved the marquee only in the frame of the key press, and later frames sized it. The marquee now reads the Space key itself, so it moves and sizes as it does outside text editing. A space typed or committed by an input method during a Zoom tool drag (the marquee or an animated zoom) is no longer typed into the text. With no zoom drag, Space types a space as before. --- crates/ui-egui/src/canvas.rs | 5 ++ crates/ui-egui/src/canvas/input.rs | 17 ++++--- crates/ui-egui/src/canvas/tests.rs | 67 ++++++++++++++++++++++++++ crates/ui-egui/src/shortcuts/typing.rs | 23 +++++++-- docs/target-app-parity.md | 1 + 5 files changed, 103 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a947dde852..486c70de88 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -186,6 +186,11 @@ pub(crate) fn dragging(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(drag_id()).is_some()) } +/// Whether the canvas is holding a Zoom tool drag: a marquee, which Space moves, or an animated zoom. +pub(crate) fn dragging_zoom(ctx: &egui::Context) -> bool { + ctx.data(|d| matches!(d.get_temp::(drag_id()), Some(Drag::ZoomBox { .. } | Drag::ZoomScrub { .. }))) +} + /// The part of `outer` inside the scroll bars, and the part of that inside the rulers, which shows /// the document. Normal Screen Mode and Full Screen Mode with Menu Bar keep the scroll bars along /// the right and bottom edges; Full Screen and Presentation Mode have none. diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 4d121eb2a3..57dbef9ff8 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -373,7 +373,7 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) } } Drag::ZoomBox { start } => { - let start = zoom_marquee_start(ui, start, before, p, space); + let start = zoom_marquee_start(ui, start, before, p); ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomBox { start })); marquee(ui.painter(), egui::Rect::from_two_pos(start, p)); } @@ -480,7 +480,7 @@ fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Po let ui_zoom = app.canvas_ui_zoom; match d { Drag::ZoomBox { start } => { - let start = zoom_marquee_start(ui, start, before, p, space); + let start = zoom_marquee_start(ui, start, before, p); ui.data_mut(|dd| dd.remove::(zoom_space_free_id())); let r = egui::Rect::from_two_pos(start, p); if let Some(vm) = app.view_mut() { @@ -612,22 +612,25 @@ fn zoom_space_free_id() -> egui::Id { /// sizes the marquee until it is let go. A marquee with no size yet is sized by the movement, so /// Space pressed before the pointer moves does not keep it at the press point. The frame's pointer /// moves and Space presses and releases are taken in the order they came, so each move follows the -/// Space state it came with. -fn zoom_marquee_start(ui: &Ui, start: Pos2, before: Option, p: Pos2, space: bool) -> Pos2 { +/// Space state it came with. The Space key counts as held whatever the modifiers, so a marquee that +/// Cmd+Space started while the Type tool edits text follows the same rules once Cmd is let go. +fn zoom_marquee_start(ui: &Ui, start: Pos2, before: Option, p: Pos2) -> Pos2 { enum Step { Space(bool), Move(Pos2), } let id = zoom_space_free_id(); - let steps: Vec = ui.input(|i| { - i.events + let (steps, space): (Vec, bool) = ui.input(|i| { + let steps = i + .events .iter() .filter_map(|e| match e { egui::Event::Key { key: egui::Key::Space, pressed, repeat: false, .. } => Some(Step::Space(*pressed)), egui::Event::PointerMoved(q) => Some(Step::Move(*q)), _ => None, }) - .collect() + .collect(); + (steps, i.key_down(egui::Key::Space)) }); // Space as it was when the frame began: the opposite of its first press or release, else as it is now. let mut held = steps.iter().find_map(|s| if let Step::Space(down) = s { Some(!down) } else { None }).unwrap_or(space); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4d609f2b5e..6c01b2ed32 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -7416,6 +7416,73 @@ fn space_types_a_space_while_editing_text_and_does_not_pan() { assert_eq!(plain(&app, id), "a ", "the text is as typed"); } +/// While the Type tool edits text, a zoom marquee that Cmd+Space started keeps its Space rules once +/// Cmd is let go: Space held since the press sizes it, and Space pressed again moves it on every +/// frame it stays held, without typing a space. With no marquee, Space types a space again, and +/// during an animated zoom drag it types none. +#[test] +fn space_moves_a_text_edit_zoom_marquee_after_cmd_is_let_go() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.placeholder_text = false; + app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap(); + let ctx = egui::Context::default(); + let id = start_typing(&mut app, &ctx); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() - vec2(100.0, 50.0); + let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers }; + let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + let corners = |app: &mut VectorcraftApp| -> (Pos2, Pos2) { + let lines: Vec> = shapes(app, &ctx) + .into_iter() + .filter_map(|s| match s { + Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(Color32::from_white_alpha(230)) => Some(p.points), + _ => None, + }) + .collect(); + let [line] = &lines[..] else { panic!("one marquee: {lines:?}") }; + (line[0], line[2]) + }; + typing_frame(&mut app, &ctx, vec![Event::Text("a".into())]); + let z0 = app.view().unwrap().zoom; + // Space held since the Cmd+Space press sizes the marquee, also once Cmd is let go. + typing_frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::COMMAND), space(true, Modifiers::COMMAND), Event::PointerMoved(at)]); + typing_frame(&mut app, &ctx, vec![button(at, true, Modifiers::COMMAND)]); + typing_frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(50.0, 25.0))]); + typing_frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::NONE), Event::PointerMoved(at + vec2(80.0, 40.0))]); + typing_frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(100.0, 50.0))]); + assert_eq!(corners(&mut app), (at, at + vec2(100.0, 50.0)), "Space held since the press sizes it"); + // Let go and pressed again, Space moves the marquee on this frame and the frames after it. + typing_frame(&mut app, &ctx, vec![space(false, Modifiers::NONE)]); + typing_frame(&mut app, &ctx, vec![space(true, Modifiers::NONE), Event::Text(" ".into()), Event::PointerMoved(at + vec2(120.0, 60.0))]); + typing_frame(&mut app, &ctx, vec![Event::PointerMoved(at + vec2(140.0, 70.0))]); + assert_eq!(corners(&mut app), (at + vec2(40.0, 20.0), at + vec2(140.0, 70.0)), "Space moves it and keeps its 100 × 50 size"); + assert_eq!(plain(&app, id), "a", "the Space that moves the marquee types nothing"); + typing_frame(&mut app, &ctx, vec![space(false, Modifiers::NONE)]); + typing_frame(&mut app, &ctx, vec![space(true, Modifiers::NONE), Event::Ime(egui::ImeEvent::Commit(" ".into()))]); + assert_eq!(plain(&app, id), "a", "nor does a space an input method commits for it"); + // Let go, the drag sizes it again, and the release zooms to the moved marquee. + typing_frame(&mut app, &ctx, vec![space(false, Modifiers::NONE), Event::PointerMoved(at + vec2(180.0, 95.0))]); + assert_eq!(corners(&mut app), (at + vec2(40.0, 20.0), at + vec2(180.0, 95.0)), "let go, the drag sizes it again"); + typing_frame(&mut app, &ctx, vec![button(at + vec2(180.0, 95.0), false, Modifiers::NONE)]); + let want = z0 * f64::from((rect.width() / 140.0).min(rect.height() / 75.0)); + assert!((app.view().unwrap().zoom - want).abs() < 1e-6, "{z0} → {}, not {want}", app.view().unwrap().zoom); + assert!(app.session.tool_wants_text(), "editing goes on"); + // With no marquee, Space types a space. + typing_frame(&mut app, &ctx, vec![space(true, Modifiers::NONE), Event::Text(" ".into())]); + typing_frame(&mut app, &ctx, vec![space(false, Modifiers::NONE)]); + assert_eq!(plain(&app, id), "a ", "Space types a space"); + // With Animated Zoom, Space during the Cmd+Space drag does not type a space either. + app.run("prefs.set", json!({"key": "animatedZoom", "value": true})).unwrap(); + typing_frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::COMMAND), space(true, Modifiers::COMMAND), Event::PointerMoved(at)]); + typing_frame(&mut app, &ctx, vec![button(at, true, Modifiers::COMMAND)]); + typing_frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::NONE), space(false, Modifiers::NONE)]); + typing_frame(&mut app, &ctx, vec![space(true, Modifiers::NONE), Event::Text(" ".into())]); + typing_frame(&mut app, &ctx, vec![button(at, false, Modifiers::NONE)]); + typing_frame(&mut app, &ctx, vec![space(false, Modifiers::NONE)]); + assert_eq!(plain(&app, id), "a ", "the text is as typed"); +} + #[test] fn space_shift_drag_rotates_the_view_whatever_the_tool() { use egui::{Event, Modifiers}; diff --git a/crates/ui-egui/src/shortcuts/typing.rs b/crates/ui-egui/src/shortcuts/typing.rs index cee164c7f8..2befac02c3 100644 --- a/crates/ui-egui/src/shortcuts/typing.rs +++ b/crates/ui-egui/src/shortcuts/typing.rs @@ -42,6 +42,8 @@ enum Seq { Text(String), Preedit(String, Option>), Commit(String), + /// A space the IME commits during a Zoom tool drag on the canvas, where Space moves the marquee. + MarqueeSpace, Erase(ToolKey, Mods), /// An input method replaces text around the composition (upstream's character counts or /// the fork's exact UTF-8 byte counts), then commits. @@ -82,6 +84,11 @@ pub(super) fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { // Set once the IME commits text in this frame. let mut committed = false; for e in seq { + // With text marked, the IME's space commits that text as usual. + let e = match e { + Seq::MarqueeSpace if app.session.tool_composing() || app.ime_marked.is_some() => Seq::Commit(" ".into()), + e => e, + }; // Before the UI, a changed composition or guard is settled `pending`: a token bound here // could outlive a route this pass invalidates later (a palette/dialog/Home) and attach to // the stock descriptor. The post-UI finalizer binds the confirmed replacement. @@ -94,10 +101,16 @@ pub(super) fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { match &e { // Accepted old payloads precede the fence even when their terminal arrives. // None of these events proves the local reset has reached its boundary. - Seq::Commit(_) | Seq::DeleteSurrounding(..) | Seq::Preedit(..) => continue, + Seq::Commit(_) | Seq::MarqueeSpace | Seq::DeleteSurrounding(..) | Seq::Preedit(..) => continue, _ => {} } } + // Otherwise the IME's input ends there, and the space is not typed. + if matches!(e, Seq::MarqueeSpace) { + committed = true; + app.ime_compositions.remove(&viewport); + continue; + } // A pointer/navigation/command notice has not reached its real handler yet. Text and // erases after it belong to the old caret, not to the destination it will establish. if crate::ime::deferred(app, ctx) { @@ -135,10 +148,11 @@ pub(super) fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { } /// Take this frame's typed text, IME events, editing keys, Enter and clipboard events, in the order -/// they arrived. +/// they arrived. A space typed during the canvas's Zoom tool drag is not typed. fn type_sequence(ctx: &egui::Context) -> Vec { let mut seq = vec![]; let mut edits = vec![]; + let zooming = crate::canvas::dragging_zoom(ctx); ctx.input_mut(|i| { // Move the events out instead of cloning their strings: a keyboard hook that reinjects a // batch keeps the batch in `seq` only, not in both the input list and a second copy. @@ -155,10 +169,13 @@ fn type_sequence(ctx: &egui::Context) -> Vec { .unwrap_or_else(|| i.pointer.primary_down()); for e in std::mem::take(&mut i.events) { match e { + // The zoom drag lasts until the primary button goes up. + egui::Event::Text(t) if zooming && primary_down && t == " " => {} egui::Event::Text(t) => seq.push(Seq::Text(t)), egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => { seq.push(Seq::Preedit(text, active_range_chars)); } + egui::Event::Ime(egui::ImeEvent::Commit(t)) if zooming && primary_down && t == " " => seq.push(Seq::MarqueeSpace), egui::Event::Ime(egui::ImeEvent::Commit(t)) => seq.push(Seq::Commit(t)), egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => { seq.push(Seq::DeleteSurrounding(before_chars, after_chars, SurroundingUnit::Chars)); @@ -254,7 +271,7 @@ fn apply_type_event( } Ok(vec![]) } - Seq::ResetCompleted(_) => Ok(vec![]), + Seq::ResetCompleted(_) | Seq::MarqueeSpace => Ok(vec![]), // Keys the IME passed through come as text only when nothing is marked. This is an // application edit (also a hook IME's reinjected characters): `Other` dominates. Seq::Text(t) if !app.session.tool_composing() && !shortcut_mods => { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..38de56892f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1152,6 +1152,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Space held while the Zoom tool drags a marquee moves the marquee and keeps its size ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). When Space is let go, the drag sizes the marquee again from where it moved to. A Space+Shift drag rotates the view whatever the tool, as a Rotate View tool drag does, and Shift does not round the angle to 15°. While the Type tool edits text, Space types a space: held, it does not show the hand, pan or rotate the view, and Cmd+Space still zooms ([the help page](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) lists the Spacebar as the Hand tool when not in text-edit mode). Test: `canvas::tests::space_types_a_space_while_editing_text_and_does_not_pan`. + As VectorCraft's own rule, a zoom marquee that Cmd+Space starts while the Type tool edits text keeps the marquee's Space rules after Cmd is let go: Space held since the press sizes it, Space pressed again moves it ([the help page](https://helpx.adobe.com/illustrator/using/viewing-artwork.html) describes holding the spacebar to move the marquee), and that Space types no space, nor does it during an animated zoom drag. With no zoom drag, Space types a space. Test: `canvas::tests::space_moves_a_text_edit_zoom_marquee_after_cmd_is_let_go`. - **Swatches panel clicks:** Ctrl/Cmd-clicking New Swatch makes a spot color, and Ctrl/Cmd+Shift-clicking it makes a global process color. - **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, art dragged onto the Swatches panel becomes a pattern swatch, and art dropped on the Brushes panel opens New Brush for that art; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it. New Brush asks for the type of brush first, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. The Brushes panel's New Brush button, its menu's New Brush… and art dropped on the panel open the New Brush dialog, where Scatter Brush, Art Brush and Pattern Brush need selected or dropped art. OK opens that type's Brush Options for the new brush, and their OK makes the brush with the name and options set; Cancel in either dialog adds no brush (`ui.newBrush`). The menu's New Calligraphic Brush, New Bristle Brush and New Art, Scatter and Pattern Brush from Selection items still make a brush at once.