From 97c31e076f4f809241c711562ea7eacc48363d7b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 12:01:38 -0800 Subject: [PATCH] Shortcuts: holding a tool's single-key shortcut uses the tool until the key is let go Holding a single-key tool shortcut becomes temporary: releasing it returns to the previous tool (a quick tap still switches for good). With the Pen chosen, holding S chooses Scale; letting go of S after 0.25 s or more, or after Scale was used while S was down, chooses the Pen again. A press shorter than 0.25 s, with nothing done in between, keeps Scale chosen as before. The 0.25 s threshold is VectorCraft's own value. A Cmd/Ctrl click, which the selection tool takes, does not count as using Scale. A drag of the Hand, Zoom or Rotate View tool chosen this way counts as using it. A key let go during a drag, with the pointer button held or with a dialog open, goes back once that ends. A key let go while the tool waits for its next click (the Dimension tool after its first point) goes back once the tool finishes or is canceled. With S held from the Pen and then V held, letting go of V returns to Scale, and letting go of S then returns to the Pen. Choosing a tool on the toolbar or from a menu while the key is down, the held tool included, ends the hold and keeps that tool, and so does the window losing the keyboard. While the preference is on, the key's repeats do not choose the tool again. The new General preference Hold a Tool Shortcut to Use the Tool Temporarily (`holdToolKeys`, on by default) turns the behavior off. With it off, every press and every repeat chooses the tool, as before. Its label has a row in every catalog. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/prefscmds.rs | 1 + crates/engine/src/prefs.rs | 4 + crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/commands.rs | 4 +- crates/ui-egui/src/canvas/drag.rs | 4 + crates/ui-egui/src/canvas/input.rs | 5 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/shortcuts.rs | 24 +- crates/ui-egui/src/shortcuts/held_tool.rs | 154 ++++++ .../ui-egui/src/shortcuts/held_tool/tests.rs | 462 ++++++++++++++++++ docs/mcp.md | 6 +- docs/target-app-parity.md | 2 +- 39 files changed, 685 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/src/shortcuts/held_tool.rs create mode 100644 crates/ui-egui/src/shortcuts/held_tool/tests.rs diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index f0829932b..a702f1580 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -152,6 +152,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool), p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool), p!("trackpadRotateView", "General", "Options", "Trackpad Gesture to Rotate View", bool), + p!("holdToolKeys", "General", "Options", "Hold a Tool Shortcut to Use the Tool Temporarily", bool), // Selection & Anchor Display p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")), p!("objectSelectionByPathOnly", "Selection & Anchor Display", "Selection", "Object Selection by Path Only", bool), diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 7730075ea..82946ac29 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -58,6 +58,9 @@ pub struct Prefs { pub scrub_numeric_fields: bool, /// A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5°. pub trackpad_rotate_view: bool, + /// A single-key tool shortcut held down chooses its tool until the key is let go, and then the + /// tool active before comes back; a quick press chooses the tool for good. + pub hold_tool_keys: bool, /// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this). pub paste_offset: f64, // Selection & Anchor Display @@ -366,6 +369,7 @@ impl Default for Prefs { zoom_with_mouse_wheel: false, scrub_numeric_fields: true, trackpad_rotate_view: true, + hold_tool_keys: true, paste_offset: 10.0, selection_tolerance: 3.0, object_selection_by_path_only: false, diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 79d3d8f0d..a74582972 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -171,6 +171,7 @@ impl VectorcraftApp { tick_iteration: None, input_frames: vec![], shortcuts_frames: vec![], + held_tool: Vec::new(), synthetic_injected: false, injected_events: vec![], injected_generation: InputGeneration::default(), diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 982972770..985f98c34 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -370,8 +370,10 @@ impl VectorcraftApp { /// Select a tool (also used by the toolbar and shortcuts). pub fn select_tool(&mut self, id: &str) { - // The tip of a pen whose eraser end selected the Eraser doesn't undo this choice. + // The tip of a pen whose eraser end selected the Eraser doesn't undo this choice, and + // neither does letting go of a held tool shortcut. self.pen_end.forget_tool(); + self.held_tool.clear(); self.activate_tool(id, true); } diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 1980661b8..63b4ef9c9 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -363,6 +363,10 @@ fn end_art_move(app: &mut VectorcraftApp, ctx: &egui::Context) { /// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches). pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_engine::ViewInfo) { + // A press lent to a selection tool (Cmd/Ctrl held) does not use the tool of a held key. + if ev.kind == PointerKind::Down && app.session.lent_tool_id(&ev.mods).is_none() { + crate::shortcuts::note_tool_use(app); + } let r = app.session.pointer(ev, view); // The artboard the Artboard tool makes active is the active one (the navigator's, the // Artboards panel's). diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index e0bd9f029..49baf494b 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -290,6 +290,11 @@ fn begin_drag(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) { crate::document_drag::begin(app, ui.ctx(), ev.pos); Drag::Tool }; + // A drag of the Zoom, Hand or Rotate View tool uses the tool of a held shortcut; held Space's + // view tools are not that tool. + if !space && matches!(d, Drag::ZoomBox { .. } | Drag::ZoomScrub { .. } | Drag::Pan { .. } | Drag::RotateView { .. }) { + crate::shortcuts::note_tool_use(app); + } // Panning, zooming or turning the view takes the pointer from the tool's hover. if !tool_holds(d) { app.session.tool_hover_ended(view); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index df2b9b4d8..c705a0b0f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2755,6 +2755,7 @@ digits Malayalam Малаялам Rotate Tool Инструмент „Завъртане“ Rotate View Tool Инструмент „Завъртане на изглед“ Trackpad Gesture to Rotate View Завъртане на изгледа с жест на тракпада + Hold a Tool Shortcut to Use the Tool Temporarily Задържане на клавиша на инструмент за временното му използване Rotate… Завъртане… Rotation Завъртане Roughen… Грапавост… diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index ac0a9cc5f..7278fec3e 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1298,6 +1298,7 @@ lighting Light Svjetlo Rotate Tool Alat za rotaciju Rotate View Tool Alat za rotaciju prikaza Trackpad Gesture to Rotate View Pokret dodirne ploče za rotaciju prikaza + Hold a Tool Shortcut to Use the Tool Temporarily Držite prečicu alata za privremenu upotrebu alata Rotate View Rotiraj prikaz Rounded Rectangle Tool Alat zaobljeni pravougaonik Scale Tool Alat za skaliranje diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 22ce8e4eb..b1f2b8d76 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -4429,6 +4429,7 @@ digits Malayalam Malaiàlam Tracing Result with Outlines Resultat del traçat amb contorns Tracking Espaiat Trackpad Gesture to Rotate View Gesticulació del trackpad per girar la vista + Hold a Tool Shortcut to Use the Tool Temporarily Mantén premuda la drecera d’una eina per usar-la temporalment Trademark Symbol Símbol de marca comercial Transform Transforma Transform Again Transforma de nou diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 12421cd4b..ede490b5b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1319,6 +1319,7 @@ lighting Light Světlo Rotate Tool Nástroj Otočení Rotate View Tool Nástroj Otočení zobrazení Trackpad Gesture to Rotate View Otáčet zobrazení gestem na trackpadu + Hold a Tool Shortcut to Use the Tool Temporarily Podržením klávesové zkratky nástroj dočasně použít Rotate View Otočení zobrazení Rounded Rectangle Tool Nástroj Zaoblený obdélník Scale Tool Nástroj Změna velikosti diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 8ab0e613c..e45b811d6 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1232,6 +1232,7 @@ lighting Light Lys Rotate Tool Roteringsværktøj Rotate View Tool Visningsroteringsværktøj Trackpad Gesture to Rotate View Rotér visning med bevægelse på pegefeltet + Hold a Tool Shortcut to Use the Tool Temporarily Hold et værktøjs genvejstast nede for at bruge værktøjet midlertidigt Rotate View Rotér visning Rounded Rectangle Tool Afrundet rektangelværktøj Scale Tool Skaleringsværktøj diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7f2fc4ca8..f09fec1f4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2783,6 +2783,7 @@ digits Malayalam Malayalam Rotate Tool Drehen-Werkzeug Rotate View Tool Ansichtdrehung-Werkzeug Trackpad Gesture to Rotate View Ansicht mit Trackpad-Geste drehen + Hold a Tool Shortcut to Use the Tool Temporarily Werkzeug-Tastaturbefehl gedrückt halten, um das Werkzeug vorübergehend zu verwenden Rotate… Drehen… Rotation Drehung Roughen… Aufrauen… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8ad6f40d3..c73891b41 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1895,6 +1895,7 @@ font-technology Variable Μεταβλητές Rotate Περιστροφή Rotate View Tool Εργαλείο περιστροφής προβολής (Rotate View Tool) Trackpad Gesture to Rotate View Περιστροφή προβολής με χειρονομία στην επιφάνεια αφής + Hold a Tool Shortcut to Use the Tool Temporarily Προσωρινή χρήση εργαλείου κρατώντας πατημένη τη συντόμευσή του Rotate… Περιστροφή… Rotation Περιστροφή Rows: Γραμμές: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9114fa1d8..7ae15f607 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2825,6 +2825,7 @@ digits Malayalam Malayálam Rotate Tool Herramienta Rotar Rotate View Tool Herramienta Rotar vista Trackpad Gesture to Rotate View Rotar vista con un gesto del trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Mantener pulsado el atajo de una herramienta para usarla temporalmente Rotate… Rotar… Rotation Rotación Roughen… Hacer áspero… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 433a38e89..78650f468 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1295,6 +1295,7 @@ lighting Light Valo Rotate Tool Kiertotyökalu Rotate View Tool Näkymän kiertotyökalu Trackpad Gesture to Rotate View Kierrä näkymää ohjauslevyn eleellä + Hold a Tool Shortcut to Use the Tool Temporarily Käytä työkalua tilapäisesti pitämällä sen pikanäppäintä painettuna Rotate View Kierrä näkymää Rounded Rectangle Tool Pyöristetyn suorakulmion työkalu Scale Tool Skaalaustyökalu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index dd24d124e..b0e3d2e13 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2817,6 +2817,7 @@ digits Malayalam Malayalam Rotate Tool Outil Rotation Rotate View Tool Outil Faire pivoter l’affichage Trackpad Gesture to Rotate View Faire pivoter l’affichage avec un geste du trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Maintenir le raccourci d’un outil pour l’utiliser temporairement Rotate… Rotation… Rotation Rotation Roughen… Rugosité… diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index b2a596b6f..a640f785a 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -4833,6 +4833,7 @@ Tracing the colors… रंग अनुरेखण… Tracking ट्रैकिंग Trackpad Gesture to Rotate View दृश्य घुमाने के लिए ट्रैकपैड जेस्चर + Hold a Tool Shortcut to Use the Tool Temporarily किसी टूल का शॉर्टकट दबाए रखकर उसे अस्थायी रूप से उपयोग करें Trademark Symbol ट्रेडमार्क सिंबल Transform रूपांतरण करें Transform Again फिर से बदलना diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 4c79d9415..2c178e211 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3392,6 +3392,7 @@ mark-style Japanese Japán Rotate View Nézet elforgatása Rotate View Tool Nézet elforgatása Trackpad Gesture to Rotate View Nézet elforgatása kézmozdulattal az érintőpadon + Hold a Tool Shortcut to Use the Tool Temporarily Eszköz ideiglenes használata a billentyűparancsa nyomva tartásával Rotate… Elforgatás… Rotation Elforgatás Rotation relative to: Elforgatás viszonyítási alapja: diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f4d529a6f..11d5b3a7f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1232,6 +1232,7 @@ lighting Light Cahaya Rotate Tool Alat putar Rotate View Tool Alat putar tampilan Trackpad Gesture to Rotate View Putar tampilan dengan gestur trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Tahan pintasan alat untuk menggunakan alat itu sementara Rotate View Putar tampilan Rounded Rectangle Tool Alat persegi panjang bulat Scale Tool Alat skala diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 62e34497e..b65f57fd6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2859,6 +2859,7 @@ digits Malayalam Malayalam Rotate Tool Strumento Ruota Rotate View Tool Strumento Ruota vista Trackpad Gesture to Rotate View Ruota vista con un gesto sul trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Tieni premuta la scorciatoia di uno strumento per usarlo temporaneamente Rotate… Ruota… Rotation Rotazione Roughen… Irruvidisci… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c39d9a307..60e633c72 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2816,6 +2816,7 @@ mark-style Japanese 日本式 Rotate Tool 回転ツール Rotate View Tool 回転ビューツール Trackpad Gesture to Rotate View トラックパッドのジェスチャーでビューを回転 + Hold a Tool Shortcut to Use the Tool Temporarily ツールのショートカットを押している間だけツールを使用 Rotation 回転 Round Cap 丸型線端 Round Join ラウンド結合 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 49b8f680b..b188ce5e3 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5523,6 +5523,7 @@ imageTrace Outline კონტური The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. შენახვის პანელში სახელი იყო „{typed}“; VectorCraft-მა ფაილის ტიპის გაფართოება დაამატა. ფაილის ჩანაცვლება მის შიგთავსს გადააწერს. Thread Text ტექსტის დაკავშირება Trackpad Gesture to Rotate View ხედის შემობრუნება ტრეკპადის ჟესტით + Hold a Tool Shortcut to Use the Tool Temporarily ხელსაწყოს დროებით გამოყენება მისი მალსახმობის დაჭერით Typing აკრეფა Uniformly Scaled თანაბრად მასშტაბირებული Unlock Variables ცვლადების განბლოკვა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 42fbefc5f..730d1f0bd 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2805,6 +2805,7 @@ mark-style Japanese 일본식 Rotate Tool 회전 도구 Rotate View Tool 보기 회전 도구 Trackpad Gesture to Rotate View 트랙패드 제스처로 보기 회전 + Hold a Tool Shortcut to Use the Tool Temporarily 도구 단축키를 누르고 있는 동안 도구를 임시로 사용 Rotation 회전 Round Cap 둥근 끝 Round Join 둥근 연결 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ebcf89356..9a4b8f1e4 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1246,6 +1246,7 @@ lighting Light Licht Rotate Tool Draaien-gereedschap Rotate View Tool Weergave draaien-gereedschap Trackpad Gesture to Rotate View Weergave draaien met trackpadgebaar + Hold a Tool Shortcut to Use the Tool Temporarily Sneltoets van een gereedschap ingedrukt houden om het tijdelijk te gebruiken Rotate View Weergave draaien Rounded Rectangle Tool Afgeronde rechthoek-gereedschap Scale Tool Schalen-gereedschap diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 512690d39..35e944341 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1241,6 +1241,7 @@ lighting Light Światło Rotate Tool Narzędzie Obrót Rotate View Tool Narzędzie Obrót widoku Trackpad Gesture to Rotate View Obracaj widok gestem na gładziku + Hold a Tool Shortcut to Use the Tool Temporarily Używaj narzędzia tymczasowo, przytrzymując jego skrót Rounded Rectangle Tool Narzędzie Zaokrąglony prostokąt Scale Tool Narzędzie Skalowanie Scallop Tool Narzędzie Ząbkowanie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f17fb4464..b155fd262 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2360,6 +2360,7 @@ digits Malayalam Malaiala Rotate Tool Ferramenta Girar Rotate View Tool Ferramenta Girar Visualização Trackpad Gesture to Rotate View Girar Visualização com Gesto do Trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Manter o Atalho de uma Ferramenta Pressionado para Usá-la Temporariamente Rotate… Girar… Rotation Rotação Roughen… Enrugar… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0f200c341..1e8152df6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2854,6 +2854,7 @@ digits Malayalam Малаялам Rotate Tool Инструмент «Поворот» Rotate View Tool Инструмент «Поворот вида» Trackpad Gesture to Rotate View Поворачивать вид жестом трекпада + Hold a Tool Shortcut to Use the Tool Temporarily Временно использовать инструмент, удерживая его клавишу Rotate… Повернуть… Rotation Поворот Roughen… Огрубление… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 5ed1cf9ca..86d4de86d 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2761,6 +2761,7 @@ digits Malayalam Malayalam Rotate Tool Rotationsverktyg Rotate View Tool Verktyget Rotera vy Trackpad Gesture to Rotate View Rotera vyn med en gest på styrplattan + Hold a Tool Shortcut to Use the Tool Temporarily Håll ned ett verktygs kortkommando för att använda verktyget tillfälligt Rotate… Rotera… Rotation Rotation Roughen… Ojämn… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c4ec25012..31e0cfc4b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5440,6 +5440,7 @@ theme Appearance Görünüm The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. Kayıt panelindeki ad “{typed}” idi; VectorCraft dosya türünün uzantısını ekledi. Dosyayı değiştirmek içeriğinin üzerine yazar. Thread Text Metni Bağla Trackpad Gesture to Rotate View Görünümü Döndürmek İçin İzleme Dörtgeni Hareketi + Hold a Tool Shortcut to Use the Tool Temporarily Aracı Geçici Olarak Kullanmak İçin Kısayolunu Basılı Tutma Typing Yazma Uniformly Scaled Tekdüze Ölçeklenmiş Unlock Variables Değişkenlerin Kilidini Aç diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ae131c2e5..bd35aa641 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2851,6 +2851,7 @@ digits Malayalam Малаялам Rotate Tool Інструмент «Поворот» Rotate View Tool Інструмент «Поворот виду» Trackpad Gesture to Rotate View Повертати вид жестом трекпада + Hold a Tool Shortcut to Use the Tool Temporarily Тимчасово використовувати інструмент, утримуючи його клавішу Rotate… Повернути… Rotation Поворот Roughen… Надати нерівності… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f36b874d1..aa362d4c5 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3147,6 +3147,7 @@ mark-style Japanese Kiểu Nhật Rotate Tool Công cụ Xoay Rotate View Tool Công cụ Xoay chế độ xem Trackpad Gesture to Rotate View Xoay chế độ xem bằng cử chỉ trên bàn di chuột + Hold a Tool Shortcut to Use the Tool Temporarily Giữ phím tắt của công cụ để tạm thời dùng công cụ đó Rotate… Xoay… Rotation Góc xoay Rotation relative to: Xoay tương đối với: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index dafd7dd3c..10900cfc6 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2728,6 +2728,7 @@ digits Malayalam 马拉雅拉姆数字 Rotate Tool 旋转工具 Rotate View Tool 旋转视图工具 Trackpad Gesture to Rotate View 使用触控板手势旋转视图 + Hold a Tool Shortcut to Use the Tool Temporarily 按住工具快捷键以临时使用该工具 Rotate… 旋转… Rotation 旋转 Roughen… 粗糙效果… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 10f0df2b9..190805ce2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3724,6 +3724,7 @@ digits Malayalam 馬來亞拉姆數字 Rotate Tool 旋轉工具 Rotate View Tool 旋轉檢視工具 Trackpad Gesture to Rotate View 使用觸控式軌跡板手勢旋轉檢視 + Hold a Tool Shortcut to Use the Tool Temporarily 按住工具快速鍵以暫時使用該工具 Rotate… 旋轉… Rotation 旋轉 Roughen… 粗糙效果… diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 21c000c12..fb8912455 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -315,6 +315,9 @@ pub struct VectorcraftApp { /// The context, viewport and frame `shortcuts::handle` already took in this host iteration. /// Interleaving root and auxiliary viewport passes cannot make an older frame look new again. pub(crate) shortcuts_frames: Vec, + /// The single-key tool shortcuts held down, the last held last, and the tool each goes back to + /// when it is let go ([`shortcuts::HeldTool`]). + pub(crate) held_tool: Vec, /// A synthetic batch is in the host's raw input and no settled UI pass has taken it yet. synthetic_injected: bool, /// The exact events of [`Self::synthetic_injected`], to remove them if the state they belong diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 406141b41..713cf5dd4 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -11,10 +11,12 @@ use vectorcraft_tools::{Mods, SurroundingUnit, ToolKey}; use crate::VectorcraftApp; +mod held_tool; mod paste; mod presentation; mod typing; +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; @@ -186,7 +188,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } ctx.input(|i| crate::shortcut_editor::note_backtick_text(&i.events)); note_key_surfaces(app, ctx); - if !(ctx.input(|input| input.focused) || app.forwarded_frame && ctx.viewport_id() == egui::ViewportId::ROOT) { + let focused = ctx.input(|input| input.focused) || app.forwarded_frame && ctx.viewport_id() == egui::ViewportId::ROOT; + // A held tool shortcut that is let go returns to the tool active before it. + held_tool::follow(app, ctx, focused); + if !focused { crate::ime::controls_without_editor(app, ctx); return; } @@ -317,6 +322,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)); } } + held_tool::note_enter_dialog(app, k); } } if typing { @@ -480,26 +486,26 @@ fn remember_escape_pair(ctx: &egui::Context, key: Key, escape_pair: Option 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, // Arabic, Russian, Greek…) the key's position stands in for what it types (#793). - // A text's key is the press that typed it ([`typing_presses`]). - let events: Vec<(String, Modifiers, Option)> = ctx.input(|i| { + // A text's key is the press that typed it ([`typing_presses`]), and the text repeats with it. + let events: Vec<(String, Modifiers, Option, bool)> = ctx.input(|i| { let typed_by = typing_presses(&i.events); i.events .iter() .zip(typed_by) .filter_map(|(e, by)| match e { egui::Event::Text(t) => { - let (physical, key) = match by.and_then(|k| i.events.get(k)) { - Some(egui::Event::Key { key, physical_key, .. }) => (*physical_key, Some(*key)), - _ => (None, None), + let (physical, key, repeat) = match by.and_then(|k| i.events.get(k)) { + Some(egui::Event::Key { key, physical_key, repeat, .. }) => (*physical_key, Some(*key), *repeat), + _ => (None, None, false), }; - Some((latin(t, physical), i.modifiers, key)) + Some((latin(t, physical), i.modifiers, key, repeat)) } _ => None, }) .collect() }); let keymap = crate::shortcut_editor::current(); - for (text, m, pressed) in events { + for (text, m, pressed, repeat) in events { if m.command || m.alt || m.ctrl { continue; } @@ -521,7 +527,7 @@ fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { // tools. let Some(hit) = hits.iter().find(|c| !c.id.starts_with("tool:")).or(hits.first()) else { continue }; match hit.id.strip_prefix("tool:") { - Some(tool) => app.select_tool(tool), + Some(tool) => held_tool::choose(app, ctx, tool, pressed, repeat), None if let Some((id, p)) = menu_target(hit) => crate::menus::invoke(app, &id, p), None if hit.params.is_null() => { let _ = app.run(&hit.id, json!({})); diff --git a/crates/ui-egui/src/shortcuts/held_tool.rs b/crates/ui-egui/src/shortcuts/held_tool.rs new file mode 100644 index 000000000..a3a809b96 --- /dev/null +++ b/crates/ui-egui/src/shortcuts/held_tool.rs @@ -0,0 +1,154 @@ +//! A single-key tool shortcut held down (General › Hold a Tool Shortcut to Use the Tool +//! Temporarily, `holdToolKeys`): the tool it chose stays until the key is let go, and then the tool +//! active before comes back. A quick press chooses the tool for good. + +use super::{Key, VectorcraftApp}; + +/// A key let go this many seconds or more after its press returns to the tool active before it. +/// VectorCraft's own value. +pub(crate) const HOLD_SECONDS: f64 = 0.25; + +/// A tool shortcut's key held down since it chose its tool. +pub(crate) struct HeldTool { + /// The key that chose the tool. + key: Key, + /// The viewport that saw the press: other viewports do not see the key. + viewport: egui::ViewportId, + /// The tool the key chose. + tool: &'static str, + /// The tool to go back to. + previous: &'static str, + /// When the key went down (`egui::InputState::time`). + since: f64, + /// The tool took a drag, a click or a dialog while the key was down. + used: bool, + /// When a frame first saw the key let go. The decision can come in a later frame (after a + /// drag, a dialog or a press of the same frame), and it uses this time. + released: Option, +} + +/// Chooses `tool` for a press of its single-key shortcut `key` (`None`: no key press typed it). A +/// press that changes the tool, with the preference on, starts a hold. A hold started while another +/// key is held goes on top of it: letting go of the later key returns to the earlier key's tool, +/// and letting go of the earlier key then returns to the tool before both. With the preference on, +/// a key's repeats choose nothing: a key held down chooses its tool once. With it off, each repeat +/// chooses the tool again. +pub(super) fn choose(app: &mut VectorcraftApp, ctx: &egui::Context, tool: &str, key: Option, repeat: bool) { + if repeat && app.session.prefs.hold_tool_keys { + return; + } + let previous = app.session.tool_id(); + // `select_tool` ends the holds of a tool chosen any other way; the keys held stay held here. + let holds = std::mem::take(&mut app.held_tool); + app.select_tool(tool); + app.held_tool = holds; + let chosen = app.session.tool_id(); + if chosen == previous { + return; + } + let viewport = ctx.viewport_id(); + let (down, now) = key.map_or((false, 0.0), |k| ctx.input(|i| (i.key_down(k), i.time))); + match key { + // A key let go in the frame that pressed it was a quick press. + Some(key) if down && app.session.prefs.hold_tool_keys && chosen == tool => { + app.held_tool.retain(|h| h.viewport == viewport && h.key != key); + app.held_tool.push(HeldTool { key, viewport, tool: chosen, previous, since: now, used: false, released: None }); + } + _ => app.held_tool.clear(), + } +} + +/// A press that reaches the active tool, a drag of the Zoom, Hand or Rotate View tool, or the +/// tool's dialog opened with Enter, uses the tool of the key held last when that tool is active, +/// also when that and the key's release come between two frames of [`follow`]. +pub(crate) fn note_tool_use(app: &mut VectorcraftApp) { + let active = app.session.tool_id(); + if let Some(held) = app.held_tool.last_mut().filter(|h| h.tool == active) { + held.used = true; + } +} + +/// After the tool takes `key`: a dialog that Enter left open is the tool's, so it uses the tool. +pub(super) fn note_enter_dialog(app: &mut VectorcraftApp, key: Key) { + if key == Key::Enter && app.ui.dialog.is_some() { + note_tool_use(app); + } +} + +/// Once per frame of the viewport that saw the press: when the key held last is let go, goes back +/// to the tool active before it, unless the press was quick and the tool was not used. A key let +/// go during a drag, with the button held or with a dialog open, goes back once that ends. Choosing +/// another tool, a quick press, turning the preference off or the window losing the keyboard ends +/// every hold without going back. A frame of another viewport that has the keyboard also ends them. +pub(super) fn follow(app: &mut VectorcraftApp, ctx: &egui::Context, focused: bool) { + // Every key let go is timed in the frame that first sees it up, under the key held last too. + let (viewport, time) = (ctx.viewport_id(), ctx.input(|i| i.time)); + for held in app.held_tool.iter_mut().filter(|h| h.viewport == viewport && h.released.is_none()) { + if !ctx.input(|i| i.key_down(held.key)) { + held.released = Some(time); + } + } + // Each pass looks at the key held last; one that goes back hands over to the key under it. + loop { + let gesture = app.session.tool_busy() || app.session.pointer_held(); + // A dialog, or a canvas drag that does not reach the tool (a pan, zoom or turn of the view), + // defers the decision. + let dialog = app.ui.dialog.is_some() || crate::canvas::dragging(ctx); + let active = app.session.tool_id(); + let on = app.session.prefs.hold_tool_keys; + let Some(held) = app.held_tool.last_mut() else { return }; + if !on { + app.held_tool.clear(); + return; + } + if held.viewport != ctx.viewport_id() { + // This viewport has the keyboard, so the viewport that saw the press lost it. + if focused { + app.held_tool.clear(); + } + return; + } + if !focused { + app.held_tool.clear(); + return; + } + let (down, now) = ctx.input(|i| (i.key_down(held.key), i.time)); + if gesture || dialog { + // A gesture of the held tool with the key down uses the tool; a gesture lent to a + // selection tool does not. A dialog uses it only when Enter opened it, and a view drag + // when it is the held tool's ([`note_tool_use`]). + held.used |= gesture && down && active == held.tool; + return; + } + if active != held.tool { + app.held_tool.clear(); + return; + } + if down { + return; + } + // The canvas takes a pointer press of the same frame, and the tool an Enter press, after + // this: the decision waits for the next frame, measured from this one. + let pressed = ctx.input(|i| { + i.events + .iter() + .any(|e| matches!(e, egui::Event::PointerButton { pressed: true, .. } | egui::Event::Key { key: Key::Enter, pressed: true, .. })) + }); + let released = held.released.unwrap_or(now); + if pressed && released == now { + ctx.request_repaint(); + return; + } + let back = held.used || released - held.since >= HOLD_SECONDS; + let previous = held.previous; + if !back { + app.held_tool.clear(); + return; + } + app.held_tool.pop(); + app.return_to_tool(previous); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/shortcuts/held_tool/tests.rs b/crates/ui-egui/src/shortcuts/held_tool/tests.rs new file mode 100644 index 000000000..e2217b822 --- /dev/null +++ b/crates/ui-egui/src/shortcuts/held_tool/tests.rs @@ -0,0 +1,462 @@ +use serde_json::json; +use vectorcraft_tools::{PointerEvent, PointerKind}; + +use super::super::handle; +use super::*; + +/// A keyboard on one context, so egui keeps the keys held down from frame to frame and marks a +/// held key's later presses as repeats, with the frame times the test sets. +struct Keyboard { + ctx: egui::Context, +} + +impl Keyboard { + fn new() -> Self { + Self { ctx: egui::Context::default() } + } + + /// One frame at `time` seconds delivering `events`; with `field`, a text field that takes the + /// keyboard is drawn after the shortcuts. + fn frame(&self, app: &mut VectorcraftApp, time: f64, events: Vec, field: Option<&mut String>) { + self.frame_with_focus(app, time, events, field, true); + } + + /// [`Self::frame`], with the window having the keyboard (`focused`) or not. + fn frame_with_focus(&self, app: &mut VectorcraftApp, time: f64, events: Vec, mut field: Option<&mut String>, focused: bool) { + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let mut out = self.ctx.run_ui(egui::RawInput { events, screen_rect, time: Some(time), focused, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + handle(app, ui.ctx()); + if let Some(text) = field.as_deref_mut() { + ui.add(egui::TextEdit::singleline(text).id(egui::Id::unique("held-tool-field"))).request_focus(); + } + }); + out.textures_delta.clear(); + } + + /// One frame at `time` seconds delivering `events`, with the canvas drawn after the shortcuts. + fn on_canvas(&self, app: &mut VectorcraftApp, time: f64, events: Vec) { + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let mut out = self.ctx.run_ui(egui::RawInput { events, screen_rect, time: Some(time), focused: true, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + handle(app, ui.ctx()); + crate::canvas::show(app, ui); + }); + out.textures_delta.clear(); + } + + /// `key` pressed (or repeated while it is down) at `time`, typing `letter`. + fn press(&self, app: &mut VectorcraftApp, time: f64, key: Key, letter: &str) { + let press = egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + self.frame(app, time, vec![press, egui::Event::Text(letter.into())], None); + } + + fn release(&self, app: &mut VectorcraftApp, time: f64, key: Key) { + let release = egui::Event::Key { key, physical_key: Some(key), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + self.frame(app, time, vec![release], None); + } + + fn press_s(&self, app: &mut VectorcraftApp, time: f64) { + self.press(app, time, Key::S, "s"); + } + + fn release_s(&self, app: &mut VectorcraftApp, time: f64) { + self.release(app, time, Key::S); + } +} + +/// A document with a selected rectangle and the Pen tool chosen. +fn pen_app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + crate::shortcut_editor::sync(&app.ui); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap(); + app.select_tool("pen"); + app +} + +/// S held from 0 s, its repeats at `repeats`, let go at `release`. +fn hold_s(app: &mut VectorcraftApp, keys: &Keyboard, repeats: &[f64], release: f64) { + keys.press_s(app, 0.0); + assert_eq!(app.session.tool_id(), "scale", "the press chooses Scale"); + for &t in repeats { + keys.press_s(app, t); + assert_eq!(app.session.tool_id(), "scale", "a repeat at {t} s keeps Scale"); + } + keys.release_s(app, release); +} + +/// A quick press of S chooses Scale for good. +#[test] +fn a_quick_press_of_a_tool_key_chooses_the_tool_for_good() { + let mut app = pen_app(); + let keys = Keyboard::new(); + hold_s(&mut app, &keys, &[], HOLD_SECONDS / 2.0); + assert_eq!(app.session.tool_id(), "scale"); + keys.frame(&mut app, 5.0, vec![], None); + assert_eq!(app.session.tool_id(), "scale", "Scale stays chosen"); + assert!(app.held_tool.is_empty()); +} + +/// A press and release of S in one frame is a quick press, also when the next frame comes long +/// after it. +#[test] +fn a_press_and_release_in_one_frame_chooses_the_tool_for_good() { + let mut app = pen_app(); + let keys = Keyboard::new(); + let key = |pressed| egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.0, vec![key(true), egui::Event::Text("s".into()), key(false)], None); + keys.frame(&mut app, 5.0, vec![], None); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// S held past the threshold, with its key repeats, and let go returns to the Pen. +#[test] +fn a_held_tool_key_returns_to_the_previous_tool_when_let_go() { + let mut app = pen_app(); + let keys = Keyboard::new(); + hold_s(&mut app, &keys, &[0.1, 0.2, 0.3, 0.4], 0.5); + assert_eq!(app.session.tool_id(), "pen"); + assert!(app.held_tool.is_empty()); +} + +/// S held from the Pen and then V held: letting go of V returns to Scale, and letting go of S +/// then returns to the Pen. +#[test] +fn overlapping_held_tool_keys_return_one_at_a_time() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + keys.press(&mut app, 0.1, Key::V, "v"); + assert_eq!(app.session.tool_id(), "selection"); + keys.release(&mut app, 0.4, Key::V); + assert_eq!(app.session.tool_id(), "scale", "letting go of V returns to Scale while S is down"); + keys.release_s(&mut app, 0.6); + assert_eq!(app.session.tool_id(), "pen", "letting go of S returns to the Pen"); + assert!(app.held_tool.is_empty()); +} + +/// S let go during a Scale drag returns to the Pen once the drag ends, also when the key was let go +/// before the threshold. +#[test] +fn a_tool_key_let_go_during_a_drag_returns_after_the_drag() { + for release in [HOLD_SECONDS / 2.0, 1.0] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 150.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 260.0, 190.0), view).unwrap(); + assert!(app.session.tool_busy(), "Scale is dragging"); + keys.frame(&mut app, release / 2.0, vec![], None); + keys.release_s(&mut app, release); + assert_eq!(app.session.tool_id(), "scale", "let go at {release} s: Scale stays during the drag"); + keys.frame(&mut app, release + 0.1, vec![], None); + assert_eq!(app.session.tool_id(), "scale", "let go at {release} s: still dragging"); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 260.0, 190.0), view).unwrap(); + keys.frame(&mut app, release + 0.2, vec![], None); + assert_eq!(app.session.tool_id(), "pen", "let go at {release} s: the Pen is back after the drag"); + } +} + +/// A click on the canvas between two frames while S is down uses Scale, so letting go of S before +/// the threshold returns to the Pen. +#[test] +fn a_click_between_frames_counts_as_using_the_held_tool() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 150.0, 125.0), view); + } + keys.release_s(&mut app, HOLD_SECONDS / 2.0); + assert_eq!(app.session.tool_id(), "pen"); +} + +/// A Cmd/Ctrl click while S is down goes to the selection tool lent to Scale, so it does not use +/// Scale: letting go of S before the threshold keeps Scale, whether or not a frame comes during +/// the click. +#[test] +fn a_click_lent_to_a_selection_tool_does_not_use_the_held_tool() { + for frame_during_click in [false, true] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let mods = vectorcraft_tools::Mods { cmd: true, ..Default::default() }; + assert!(app.session.lent_tool_id(&mods).is_some(), "Scale lends a selection tool to a Cmd press"); + let view = app.view_info(); + let event = |kind| PointerEvent { mods, ..PointerEvent::new(kind, 150.0, 125.0) }; + crate::canvas::dispatch(&mut app, &event(PointerKind::Down), view); + if frame_during_click { + keys.frame(&mut app, HOLD_SECONDS / 4.0, vec![], None); + } + crate::canvas::dispatch(&mut app, &event(PointerKind::Up), view); + keys.release_s(&mut app, HOLD_SECONDS / 2.0); + assert_eq!(app.session.tool_id(), "scale", "frame during the click: {frame_during_click}"); + assert!(app.held_tool.is_empty()); + } +} + +/// A pan with the Hand tool while H is down uses the Hand: letting go of H quickly after the pan +/// returns to the Pen, and letting go of H during the pan returns once the pan ends. +#[test] +fn a_view_tool_drag_counts_as_using_the_held_tool() { + for let_go_during_pan in [false, true] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.on_canvas(&mut app, 0.0, vec![]); + let at = app.canvas_rect.expect("the canvas was drawn").center(); + let to = at + egui::vec2(30.0, 20.0); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + let key = |pressed| egui::Event::Key { key: Key::H, physical_key: Some(Key::H), pressed, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.on_canvas(&mut app, 0.01, vec![egui::Event::PointerMoved(at), key(true), egui::Event::Text("h".into())]); + assert_eq!(app.session.tool_id(), "hand", "let go during the pan: {let_go_during_pan}"); + keys.on_canvas(&mut app, 0.02, vec![button(at, true)]); + keys.on_canvas(&mut app, 0.03, vec![egui::Event::PointerMoved(to)]); + assert!(crate::canvas::dragging(&keys.ctx), "the Hand pans"); + if let_go_during_pan { + keys.on_canvas(&mut app, 0.05, vec![key(false)]); + assert_eq!(app.session.tool_id(), "hand", "the Hand stays during the pan"); + keys.on_canvas(&mut app, 0.06, vec![button(to, false)]); + } else { + keys.on_canvas(&mut app, 0.05, vec![button(to, false)]); + keys.on_canvas(&mut app, 0.06, vec![key(false)]); + } + keys.on_canvas(&mut app, 0.07, vec![]); + assert_eq!(app.session.tool_id(), "pen", "let go during the pan: {let_go_during_pan}"); + assert!(app.held_tool.is_empty()); + } +} + +/// A pointer press in the frame that lets go of S: a press that reaches Scale on the canvas returns +/// to the Pen, and a press elsewhere (no canvas press follows) keeps Scale, also when the next frame +/// comes late. +#[test] +fn a_press_in_the_frame_that_lets_go_waits_for_the_canvas() { + for canvas in [true, false] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let at = egui::pos2(150.0, 125.0); + let press = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }; + let release = egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.1, vec![egui::Event::PointerMoved(at), press, release], None); + assert_eq!(app.session.tool_id(), "scale", "canvas {canvas}: no decision before the canvas"); + if canvas { + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 150.0, 125.0), view); + } + } + keys.frame(&mut app, 2.0, vec![], None); + assert_eq!(app.session.tool_id(), if canvas { "pen" } else { "scale" }, "canvas {canvas}"); + } +} + +/// S and then V pressed quickly, both let go in the frame of a canvas click with the Selection tool: +/// V was used, so Scale comes back, and S was a quick press, so Scale stays, also when the next +/// frame comes late. +#[test] +fn keys_let_go_together_are_timed_in_the_frame_that_lets_them_go() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + keys.press(&mut app, 0.02, Key::V, "v"); + let at = egui::pos2(150.0, 125.0); + let up = |key| egui::Event::Key { key, physical_key: Some(key), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + let press = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.05, vec![egui::Event::PointerMoved(at), press, up(Key::V), up(Key::S)], None); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 150.0, 125.0), view); + } + keys.frame(&mut app, 2.0, vec![], None); + assert_eq!(app.session.tool_id(), "scale"); + assert!(app.held_tool.is_empty()); +} + +/// With S held and then V held, a click with the Selection tool uses Selection only: V let go +/// quickly returns to Scale, and S let go quickly then keeps Scale. +#[test] +fn a_click_uses_only_the_tool_of_the_key_held_last() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + keys.press(&mut app, 0.02, Key::V, "v"); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 150.0, 125.0), view); + } + keys.release(&mut app, 0.05, Key::V); + assert_eq!(app.session.tool_id(), "scale", "V was used, so Scale is back"); + keys.release_s(&mut app, 0.1); + assert_eq!(app.session.tool_id(), "scale", "Scale was not used, so it stays"); +} + +/// Enter with S down opens the Scale dialog, and with V down the Move dialog; the key let go quickly +/// while the dialog is open returns to the Pen once the dialog closes. +#[test] +fn a_dialog_opened_during_the_hold_counts_as_using_the_tool() { + for (key, letter, tool, dialog) in [(Key::S, "s", "scale", "scale"), (Key::V, "v", "selection", "move")] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press(&mut app, 0.0, key, letter); + let enter = + egui::Event::Key { key: Key::Enter, physical_key: Some(Key::Enter), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.05, vec![enter], None); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialog), "Enter opens the {dialog} dialog"); + keys.release(&mut app, 0.1, key); + assert_eq!(app.session.tool_id(), tool, "{tool} stays while its dialog is open"); + crate::dialogs::cancel(&mut app); + keys.frame(&mut app, 0.15, vec![], None); + assert_eq!(app.session.tool_id(), "pen", "after the {dialog} dialog"); + } +} + +/// Enter pressed in the frame that lets go of S after the threshold opens the Scale dialog, and +/// Scale stays until the dialog closes. +#[test] +fn enter_in_the_frame_that_lets_go_reaches_the_held_tool() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let enter = egui::Event::Key { key: Key::Enter, physical_key: Some(Key::Enter), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let release = egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 1.0, vec![release, enter], None); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("scale"), "Enter opens the Scale dialog"); + keys.frame(&mut app, 1.1, vec![], None); + assert_eq!(app.session.tool_id(), "scale", "Scale stays while its dialog is open"); + crate::dialogs::cancel(&mut app); + keys.frame(&mut app, 1.2, vec![], None); + assert_eq!(app.session.tool_id(), "pen"); +} + +/// Another dialog (Preferences) opened while S is down and S let go quickly while it is open: Scale +/// was not used, so it stays once the dialog closes. +#[test] +fn another_dialog_opened_during_the_hold_does_not_use_the_tool() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + crate::prefs_dialog::open(&mut app, None); + keys.frame(&mut app, 0.05, vec![], None); + keys.release_s(&mut app, 0.1); + crate::dialogs::cancel(&mut app); + keys.frame(&mut app, 0.15, vec![], None); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// S typed and held in a text field types its letter and chooses no tool. +#[test] +fn a_tool_key_held_in_a_text_field_types_and_keeps_the_tool() { + let mut app = pen_app(); + let keys = Keyboard::new(); + let mut text = String::new(); + keys.frame(&mut app, 0.0, vec![], Some(&mut text)); + let press = egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let release = egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.1, vec![press.clone(), egui::Event::Text("s".into())], Some(&mut text)); + keys.frame(&mut app, 0.6, vec![press, egui::Event::Text("s".into())], Some(&mut text)); + keys.frame(&mut app, 1.0, vec![release], Some(&mut text)); + assert_eq!(text, "ss"); + assert_eq!(app.session.tool_id(), "pen"); + assert!(app.held_tool.is_empty()); +} + +/// With General › Hold a Tool Shortcut to Use the Tool Temporarily off, a held S chooses Scale for +/// good, as a quick press does. +#[test] +fn with_the_preference_off_a_held_tool_key_chooses_the_tool_for_good() { + let mut app = pen_app(); + app.run("prefs.set", json!({"key": "holdToolKeys", "value": false})).unwrap(); + let keys = Keyboard::new(); + hold_s(&mut app, &keys, &[0.1, 0.2, 0.3, 0.4], 0.5); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// With the preference off, each repeat of a held S chooses Scale again, as before: a flyout opened +/// in between closes. +#[test] +fn with_the_preference_off_each_repeat_chooses_the_tool_again() { + let mut app = pen_app(); + app.run("prefs.set", json!({"key": "holdToolKeys", "value": false})).unwrap(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + app.ui.flyout = Some(0); + keys.press_s(&mut app, 0.3); + assert_eq!(app.ui.flyout, None, "the repeat chose Scale again"); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// A hold whose viewport stopped drawing ends when another viewport has the keyboard or the +/// preference is turned off, and stays while the other viewport does not have the keyboard. +#[test] +fn a_hold_of_another_viewport_ends_when_this_one_has_the_keyboard() { + let other = egui::ViewportId::from_hash_of("held-tool-other"); + let hold = || HeldTool { key: Key::S, viewport: other, tool: "scale", previous: "pen", since: 0.0, used: false, released: None }; + let mut app = pen_app(); + app.select_tool("scale"); + let keys = Keyboard::new(); + app.held_tool.push(hold()); + keys.frame_with_focus(&mut app, 0.5, vec![], None, false); + assert_eq!(app.held_tool.len(), 1, "a viewport without the keyboard leaves the hold alone"); + keys.frame(&mut app, 1.0, vec![], None); + assert!(app.held_tool.is_empty(), "a viewport with the keyboard ends the hold"); + assert_eq!(app.session.tool_id(), "scale"); + app.held_tool.push(hold()); + app.run("prefs.set", json!({"key": "holdToolKeys", "value": false})).unwrap(); + keys.frame_with_focus(&mut app, 1.5, vec![], None, false); + assert!(app.held_tool.is_empty(), "turning the preference off ends the hold"); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// Scale chosen again on the toolbar while S is held stays chosen when S is let go. +#[test] +fn the_held_tool_chosen_again_stays_when_the_key_is_let_go() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + app.select_tool("scale"); + // A tool chosen closes an open flyout: the repeats of S, still down, choose nothing. + app.ui.flyout = Some(0); + keys.press_s(&mut app, 0.3); + assert_eq!(app.ui.flyout, Some(0), "a repeat chooses no tool"); + keys.release_s(&mut app, 0.5); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// The window losing the keyboard while S is held, also during a drag, ends the hold: Scale stays. +#[test] +fn losing_the_keyboard_ends_the_hold() { + for drag in [false, true] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let view = app.view_info(); + if drag { + app.session.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 150.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 260.0, 190.0), view).unwrap(); + } + keys.frame_with_focus(&mut app, 0.3, vec![egui::Event::WindowFocused(false)], None, false); + if drag { + app.session.pointer(&PointerEvent::new(PointerKind::Up, 260.0, 190.0), view).unwrap(); + } + keys.frame(&mut app, 0.5, vec![egui::Event::WindowFocused(true)], None); + keys.release_s(&mut app, 0.6); + assert_eq!(app.session.tool_id(), "scale", "drag {drag}: Scale stays"); + } +} + +/// Another tool chosen while S is held stays chosen when S is let go. +#[test] +fn a_tool_chosen_during_the_hold_stays_when_the_key_is_let_go() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + app.select_tool("rectangle"); + keys.frame(&mut app, 0.3, vec![], None); + keys.release_s(&mut app, 0.5); + assert_eq!(app.session.tool_id(), "rectangle"); +} diff --git a/docs/mcp.md b/docs/mcp.md index 98bdf6739..6af5b4602 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3637,7 +3637,11 @@ control channel's `ui.drag` scrubs), `showHomeScreen` (on by default: the Home s off, an empty window, and the Home button or `app.home` still shows the screen), `autoCollapseIconPanels` (off by default: on, a click away from a panel popped out of the icon column puts it away), `displayPrintSize` (off by default: View › Actual Size / `view.actualSize` is one document point per screen point; on, one document inch fills -96 screen points, an inch at the system's reference density whatever the display scaling). `highlightAlternateGlyphs` (off by default: on, the canvas paints a pink cell behind glyphs OpenType features +96 screen points, an inch at the system's reference density whatever the display scaling). The tool shortcut +preference `holdToolKeys` also needs the desktop app. It is on by default: a single-key tool shortcut held for 0.25 s +or more, or held while its tool is used, chooses the tool until the key is let go and then returns to the tool active +before, after any drag in progress; a quicker press chooses the tool for good. With it off, every press and every key +repeat chooses the tool. `highlightAlternateGlyphs` (off by default: on, the canvas paints a pink cell behind glyphs OpenType features substituted — ligatures, contextual forms — on screen; Document Setup's Highlight Substituted Glyphs stays separate). `highlightSubstitutedFonts` (on by default: the canvas paints pink behind text drawn in a substitute for a missing font, in every document). `blackOnScreen` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a0d1be2b2..3fa0ee321 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -876,7 +876,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Composition's leading-space reduction stays along the line when a character rotates. An opening bracket trimmed by Line-end Punctuation Half Width Mojikumi keeps that placement under Character Rotation; its turning point follows the composition adjustment, and removing the rotation keeps the trim. Scores and estimates are unchanged. A character rotated near or at a quarter turn follows a Touch Type drag across its line as well as along it: baseline shift moves a character up from its line at any rotation, and the character turns about its shifted pen position. Right-to-left characters can also move along their line when a preceding logical character is on that line. Positive `text.touchType` `dx` follows reading order, toward the left in horizontal right-to-left text; the first logical character still moves only up and down. These direction rules are VectorCraft's choices; the public Touch Type page describes moving a character without specifying this case. Scores and estimates are unchanged. - - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. + - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. A single-key tool shortcut held for 0.25 s or more, or held while its tool is used, chooses that tool only until the key is let go, and the tool active before comes back (after a drag in progress ends); a quicker press chooses the tool for good. This is VectorCraft's own rule and its own threshold, and General › Hold a Tool Shortcut to Use the Tool Temporarily (`holdToolKeys`, on by default) turns it off. 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°. - **Swatches panel clicks:** Ctrl/Cmd-clicking New Swatch makes a spot color, and Ctrl/Cmd+Shift-clicking it makes a global process color.