View: one Escape resets a rotated view when it has nothing else to end

Escape reset a rotated view only as the second of two presses less than
a second apart, and a click or another key between them started the
count again. With the Rotate View tool one press reset it.

Now an Escape that has nothing else to end resets the rotation. An
Escape that ends something else (isolation mode, a path being drawn, a
full screen mode, a dialog and the others listed in
docs/target-app-parity.md) ends only that, and the next Escape resets
the view, however long after it comes. Tools report a path or pick that
Escape ends with `Tool::ends_on_escape`.

The rotate view tests that needed two presses now check one press, and
the screen mode test that named the old pair is renamed.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-09 22:56:53 -08:00
parent 0ddb5b6820
commit 858e4235db
11 changed files with 320 additions and 130 deletions
+6
View File
@@ -1059,6 +1059,12 @@ impl Session {
self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key(cx, key))
}
/// Has the active tool started something that Escape ends while no button is pressed (see
/// `Tool::ends_on_escape`)?
pub fn tool_ends_on_escape(&mut self, view: ViewInfo) -> bool {
self.with_tool_cx(view, |t, cx| t.ends_on_escape(cx))
}
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
// The Blend tool's first pick is not marked after an edit that dropped it.
self.tell_tool_history_moved();
+5
View File
@@ -547,6 +547,11 @@ impl Tool for SmartPenTool {
key == ToolKey::Tab && (self.press.is_some() || self.active(cx).is_some())
}
/// A path being drawn: Escape ends it.
fn ends_on_escape(&self, _cx: &ToolContext) -> bool {
self.path.is_some()
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.stop();
self.marquee = None;
+4
View File
@@ -649,6 +649,10 @@ impl Tool for CurvatureTool {
self.id = Some(cx.selection.objects[0]);
}
}
/// A path being drawn: Escape ends it.
fn ends_on_escape(&self, cx: &ToolContext) -> bool {
self.active(cx).is_some()
}
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
matches!(key, ToolKey::Backspace | ToolKey::Delete)
&& self.drag.is_none()
+6
View File
@@ -162,6 +162,12 @@ pub trait Tool: Send {
fn claims_key(&self, _cx: &ToolContext, _key: ToolKey) -> bool {
false
}
/// Has the tool started something that Escape ends while no button is pressed (a path being
/// drawn, the Blend tool's first pick)? That Escape ends nothing else, such as isolation mode or
/// a rotated view.
fn ends_on_escape(&self, _cx: &ToolContext) -> bool {
false
}
/// Does the tool want typed text (Type tool editing)? Single-key shortcuts are suppressed.
fn wants_text(&self) -> bool {
false
+6
View File
@@ -153,6 +153,10 @@ impl Tool for BlendTool {
fn busy(&self) -> bool {
self.first.is_some()
}
/// A first pick, or adding to the blend just made: Escape ends either.
fn ends_on_escape(&self, cx: &ToolContext) -> bool {
self.first(cx).is_some()
}
/// Escape drops the object picked first and stops adding to the blend just made, so the next
/// click picks a new first object. The document and the selection stay as they are.
fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec<Action> {
@@ -525,8 +529,10 @@ mod tests {
let c = cx(&d, &s, &p);
let mut t = BlendTool { first: None, chained: true, ..Default::default() };
assert!(t.first(&c).is_some());
assert!(!t.busy() && t.ends_on_escape(&c), "Escape has adding to the blend to end");
assert!(t.key(&c, crate::ToolKey::Escape, Mods::default()).is_empty());
assert!(t.first(&c).is_none(), "Escape ends adding to the blend just made");
assert!(!t.ends_on_escape(&c), "and then has nothing to end");
assert_eq!(t.pointer(&c, &down(150.0, 150.0)), picks(a));
assert!(esc(&mut BlendTool::default()).is_empty(), "nothing to drop");
}
+4
View File
@@ -242,6 +242,10 @@ impl Tool for PenTool {
fn busy(&self) -> bool {
self.drag.is_some() || self.handle.is_some() || self.convert.is_some()
}
/// A path being drawn: Escape ends it.
fn ends_on_escape(&self, _cx: &ToolContext) -> bool {
self.path.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let tol = cx.tol(5.0).max(cx.point_tol());
// The path a press began is the selected one by the next event.
+45 -35
View File
@@ -20,7 +20,7 @@ pub(crate) use held_tool::{HeldTool, note_tool_use};
use paste::unclip_chords;
pub(crate) use paste::{PasteChord, paste_command};
pub(crate) use presentation::presentation_step;
use presentation::{escape_pair_id, escape_press, escape_resets_rotation, presentation_keys};
use presentation::{escape_resets_rotation, presentation_keys};
#[cfg(test)]
use typing::is_shortcut_chord;
pub(crate) use typing::keep_marked_text;
@@ -201,8 +201,6 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
crate::ime::controls_without_editor(app, ctx);
return;
}
// Taken before anything returns, so a press in a field or a dialog ends a pair of Escapes.
let mut escape_pair = escape_press(ctx);
// Followed before anything returns, so a key typed in a field isn't taken for a paste.
let textless_paste = ctx.input(|i| app.paste_chord.textless_paste(&i.events));
// A numeric field being scrubbed has the keyboard: Escape cancels the drag.
@@ -238,7 +236,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
if app.ui.screen_mode == 3 {
crate::ime::controls_without_editor(app, ctx);
presentation_keys(app, ctx, escape_pair);
presentation_keys(app, ctx);
return;
}
// Escape that cancels a ruler-origin drag or floating Tools move skips the tool and later Escape actions,
@@ -268,7 +266,6 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
continue;
}
if k == Key::Escape && escape_ended_gesture {
remember_escape_pair(ctx, k, escape_pair);
continue;
}
// A key the tool claims (Esc with a loaded place cursor) is only the tool's.
@@ -285,7 +282,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
.unwrap_or(i.modifiers);
crate::canvas::mods(m, false)
});
let ends = k == Key::Escape && app.session.tool_ends_on_escape(view);
let before = (k == Key::Escape && !ends).then(|| document_revision(app));
let r = app.session.tool_key(tk, mods, view);
// An Escape that ended something in the tool (a path being drawn, a selected point) ends
// nothing else. A hover highlight it clears does not count.
let tool_took = ends || before.is_some_and(|b| b != document_revision(app));
// Enter also takes what the tool asks of the UI: Rotate, Scale, Reflect and Shear open
// their dialog with it.
if claimed || (k == Key::Enter && !busy) {
@@ -295,33 +297,9 @@ 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_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()) {
let _ = app.run("object.exitIsolation", json!({}));
} else if app.session.active().is_some_and(|d| d.global_edit.is_some()) {
let _ = app.run("select.globalEdit", json!({}));
} else if app.ui.flyout.is_some() {
app.ui.flyout = None;
} else if escape_pair.is_some_and(|second| escape_resets_rotation(app, ctx, second)) {
// 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)
&& !text_focused_in_previous_frame(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 }));
}
if k == Key::Escape && !busy && !claimed && !escape_held_by_drag && !tool_took {
escape_ends_one_thing(app, ctx);
}
// An Escape that ended something can start a pair too. One that closed a popup cannot.
remember_escape_pair(ctx, k, escape_pair);
if k == Key::Enter && !busy && !claimed {
// Enter with a selection tool opens the Move dialog, as a double-click on its button
// does.
@@ -488,13 +466,45 @@ fn escape_ended_gesture(ctx: &egui::Context) -> bool {
held
}
fn remember_escape_pair(ctx: &egui::Context, key: Key, escape_pair: Option<bool>) {
if key == Key::Escape && escape_pair.is_some() && !egui::Popup::is_any_open(ctx) {
let now = ctx.input(|i| i.time);
ctx.data_mut(|d| d.insert_temp(escape_pair_id(), now));
/// An Escape that no tool, field, popup or drag took ends the first of these that is on: pattern
/// editing, isolation mode, Global Edit, a tool flyout, a full screen mode and a rotated view.
fn escape_ends_one_thing(app: &mut VectorcraftApp, ctx: &egui::Context) {
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()) {
let _ = app.run("object.exitIsolation", json!({}));
} else if app.session.active().is_some_and(|d| d.global_edit.is_some()) {
let _ = app.run("select.globalEdit", json!({}));
} else if app.ui.flyout.is_some() {
app.ui.flyout = None;
} else if app.session.tool_id() == "rotateView" && escape_resets_rotation(app, ctx) {
// With the Rotate View tool, the view resets before a full screen mode ends.
let _ = app.run("view.rotateReset", json!({}));
} else if matches!(app.ui.screen_mode, 1 | 2)
&& !egui::Popup::is_any_open(ctx)
&& !text_focused_in_previous_frame(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 }));
} else if escape_resets_rotation(app, ctx) {
// An Escape that has nothing else to end resets a rotated view. After one that ended
// something, the next one does, however long after it comes. A modified Escape does not
// leave a full screen mode, so it resets the view there.
let _ = app.run("view.rotateReset", json!({}));
}
}
/// What a tool's Escape can change besides what [`vectorcraft_engine::Session::tool_ends_on_escape`]
/// reports: the document's revision (edits and selection changes, such as the Curvature tool
/// letting go of a selected path's current point).
fn document_revision(app: &VectorcraftApp) -> Option<u64> {
app.session.active().map(|d| d.revision)
}
/// Dispatch unmodified text keys to command bindings or tools.
fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) {
// Single-key tool shortcuts (no Cmd/Ctrl/Alt). With a layout without Latin letters (Persian,
+13 -45
View File
@@ -1,45 +1,18 @@
use super::{Key, VectorcraftApp, is_modifier, json};
use super::{Key, VectorcraftApp, json};
/// The second press of Escape pressed twice comes less than this many seconds after the first.
const ESCAPE_PAIR_SECONDS: f64 = 1.0;
/// The key of the last counted Escape's time in egui's temporary data.
pub(super) fn escape_pair_id() -> egui::Id {
egui::Id::unique("escape-pair")
}
/// Reads this frame's presses for Escape pressed twice. `None` when the frame has no Escape press, or
/// has another key or pointer button press as well (a key repeat counts as another press, and the
/// modifier keys do not count). Otherwise, whether the last counted Escape came less than
/// [`ESCAPE_PAIR_SECONDS`] before. A press of Escape, another key or a pointer button removes the
/// last counted Escape's time.
pub(super) fn escape_press(ctx: &egui::Context) -> Option<bool> {
let (escape, other, now) = ctx.input(|i| {
let (mut escape, mut other) = (false, false);
for e in &i.events {
match e {
egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, .. } => escape = true,
egui::Event::Key { key, pressed: true, .. } if !is_modifier(*key) => other = true,
egui::Event::PointerButton { pressed: true, .. } => other = true,
_ => {}
}
}
(escape, other, i.time)
});
if !escape && !other {
return None;
}
let counted = ctx.data_mut(|d| d.remove_temp::<f64>(escape_pair_id()));
(escape && !other).then(|| counted.is_some_and(|t| now - t < ESCAPE_PAIR_SECONDS))
}
/// Does this Escape reset the view's rotation? It does when the view is rotated, no popup is open,
/// 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 {
/// Does this Escape reset the view's rotation? The caller has checked that nothing earlier took
/// the press. It does when the view is rotated; no popup, modal window (a progress window, About)
/// or Appearance row popup is open; no text field had the keyboard in the last frame (egui takes
/// the focus away on Escape before this runs); no pointer button is held or let go in this frame
/// (a tab or panel drag takes the Escape, in its release frame too); and neither a ruler-origin
/// drag nor a floating Tools move is ending.
pub(super) fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context) -> bool {
app.view().is_some_and(|v| v.rotation != 0.0)
&& (second || app.session.tool_id() == "rotateView")
&& !egui::Popup::is_any_open(ctx)
&& !crate::panel_docking::modal_open(app, ctx)
&& app.ui.transient_panel.is_none()
&& !super::text_focused_in_previous_frame(ctx)
&& !ctx.input(|i| i.pointer.any_down() || i.pointer.any_released())
&& !crate::canvas::ruler_origin_dragging(ctx)
&& !crate::tools_float::escape_cancelled_move(ctx)
}
@@ -48,7 +21,7 @@ pub(super) fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context,
/// the previous and the next artboard ([`presentation_step`]). Every other key press, the text it
/// types and the clipboard keys are removed from the input, so no shortcut, tool or field acts on
/// them. The macOS menu bar's key equivalents arrive as key presses too ([`crate::native_menu`]).
pub(super) fn presentation_keys(app: &mut VectorcraftApp, ctx: &egui::Context, escape_pair: Option<bool>) {
pub(super) fn presentation_keys(app: &mut VectorcraftApp, ctx: &egui::Context) {
let (mut leave, mut steps) = (false, vec![]);
ctx.input_mut(|i| {
i.events.retain(|e| match e {
@@ -80,11 +53,6 @@ pub(super) fn presentation_keys(app: &mut VectorcraftApp, ctx: &egui::Context, e
}
if leave {
let _ = app.run("view.presentation", json!({}));
// The Escape that left the mode can start a pair, as in `handle`.
if escape_pair.is_some() && !egui::Popup::is_any_open(ctx) {
let now = ctx.input(|i| i.time);
ctx.data_mut(|d| d.insert_temp(escape_pair_id(), now));
}
}
}
+228 -47
View File
@@ -262,80 +262,259 @@ fn key(key: egui::Key, repeat: bool) -> Vec<egui::Event> {
[true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat, modifiers: Default::default() }).to_vec()
}
/// The second of two Escape presses less than a second apart resets a rotated view. A press of
/// another key or a pointer button between them, or a longer wait, ends the pair. Shift does not.
fn selected(app: &VectorcraftApp) -> usize {
app.session.active().unwrap().selection.objects.len()
}
/// One Escape resets a rotated view when it has nothing else to end, with or without a selected
/// object, which stays selected. Shift held down with it (as after a Space+Shift drag) does not
/// change that, and the repeats of a held Escape do nothing more.
#[test]
fn escape_pressed_twice_resets_a_rotated_view() {
fn one_escape_resets_a_rotated_view() {
let mut w = Window::new();
w.escape(0.0);
assert_eq!(w.rotation(), 30.0, "one Escape");
assert_eq!(w.rotation(), 0.0, "one Escape with nothing selected");
w.escape(0.5);
assert_eq!(w.rotation(), 0.0, "a second Escape 0.5 s later");
w.escape(0.5);
assert_eq!(w.rotation(), 0.0, "a third Escape leaves the view at 0°");
assert_eq!(w.rotation(), 0.0, "a second Escape leaves the view at 0°");
let mut w = Window::new();
let empty = w.app.canvas_rect.unwrap().left_top() + egui::vec2(20.0, 20.0);
w.app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap();
w.after(0.0, vec![]);
assert_eq!(selected(&w.app), 1);
w.escape(0.0);
w.click(0.2, egui::PointerButton::Primary, empty);
w.escape(0.2);
assert_eq!(w.rotation(), 30.0, "a click between the two");
let mut w = Window::new();
w.escape(0.0);
w.after(0.2, key(egui::Key::ArrowRight, false));
w.escape(0.2);
assert_eq!(w.rotation(), 30.0, "an arrow key between the two");
let mut w = Window::new();
w.escape(0.0);
w.after(0.2, key(egui::Key::ShiftLeft, false));
w.escape(0.2);
assert_eq!(w.rotation(), 0.0, "a Shift press between the two");
assert_eq!((w.rotation(), selected(&w.app)), (0.0, 1), "one Escape with a selected object, which stays selected");
let mut w = Window::new();
w.escape(0.0);
w.escape(2.0);
assert_eq!(w.rotation(), 30.0, "two Escapes 2 s apart");
w.escape(0.5);
assert_eq!(w.rotation(), 0.0, "the later of them starts a pair");
let shifted = [true, false].map(|pressed| egui::Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed,
repeat: false,
modifiers: egui::Modifiers::SHIFT,
});
w.after(0.0, shifted.to_vec());
assert_eq!(w.rotation(), 0.0, "Shift+Escape");
// A held Escape repeats: the repeats are not a second press.
let mut w = Window::new();
w.after(0.0, vec![key(egui::Key::Escape, false)[0].clone()]);
assert_eq!(w.rotation(), 0.0, "the press of a held Escape");
w.app.run("view.rotate", json!({"angle": 30})).unwrap();
for _ in 0..5 {
w.after(0.1, vec![key(egui::Key::Escape, true)[0].clone()]);
}
w.after(0.1, vec![key(egui::Key::Escape, false)[1].clone()]);
assert_eq!(w.rotation(), 30.0, "Escape held down");
assert_eq!(w.rotation(), 30.0, "its repeats");
let mut w = Window::new();
w.app.select_tool("rotateView");
w.escape(0.0);
assert_eq!(w.rotation(), 0.0, "one Escape with the Rotate View tool");
// A hover highlight that the Escape clears does not count as something it ends.
let mut w = Window::new();
w.app.run("shape.rectangle", json!({"x": 150, "y": 100, "width": 120, "height": 80})).unwrap();
// The new object is the selection, to be put on the path under the pointer.
w.app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap();
w.app.select_tool("objectsOnPath");
let view = w.app.view_info();
let over = vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::Move, 150.0, 140.0);
w.app.session.pointer(&over, view).unwrap();
assert!(!w.app.session.overlays(view).is_empty(), "Objects on Path highlights the path under the pointer");
w.escape(0.0);
assert_eq!(w.rotation(), 0.0, "one Escape with Objects on Path hovering");
}
/// The first Escape of a pair may end something, such as isolation mode. With the Rotate View
/// tool, one Escape resets the view.
/// An Escape that ends something, such as isolation mode, leaves the view as it is. The next
/// Escape resets it, however long after it comes and whatever is clicked or pressed in between.
#[test]
fn escape_after_ending_isolation_or_with_the_rotate_view_tool_resets_the_view() {
fn escape_after_ending_isolation_resets_the_view() {
let isolated_window = || {
let mut w = Window::new();
w.app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap();
w.app.run("shape.rectangle", json!({"x": 80, "y": 20, "width": 40, "height": 40})).unwrap();
w.app.run("select.all", json!({})).unwrap();
w.app.run("object.group", json!({})).unwrap();
menus::invoke(&mut w.app, "object.isolate", json!({}));
w.after(0.0, vec![]);
w
};
let isolated = |w: &Window| w.app.session.active().unwrap().isolation.is_some();
let mut w = isolated_window();
assert!(isolated(&w));
w.escape(0.0);
assert_eq!((isolated(&w), w.rotation()), (false, 30.0), "the first Escape leaves isolation mode");
w.escape(5.0);
assert_eq!(w.rotation(), 0.0, "the second resets the view 5 s later");
let mut w = isolated_window();
let empty = w.app.canvas_rect.unwrap().left_top() + egui::vec2(20.0, 20.0);
w.escape(0.0);
assert_eq!((isolated(&w), w.rotation()), (false, 30.0));
w.click(2.0, egui::PointerButton::Primary, empty);
w.after(0.5, key(egui::Key::ArrowRight, false));
w.escape(3.0);
assert_eq!(w.rotation(), 0.0, "after a click and an arrow key between the two");
}
/// An Escape that ends the path the Pen, the Smart Pen or the Curvature tool is drawing leaves the
/// view as it is, with the rubber band shown or not; the next Escape resets it.
#[test]
fn escape_that_ends_a_path_being_drawn_keeps_the_rotation() {
for (tool, rubber_band) in [("pen", true), ("pen", false), ("smartPen", true), ("smartPen", false), ("curvature", true)] {
let mut w = Window::new();
w.app.session.prefs.pen_rubber_band = rubber_band;
w.app.select_tool(tool);
let c = w.app.canvas_rect.unwrap().center();
w.click(0.0, egui::PointerButton::Primary, c);
w.click(0.1, egui::PointerButton::Primary, c + egui::vec2(60.0, 10.0));
w.after(0.1, vec![egui::Event::PointerMoved(c + egui::vec2(60.0, 80.0))]);
assert!(w.app.session.tool_ends_on_escape(w.app.view_info()), "{tool}: a path is being drawn");
w.escape(0.2);
assert_eq!(w.rotation(), 30.0, "{tool}, rubber band {rubber_band}: the Escape that ends the path");
assert!(!w.app.session.tool_ends_on_escape(w.app.view_info()), "{tool}: the path is ended");
w.escape(3.0);
assert_eq!(w.rotation(), 0.0, "{tool}, rubber band {rubber_band}: the next Escape");
}
}
/// In isolation mode, an Escape that ends the Pen's path ends only the path: the next Escape leaves
/// isolation mode, and the one after it resets the view.
#[test]
fn escape_ends_one_thing_at_a_time() {
let mut w = Window::new();
w.app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap();
w.app.run("shape.rectangle", json!({"x": 80, "y": 20, "width": 40, "height": 40})).unwrap();
w.app.run("select.all", json!({})).unwrap();
w.app.run("object.group", json!({})).unwrap();
menus::invoke(&mut w.app, "object.isolate", json!({}));
w.after(0.0, vec![]);
let isolated = |w: &Window| w.app.session.active().unwrap().isolation.is_some();
assert!(isolated(&w));
w.escape(0.0);
assert_eq!((isolated(&w), w.rotation()), (false, 30.0), "the first Escape leaves isolation mode");
w.escape(0.5);
assert_eq!(w.rotation(), 0.0, "the second resets the view");
let mut w = Window::new();
w.app.select_tool("rotateView");
w.escape(0.0);
assert_eq!(w.rotation(), 0.0, "one Escape with the Rotate View tool");
w.app.select_tool("pen");
let c = w.app.canvas_rect.unwrap().center();
w.click(0.0, egui::PointerButton::Primary, c);
w.click(0.1, egui::PointerButton::Primary, c + egui::vec2(60.0, 10.0));
assert!(w.app.session.tool_ends_on_escape(w.app.view_info()), "a path is being drawn");
w.escape(0.2);
assert!(!w.app.session.tool_ends_on_escape(w.app.view_info()), "the first Escape ends the path");
assert_eq!((isolated(&w), w.rotation()), (true, 30.0), "and only the path");
w.escape(0.2);
assert_eq!((isolated(&w), w.rotation()), (false, 30.0), "the second leaves isolation mode");
w.escape(0.2);
assert_eq!(w.rotation(), 0.0, "the third resets the view");
}
/// An Escape that closes a popup (the status bar's angle menu, the canvas context menu) does not
/// count, so the Escape after it leaves the view as it is. An Escape while the ruler origin is
/// dragged does not reset the view either, but it counts as the first Escape of a pair.
/// The Escape that leaves the Artboard tool leaves the view as it is; the next resets it.
#[test]
fn escape_after_closing_a_popup_or_during_a_ruler_origin_drag_keeps_the_rotation() {
fn escape_after_leaving_the_artboard_tool_resets_the_view() {
let mut w = Window::new();
w.app.select_tool("artboard");
w.escape(0.0);
assert_eq!((w.app.session.tool_id(), w.rotation()), ("selection", 30.0), "the first Escape leaves the Artboard tool");
w.escape(5.0);
assert_eq!(w.rotation(), 0.0, "the next resets the view");
}
/// An Escape that a dialog or a full screen mode takes leaves the view as it is; the next resets it.
#[test]
fn escape_in_a_dialog_or_a_full_screen_mode_keeps_the_rotation() {
let mut w = Window::new();
menus::invoke(&mut w.app, "view.rotate", json!({}));
w.after(0.0, vec![]);
assert!(w.app.ui.dialog.is_some(), "Rotate View asks for an angle");
w.escape(0.0);
assert_eq!((w.app.ui.dialog.is_none(), w.rotation()), (true, 30.0), "Escape closes the dialog");
w.escape(0.5);
assert_eq!(w.rotation(), 0.0, "the next Escape");
for mode in [1, 2] {
let mut w = Window::new();
w.app.ui.screen_mode = mode;
w.after(0.0, vec![]);
w.escape(0.0);
assert_eq!((w.app.ui.screen_mode, w.rotation()), (0, 30.0), "Escape leaves screen mode {mode}");
w.escape(0.5);
assert_eq!(w.rotation(), 0.0, "the next Escape after screen mode {mode}");
// Shift+Escape does not leave a full screen mode, so it resets the view there.
let mut w = Window::new();
w.app.ui.screen_mode = mode;
w.after(0.0, vec![]);
let shifted = [true, false].map(|pressed| egui::Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed,
repeat: false,
modifiers: egui::Modifiers::SHIFT,
});
w.after(0.0, shifted.to_vec());
assert_eq!((w.app.ui.screen_mode, w.rotation()), (mode, 0.0), "Shift+Escape in screen mode {mode}");
}
let mut w = Window::new();
w.app.run("view.presentation", json!({})).unwrap();
w.after(0.0, vec![]);
assert_eq!(w.app.ui.screen_mode, 3, "Presentation Mode");
w.escape(0.0);
assert_eq!((w.app.ui.screen_mode != 3, w.rotation()), (true, 30.0), "Escape leaves Presentation Mode");
w.escape(0.5);
assert_eq!(w.rotation(), 0.0, "the next Escape after Presentation Mode");
}
/// An Escape while About or an Appearance row's popup is open, or while a pointer button is held
/// outside the canvas (a scroll bar, a tab or a panel being dragged) or let go in the same frame,
/// leaves the view as it is.
#[test]
fn escape_with_a_window_open_or_a_button_held_keeps_the_rotation() {
let mut w = Window::new();
w.app.ui.about = true;
w.after(0.0, vec![]);
w.escape(0.0);
assert_eq!(w.rotation(), 30.0, "an Escape with About open");
// An Appearance row's popup (here, the Stroke panel's) closes on Escape as the frame is drawn.
let mut w = Window::new();
w.app.ui.transient_panel = Some("stroke".into());
w.escape(0.0);
assert_eq!(w.rotation(), 30.0, "an Escape with an Appearance row's popup open");
let mut w = Window::new();
let bar = w.app.canvas_rect.unwrap().right_center() + egui::vec2(4.0, 0.0);
let button = |pressed| egui::Event::PointerButton { pos: bar, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
w.after(0.0, vec![egui::Event::PointerMoved(bar), button(true)]);
w.escape(0.1);
assert_eq!(w.rotation(), 30.0, "an Escape with the button held");
let mut release = vec![button(false)];
release.extend(key(egui::Key::Escape, false));
w.after(0.1, release);
assert_eq!(w.rotation(), 30.0, "an Escape in the frame the button is let go");
w.escape(0.1);
assert_eq!(w.rotation(), 0.0, "an Escape after the release");
}
/// An Escape in a text field ends the editing and leaves the view as it is.
#[test]
fn escape_in_a_text_field_keeps_the_rotation() {
let mut w = Window::new();
w.ctx.enable_accesskit();
let out = w.after(0.0, vec![]);
let tree = out.platform_output.accesskit_update.expect("the window's accessibility tree");
let field = tree.nodes.iter().find(|(_, node)| node.role() == egui::accesskit::Role::TextInput).map(|(id, _)| *id).expect("a text field");
let field = w
.ctx
.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, ws)| ws.iter()).find(|r| r.id.accesskit_id() == field).copied())
.expect("the text field's widget");
w.ctx.memory_mut(|m| m.request_focus(field.id));
w.after(0.0, vec![]);
assert!(w.ctx.egui_wants_keyboard_input(), "the field has the keyboard");
w.escape(0.0);
assert_eq!(w.rotation(), 30.0, "the Escape in the field");
}
/// An Escape that closes a popup (the status bar's angle menu, the canvas context menu) or ends a
/// drag of the ruler origin leaves the view as it is; the next Escape resets it.
#[test]
fn escape_that_closes_a_popup_or_ends_a_ruler_origin_drag_keeps_the_rotation() {
let mut w = Window::new();
// Find the actual combo by its accessible name, which is present in both build profiles;
// `Id`'s debug source (including "rot-combo") only exists with debug assertions enabled.
@@ -354,8 +533,9 @@ fn escape_after_closing_a_popup_or_during_a_ruler_origin_drag_keeps_the_rotation
assert!(craft_ui::ComboBox::is_open(&w.ctx, combo.id), "the angle menu is open");
w.escape(0.2);
assert!(!egui::Popup::is_any_open(&w.ctx), "Escape closes it");
assert_eq!(w.rotation(), 30.0, "the Escape that closes the angle menu");
w.escape(0.2);
assert_eq!(w.rotation(), 30.0, "an Escape after the angle menu closed");
assert_eq!(w.rotation(), 0.0, "the next Escape");
let mut w = Window::new();
w.click(0.0, egui::PointerButton::Secondary, w.app.canvas_rect.unwrap().center());
@@ -363,8 +543,9 @@ fn escape_after_closing_a_popup_or_during_a_ruler_origin_drag_keeps_the_rotation
assert!(egui::Popup::is_any_open(&w.ctx), "a right click opens the context menu");
w.escape(0.2);
assert!(!egui::Popup::is_any_open(&w.ctx), "Escape closes it");
assert_eq!(w.rotation(), 30.0, "the Escape that closes the context menu");
w.escape(0.2);
assert_eq!(w.rotation(), 30.0, "an Escape after the context menu closed");
assert_eq!(w.rotation(), 0.0, "the next Escape");
// With the Rotate View tool, the Escape that closes the menu leaves the view as it is too.
let mut w = Window::new();
w.app.select_tool("rotateView");
@@ -391,5 +572,5 @@ fn escape_after_closing_a_popup_or_during_a_ruler_origin_drag_keeps_the_rotation
assert_eq!(w.rotation(), 30.0, "two Escapes while the ruler origin is dragged");
w.after(0.1, vec![button(to, false)]);
w.escape(0.2);
assert_eq!(w.rotation(), 0.0, "an Escape after the drag ends pairs with the last one");
assert_eq!(w.rotation(), 0.0, "an Escape after the drag ends");
}
+2 -2
View File
@@ -677,7 +677,7 @@ fn a_tool_flyout_takes_escape_before_full_screen() {
}
#[test]
fn a_rotation_pair_exits_full_screen_then_resets_with_selection_tool() {
fn escape_exits_full_screen_then_resets_the_rotation_with_selection_tool() {
for mode in [1, 2] {
let ctx = egui::Context::default();
let mut app = in_screen_mode(&ctx, mode);
@@ -686,7 +686,7 @@ fn a_rotation_pair_exits_full_screen_then_resets_with_selection_tool() {
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}");
assert_eq!(app.view().unwrap().rotation, 0.0, "the next Escape after mode {mode}");
}
}
+1 -1
View File
@@ -1031,7 +1031,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
View › Rotate View turns the view to the status bar's preset angles (`view.rotate {angle}`, which takes any angle). View › Rotate View to Selection turns the view to the angle of the selected objects' bounding box, so the box is square to the window (`view.rotateToSelection`).
When the view angle changes (a Rotate View drag, the status bar's angle menu, Reset Rotate View or a saved view) and the desktop app renders the frame at the new angle on the render worker, the canvas draws the last frame turned to the new angle until that frame is ready. It used to draw no art until then.
While the desktop app's render worker renders the frame for a new view, the parts of the canvas the last frame does not cover after a pan, a zoom-out or a turn show the art from a frame that a second worker renders at a quarter of the resolution, covering twice the canvas's width and height. That frame draws images only from pixels already decoded (an image not yet decoded shows as a light gray box), and it renders large raster effects at the lower resolution a drag uses. Until it is ready, those parts show the pasteboard and the artboards. They used to show only the pasteboard and the artboards until the frame for the new view was ready.
With the view rotated, a second Escape less than a second after the first resets the rotation, as Reset Rotate View does. The first Escape may end something else, such as isolation mode or a path being drawn. The second resets the view only when it has nothing else to end, and an Escape that closes a menu does not count. After a click, or a press of another key that is not a modifier (Shift, Control, Alt/Option or Command), the next Escape counts as the first. With the Rotate View tool chosen, one Escape resets the view.
With the view rotated, an Escape that has nothing else to end resets the rotation, as Reset Rotate View does, with or without a selection, which stays selected. An Escape that ends something else ends only that, and the next Escape resets the view: to reset the view while another tool or feature is in use, Escape is pressed twice ([Rotate canvas view](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/rotate-canvas-view.html)). Under VectorCraft's own rule, the things an Escape ends first are isolation mode, pattern editing, Global Edit, a tool flyout, a full screen mode, Presentation Mode, a path being drawn with the Pen, Smart Pen or Curvature tool, the Blend tool's first pick, the Artboard tool, a tool's drag, a menu, a dialog, a text field and a drag of the ruler origin, and the second press may come any time later, after clicks or other keys. With the Rotate View tool, the view resets before a full screen mode ends; with another tool, Escape leaves a full screen mode first, and Shift+Escape, which does not leave it, resets the view. An Escape does not reset the view while About, a progress window or an Appearance row's popup is open, while a pointer button is held, or in the frame a button is let go. It used to reset the view only as the second of two Escape presses less than a second apart.
A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5° (Preferences › General › Trackpad Gesture to Rotate View, on by default).
On a touch screen a second finger cancels the tool press the first finger began, in the frame it lands or later: the Pencil's stroke or a Selection move is rolled back with no undo entry, and a finger that lands while another is down presses nothing, so a pinch or two-finger pan only zooms and pans (#4198; canvas test `a_second_finger_cancels_the_tool_gesture_the_first_began`).
A tool press dropped by a second finger, a long touch or a canceled touch cancels the tool's drag as Escape does: the tool gets back the options it had when the press began (the Polygon tool's sides, set with ↑ and ↓) and they are not stored, a press held still opens no size dialog, a Selection or Artboard tool marquee selects nothing, and the Pen goes on drawing its path without the dropped anchor (canvas tests `a_canceled_touch_drops_the_drag_without_keeping_its_options` and `long_touch_drops_a_tool_press_without_committing_art`; engine tests in `tests::toolsettings`).