Merge pull request #6172 from dotCooCoo/feat/hold-to-use-tool-keys

Shortcuts: holding a tool's single-key shortcut uses the tool until the key is let go
This commit is contained in:
BFlat
2026-11-08 14:40:01 +08:00
committed by GitHub
39 changed files with 685 additions and 12 deletions
+1
View File
@@ -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),
+4
View File
@@ -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,
+1
View File
@@ -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(),
+3 -1
View File
@@ -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);
}
+4
View File
@@ -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).
+5
View File
@@ -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);
+1
View File
@@ -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… Грапавост…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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: Γραμμές:
Can't render this file because it contains an unexpected character in line 168 and column 5.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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é…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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 फिर से बदलना
Can't render this file because it is too large.
+1
View File
@@ -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:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1232
1233
1234
1235
1236
1237
1238
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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 ラウンド結合
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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 ცვლადების განბლოკვა
Can't render this file because it is too large.
+1
View File
@@ -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 둥근 연결
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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… Огрубление…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -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ç
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -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… Надати нерівності…
Can't render this file because it contains an unexpected character in line 182 and column 5.
+1
View File
@@ -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:
Can't render this file because it contains an unexpected character in line 204 and column 5.
+1
View File
@@ -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… 粗糙效果…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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… 粗糙效果…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -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<InputFrame>,
/// 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<shortcuts::HeldTool>,
/// 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
+15 -9
View File
@@ -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<bool>
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<Key>)> = 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<Key>, 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!({}));
+154
View File
@@ -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<f64>,
}
/// 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<Key>, 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;
@@ -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<egui::Event>, 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<egui::Event>, 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<egui::Event>) {
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");
}
+5 -1
View File
@@ -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`
+1 -1
View File
@@ -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.