mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
Rotate View: Escape pressed twice resets a rotated view
Escape never changed the view's rotation. Now a second Escape less than a second after the first runs Reset Rotate View (`view.rotateReset`) when the view is rotated and the second press has nothing else to end: no Presentation Mode, pattern editing, isolation mode or open tool flyout, no tool that is busy or takes the key, no open menu, dialog, command palette or Edit Toolbar drawer, no text field or input method that takes the key, no number field being dragged and no drag of the ruler origin. The first Escape may end something, such as isolation mode, but it does not count as the first when a menu, a dialog, the command palette, the drawer, a field or an input method takes it. After a click, a press of another key that is not a modifier (Shift, Control, Alt/Option or Command) or a held Escape's repeats, the next Escape counts as the first. With the Rotate View tool chosen, one Escape resets the view. Docs: docs/target-app-parity.md.
This commit is contained in:
@@ -1241,7 +1241,7 @@ fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui
|
||||
}
|
||||
// Keep previews in the UI: only a release on the canvas edits the document, once. Cancelling
|
||||
// never adds an undo step or interferes with the drawing tool's interaction.
|
||||
let drag_id = egui::Id::new("ruler-origin-drag");
|
||||
let drag_id = ruler_origin_drag_id();
|
||||
let uid = app.session.active().map(|d| d.uid);
|
||||
if corner.drag_started_by(egui::PointerButton::Primary) {
|
||||
ui.data_mut(|d| d.insert_temp(drag_id, (uid, false)));
|
||||
@@ -1270,6 +1270,17 @@ fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui
|
||||
preview
|
||||
}
|
||||
|
||||
/// The key of the ruler origin drag's state in egui's temporary data.
|
||||
fn ruler_origin_drag_id() -> egui::Id {
|
||||
egui::Id::new("ruler-origin-drag")
|
||||
}
|
||||
|
||||
/// Is a drag from the rulers' corner in progress? It stays true after Escape cancels the move, until
|
||||
/// the button is released.
|
||||
pub(crate) fn ruler_origin_dragging(ctx: &egui::Context) -> bool {
|
||||
ctx.data(|d| d.get_temp::<(Option<u64>, bool)>(ruler_origin_drag_id()).is_some())
|
||||
}
|
||||
|
||||
fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit, origin: Point, t: &Tokens) {
|
||||
let p = ui.painter();
|
||||
let [top, left, corner] = ruler_rects(full);
|
||||
|
||||
@@ -241,6 +241,8 @@ fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
|
||||
pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
// 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.
|
||||
@@ -294,8 +296,17 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let _ = app.run("object.exitIsolation", 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;
|
||||
}
|
||||
}
|
||||
// An Escape that ended something can start a pair too. One that closed a popup cannot.
|
||||
if k == 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));
|
||||
}
|
||||
if k == Key::Enter && !busy && !claimed {
|
||||
// Enter with a selection tool opens the Move dialog, as a double-click on its button
|
||||
// does.
|
||||
@@ -562,6 +573,49 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn escape_pair_id() -> egui::Id {
|
||||
egui::Id::new("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.
|
||||
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,
|
||||
/// the ruler origin is not being dragged, and the press is the second of a pair (`second`) or the
|
||||
/// Rotate View tool is chosen.
|
||||
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)
|
||||
}
|
||||
|
||||
/// Go to the next artboard (`next`) or the previous one with `view.goToArtboard`, as the status bar
|
||||
/// navigator's Next and Previous buttons do, for the arrow keys and a click in Presentation Mode.
|
||||
/// On the last artboard a step forward leaves the view as it is, and so does a step back on the
|
||||
|
||||
@@ -119,3 +119,182 @@ fn only_the_submenu_name_needs_translating() {
|
||||
assert!(!strings.contains(label), "{label}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The window, with a 400 × 300 document whose view is turned to 30°, and the Selection tool. Each
|
||||
/// frame comes the given number of seconds, zero or more, after the one before it.
|
||||
struct Window {
|
||||
app: VectorcraftApp,
|
||||
ctx: egui::Context,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
fn new() -> Self {
|
||||
let mut w = Self { app: with_document(), ctx: egui::Context::default(), time: 0.0 };
|
||||
w.app.select_tool("selection");
|
||||
w.app.run("view.rotate", json!({"angle": 30})).unwrap();
|
||||
w.after(0.0, vec![]);
|
||||
w.after(0.0, vec![]);
|
||||
w
|
||||
}
|
||||
|
||||
/// One headless frame of the whole window, `seconds` after the last one, with `events`.
|
||||
fn after(&mut self, seconds: f64, events: Vec<egui::Event>) {
|
||||
self.time += seconds;
|
||||
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
|
||||
let raw = egui::RawInput { screen_rect: Some(rect), time: Some(self.time), events, ..Default::default() };
|
||||
let app = &mut self.app;
|
||||
let mut out = self.ctx.run_ui(raw, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// A press and release of Escape, `seconds` after the last frame.
|
||||
fn escape(&mut self, seconds: f64) {
|
||||
self.after(seconds, key(egui::Key::Escape, false));
|
||||
}
|
||||
|
||||
/// A press of `button` at `pos` and its release, a frame each, `seconds` after the last frame.
|
||||
fn click(&mut self, seconds: f64, button: egui::PointerButton, pos: egui::Pos2) {
|
||||
let press = |pressed| egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() };
|
||||
self.after(seconds, vec![egui::Event::PointerMoved(pos), press(true)]);
|
||||
self.after(0.0, vec![press(false)]);
|
||||
}
|
||||
|
||||
fn rotation(&self) -> f64 {
|
||||
self.app.view().unwrap().rotation
|
||||
}
|
||||
}
|
||||
|
||||
/// A press and release of `key`. With `repeat`, the press is a key repeat.
|
||||
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.
|
||||
#[test]
|
||||
fn escape_pressed_twice_resets_a_rotated_view() {
|
||||
let mut w = Window::new();
|
||||
w.escape(0.0);
|
||||
assert_eq!(w.rotation(), 30.0, "one Escape");
|
||||
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°");
|
||||
|
||||
let mut w = Window::new();
|
||||
let empty = w.app.canvas_rect.unwrap().left_top() + egui::vec2(20.0, 20.0);
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
// 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()]);
|
||||
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");
|
||||
}
|
||||
|
||||
/// The first Escape of a pair may end something, such as isolation mode. With the Rotate View
|
||||
/// tool, one Escape resets the view.
|
||||
#[test]
|
||||
fn escape_after_ending_isolation_or_with_the_rotate_view_tool_resets_the_view() {
|
||||
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!({}));
|
||||
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");
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn escape_after_closing_a_popup_or_during_a_ruler_origin_drag_keeps_the_rotation() {
|
||||
let mut w = Window::new();
|
||||
// The menu's button has the id `ComboBox::from_id_salt("rot-combo")` gives it in the status bar's
|
||||
// row, which hosts other widgets too.
|
||||
let salt = egui::IdSalt::new("rot-combo");
|
||||
let widgets: Vec<egui::WidgetRect> = w.ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, ws)| ws.iter().copied()).collect());
|
||||
let combo = widgets.iter().find(|r| widgets.iter().any(|row| r.id == row.parent_id.with(salt))).copied();
|
||||
let combo = combo.expect("the status bar's angle menu");
|
||||
w.click(0.0, egui::PointerButton::Primary, combo.rect.center());
|
||||
w.after(0.0, vec![]);
|
||||
assert!(egui::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");
|
||||
w.escape(0.2);
|
||||
assert_eq!(w.rotation(), 30.0, "an Escape after the angle menu closed");
|
||||
|
||||
let mut w = Window::new();
|
||||
w.click(0.0, egui::PointerButton::Secondary, w.app.canvas_rect.unwrap().center());
|
||||
w.after(0.0, vec![]);
|
||||
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");
|
||||
w.escape(0.2);
|
||||
assert_eq!(w.rotation(), 30.0, "an Escape after the context menu closed");
|
||||
// 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");
|
||||
w.click(0.0, egui::PointerButton::Secondary, w.app.canvas_rect.unwrap().center());
|
||||
w.after(0.0, vec![]);
|
||||
assert!(egui::Popup::is_any_open(&w.ctx), "a right click opens the context menu");
|
||||
w.escape(0.2);
|
||||
assert_eq!(w.rotation(), 30.0, "the Escape that closes the context menu");
|
||||
w.escape(0.2);
|
||||
assert_eq!(w.rotation(), 0.0, "the next Escape with the Rotate View tool");
|
||||
|
||||
let mut w = Window::new();
|
||||
w.app.ui.view.rulers = true;
|
||||
w.after(0.0, vec![]);
|
||||
let corner = w.ctx.read_response(egui::Id::new("ruler-corner")).expect("the rulers' corner").rect.center();
|
||||
let to = w.app.canvas_rect.unwrap().center();
|
||||
w.after(0.0, vec![egui::Event::PointerMoved(corner)]);
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
w.after(0.0, vec![button(corner, true)]);
|
||||
w.after(0.1, vec![egui::Event::PointerMoved(to)]);
|
||||
assert!(crate::canvas::ruler_origin_dragging(&w.ctx));
|
||||
w.escape(0.2);
|
||||
w.escape(0.2);
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -514,6 +514,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
The canvas draws the active artboard's edge in black, as the public [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html) page describes. It draws the other artboards' edges in gray. With several artboards selected by the Artboard tool, the canvas draws the black edge around the active one, which the tool shows with handles and its name. It used to draw artboard 1's edge in black whichever artboard was active.
|
||||
View › Rotate View turns the view to the status bar's preset angles (`view.rotate {angle}`, which takes any angle).
|
||||
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.
|
||||
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.
|
||||
The status bar navigator's field shows the active artboard's number and name, and its drop-down lists every artboard by number and name, as the Artboards panel does. Choosing an artboard in the list makes it the active artboard and fits it in the window (`view.goToArtboard`). The list opens on the active artboard's row and lays out only the rows in view and at most one more.
|
||||
- **Geometry and effects:**
|
||||
- **Open-path anchor Delete:** Delete/Backspace removes selected anchors and their adjoining segments, leaving drawable runs as separate open subpaths of the original object. Surviving curves and appearance stay intact; one Undo restores the document and selection, and a second Delete removes the selected remainder. Explicit Remove Anchor Points still reconnects/refits. Four core regressions, one full-frame Delete/Backspace regression and three existing removal/segment controls pass, covering these distinctions, multiple deletions, transformed geometry and mixed whole-object selection. Behaviour references: the legacy public [Illustrator reference guide, Add or delete an anchor point](https://helpx.adobe.com/content/dam/help/en/pdf/illustrator_reference.pdf) distinguishes keyboard deletion from dedicated removal; the current [Add or remove anchor points](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/add-or-remove-anchor-points.html) describes the dedicated tools. Other G11 gaps and scores/hours remain unchanged.
|
||||
|
||||
Reference in New Issue
Block a user