mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 06:07:06 -06:00
The key-position match for Shift+Comma and Shift+Period now applies only when Shift is the only modifier. Cmd/Ctrl+Shift and Alt+Shift presses at those keys keep their logical key, so the type-size shortcuts on German-like layouts behave as before. The typing test also checks that no paint command runs while text is edited.
955 lines
44 KiB
Rust
955 lines
44 KiB
Rust
//! Keyboard shortcuts: command shortcuts from the registry, single-key tool shortcuts, arrows,
|
||
//! and tool keys (Enter/Esc/↑/↓ while drawing).
|
||
|
||
use craft_keymap::egui::Hit;
|
||
use craft_keymap::{Active, CommandRef};
|
||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||
use serde_json::json;
|
||
use vectorcraft_engine::cmd::clipboard::{Flavour, TEXT};
|
||
use vectorcraft_engine::{Result as EngineResult, UiRequest, ViewInfo};
|
||
use vectorcraft_tools::{Mods, SurroundingUnit, ToolKey};
|
||
|
||
use crate::VectorcraftApp;
|
||
|
||
mod paste;
|
||
mod presentation;
|
||
mod typing;
|
||
|
||
use paste::unclip_chords;
|
||
pub(crate) use paste::{PasteChord, paste_command};
|
||
pub(crate) use presentation::presentation_step;
|
||
use presentation::{escape_pair_id, escape_press, escape_resets_rotation, presentation_keys};
|
||
#[cfg(test)]
|
||
use typing::is_shortcut_chord;
|
||
pub(crate) use typing::keep_marked_text;
|
||
use typing::{drop_typing_presses, type_text};
|
||
|
||
fn text_focus_frame_id() -> egui::Id {
|
||
egui::Id::unique("shortcut-text-focus-frame")
|
||
}
|
||
|
||
pub(crate) fn remember_text_focus(ctx: &egui::Context) {
|
||
if ctx.egui_wants_keyboard_input() {
|
||
let frame = ctx.cumulative_frame_nr_for(ctx.viewport_id());
|
||
ctx.data_mut(|d| d.insert_temp(text_focus_frame_id(), frame));
|
||
}
|
||
}
|
||
|
||
fn text_focused_in_previous_frame(ctx: &egui::Context) -> bool {
|
||
let previous = ctx.cumulative_frame_nr_for(ctx.viewport_id()).checked_sub(1);
|
||
ctx.data(|d| d.get_temp::<u64>(text_focus_frame_id())) == previous
|
||
}
|
||
|
||
/// Parse "Cmd+Shift+]" into an egui shortcut. `Cmd` is Command on macOS and Ctrl elsewhere.
|
||
/// `+` and `=` name one chord key ([`Key::Equals`]): `+` is Shift+`=` on many layouts, so a
|
||
/// `Cmd+=` chord also answers `+` however it is typed.
|
||
pub fn parse(s: &str) -> Option<KeyboardShortcut> {
|
||
if s.is_empty() {
|
||
return None;
|
||
}
|
||
// A bare `+` (the Add Anchor Point tool's key) is the key itself, not a separator with
|
||
// nothing on either side.
|
||
let (mods_part, key_part) = if s == "+" {
|
||
("", "+")
|
||
} else if let Some(stripped) = s.strip_suffix("++") {
|
||
(stripped.trim_end_matches('+'), "+")
|
||
} else {
|
||
s.rsplit_once('+').unwrap_or(("", s))
|
||
};
|
||
let mut m = Modifiers::NONE;
|
||
for part in mods_part.split('+').filter(|p| !p.is_empty()) {
|
||
match part {
|
||
"Cmd" => m |= Modifiers::COMMAND,
|
||
"Shift" => m |= Modifiers::SHIFT,
|
||
"Alt" => m |= Modifiers::ALT,
|
||
"Ctrl" => m |= Modifiers::CTRL,
|
||
_ => return None,
|
||
}
|
||
}
|
||
let key = match key_part {
|
||
"]" => Key::CloseBracket,
|
||
"[" => Key::OpenBracket,
|
||
";" => Key::Semicolon,
|
||
"'" => Key::Quote,
|
||
"/" => Key::Slash,
|
||
"\\" => Key::Backslash,
|
||
"=" | "+" => Key::Equals,
|
||
"-" => Key::Minus,
|
||
"Delete" => Key::Delete,
|
||
"Backspace" => Key::Backspace,
|
||
"Tab" => Key::Tab,
|
||
"~" => Key::Backtick,
|
||
// A modifier alone is never a chord's key: it couldn't fire (#487).
|
||
k => Key::from_name(k).filter(|k| !is_modifier(*k))?,
|
||
};
|
||
Some(KeyboardShortcut::new(m, key))
|
||
}
|
||
|
||
/// The modifier keys, which egui also reports as key presses of their own.
|
||
pub(crate) fn is_modifier(key: Key) -> bool {
|
||
matches!(
|
||
key,
|
||
Key::ShiftLeft | Key::ShiftRight | Key::ControlLeft | Key::ControlRight | Key::AltLeft | Key::AltRight | Key::SuperLeft | Key::SuperRight
|
||
)
|
||
}
|
||
|
||
/// Every command shortcut in effect (user overrides from Edit → Keyboard Shortcuts win), with the
|
||
/// command's params: `{}`, or `{panel}` for the panel shortcuts (`window.panel`).
|
||
pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json::Value)> {
|
||
let mut v = vec![];
|
||
for c in vectorcraft_engine::command_specs() {
|
||
if let Some(sc) = crate::menus::shortcut_of(c.id).and_then(parse) {
|
||
v.push((sc, c.id, json!({})));
|
||
}
|
||
}
|
||
for c in crate::menus::UI_COMMANDS {
|
||
if let Some(sc) = crate::menus::shortcut_of(c.0).and_then(parse) {
|
||
v.push((sc, c.0, json!({})));
|
||
}
|
||
}
|
||
for (panel, _) in crate::state::all_panels() {
|
||
if let Some(sc) = crate::shortcut_editor::panel_shortcut(panel).and_then(parse) {
|
||
v.push((sc, "window.panel", json!({ "panel": panel })));
|
||
}
|
||
}
|
||
// Most specific (most modifiers) first: `paste_command` takes the first V chord the held keys
|
||
// match logically, so Cmd+Shift+V isn't taken by Cmd+V.
|
||
v.sort_by_key(|(sc, ..)| {
|
||
std::cmp::Reverse(sc.modifiers.shift as u8 + sc.modifiers.alt as u8 + sc.modifiers.command as u8 + sc.modifiers.ctrl as u8)
|
||
});
|
||
v
|
||
}
|
||
|
||
/// Does every press of `key` this frame hold a chord with modifiers that a command shortcut is bound
|
||
/// to, matched as [`consume`] matches it? `cmd_only`: only chords with Cmd count. False when `key`
|
||
/// isn't pressed.
|
||
fn runs_a_command(i: &egui::InputState, key: Key, cmd_only: bool) -> bool {
|
||
let presses: Vec<Modifiers> = i
|
||
.events
|
||
.iter()
|
||
.filter_map(|e| match e {
|
||
egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key => Some(*modifiers),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let shortcuts = all_shortcuts();
|
||
let bound = |m: &Modifiers| {
|
||
shortcuts
|
||
.iter()
|
||
.any(|(sc, ..)| sc.logical_key == key && (sc.modifiers.command || (!cmd_only && sc.modifiers.any())) && m.matches_logically(sc.modifiers))
|
||
};
|
||
!presses.is_empty() && presses.iter().all(bound)
|
||
}
|
||
|
||
/// Whether a key press runs a shortcut of the keymap in effect, the user's own included. The web
|
||
/// build asks this of every Cmd and Ctrl press, before egui sees it, to cancel what the browser
|
||
/// would also do with it (zoom the page on `Cmd+=`, undo on `Cmd+Z`, go back on `Cmd+[`).
|
||
/// `key` and `code` are the browser's names for the character typed and the key's position, which
|
||
/// egui reads with [`Key::from_name`]; `modifiers` as egui reports them. The press is matched as
|
||
/// the dispatcher matches it ([`dispatch`]) on a layout about which nothing is known: what typing
|
||
/// has shown of the layout is left out, and so are double presses.
|
||
pub fn runs_a_shortcut(key: &str, code: &str, modifiers: Modifiers) -> bool {
|
||
let physical_key = Key::from_name(code);
|
||
let Some(key) = Key::from_name(key).or(physical_key) else { return false };
|
||
let press = egui::Event::Key { key, physical_key, pressed: true, repeat: false, modifiers };
|
||
let Some(input) = craft_keymap::KeyInput::from_egui(&press, crate::keymap::platform()) else { return false };
|
||
let mirror = crate::shortcut_editor::current();
|
||
craft_keymap::Layout::default().candidates(&input).iter().any(|c| mirror.keymap.lookup(c, false, &Active::NONE) != craft_keymap::Lookup::None)
|
||
}
|
||
|
||
/// Process one frame's events against the keymap at most once per context, viewport and frame, so
|
||
/// reruns cannot fire a shortcut or tool key twice.
|
||
pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||
// One UI pass takes a frame's events. A discarded rerun and a test helper that also calls
|
||
// `logic` see the same events again; they must not fire a shortcut or tool key twice. The
|
||
// key includes the context (a restarted host starts at frame 0) and the viewport (frame
|
||
// numbers are per viewport), and remembers every key taken this host iteration: an
|
||
// interleaved root/auxiliary pass cannot make an earlier frame look new again.
|
||
let frame = (crate::graphics::context_tag(ctx), ctx.viewport_id(), ctx.cumulative_frame_nr_for(ctx.viewport_id()));
|
||
if !crate::take_frame_once(&mut app.shortcuts_frames, frame) {
|
||
return;
|
||
}
|
||
if !(ctx.input(|input| input.focused) || app.forwarded_frame && ctx.viewport_id() == egui::ViewportId::ROOT) {
|
||
crate::ime::controls_without_editor(app, ctx);
|
||
return;
|
||
}
|
||
// Taken before anything returns, so a press in a field or a dialog ends a pair of Escapes.
|
||
let mut escape_pair = escape_press(ctx);
|
||
// Followed before anything returns, so a key typed in a field isn't taken for a paste.
|
||
let textless_paste = ctx.input(|i| app.paste_chord.textless_paste(&i.events));
|
||
// A numeric field being scrubbed has the keyboard: Escape cancels the drag.
|
||
if crate::scrub::phase(ctx) != crate::scrub::Phase::Idle {
|
||
crate::ime::controls_without_editor(app, ctx);
|
||
return;
|
||
}
|
||
if app.ui.dialog.is_some() || app.ui.palette_open {
|
||
crate::ime::controls_without_editor(app, ctx);
|
||
if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) && crate::dialogs::revolve_gizmo::dragging(ctx) {
|
||
// Escape first restores this rotation gesture. The settings stay open.
|
||
return;
|
||
}
|
||
if crate::shortcut_editor::is_recording(app) {
|
||
return;
|
||
}
|
||
// Escape closes a dropdown list or popover open in the dialog (each closes itself on it) and
|
||
// leaves the dialog open.
|
||
let popup = app.ui.dialog.as_ref().is_some_and(|d| egui::Popup::is_any_open(ctx) || crate::dialogs::brush_options::tips_open(d));
|
||
if !popup && ctx.input(|i| i.key_pressed(Key::Escape)) {
|
||
crate::dialogs::cancel(app);
|
||
app.ui.palette_open = false;
|
||
}
|
||
return;
|
||
}
|
||
if app.ui.screen_mode == 3 {
|
||
crate::ime::controls_without_editor(app, ctx);
|
||
presentation_keys(app, ctx, escape_pair);
|
||
return;
|
||
}
|
||
// Escape that cancels a ruler-origin drag or floating Tools move skips the tool and later Escape actions.
|
||
let escape_ended_gesture = crate::canvas::ruler_origin_dragging(ctx) || crate::tools_float::escape_cancelled_move(ctx);
|
||
let escape_held_by_drag = crate::canvas::dragging(ctx) || escape_ended_gesture;
|
||
let typing = ctx.egui_wants_keyboard_input();
|
||
// Escape closes Edit Toolbar's drawer first (egui drops a drag from it on Escape already).
|
||
if app.ui.tools_drawer && !typing && !escape_held_by_drag && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) {
|
||
app.ui.tools_drawer = false;
|
||
}
|
||
let view = app.view_info();
|
||
// While an IME composes, Enter and Escape are its own (confirm / cancel).
|
||
let composing = app.session.tool_composing();
|
||
// Tool keys first (Enter/Escape end paths; arrows change polygon sides while dragging).
|
||
let busy = app.session.tool_busy();
|
||
for (k, tk) in [(Key::Enter, ToolKey::Enter), (Key::Escape, ToolKey::Escape)] {
|
||
if !typing && !composing && ctx.input(|i| i.key_pressed(k)) {
|
||
// A chord a command is bound to (Cmd+Enter) runs only that command, in the shortcuts
|
||
// below, also when the tool claims the key. While the Type tool edits text only Cmd
|
||
// chords run one, and while another tool is busy none does.
|
||
let text = app.session.tool_wants_text();
|
||
if (text || !busy) && ctx.input(|i| runs_a_command(i, k, text)) {
|
||
continue;
|
||
}
|
||
if k == Key::Escape && escape_ended_gesture {
|
||
remember_escape_pair(ctx, k, escape_pair);
|
||
continue;
|
||
}
|
||
// A key the tool claims (Esc with a loaded place cursor) is only the tool's.
|
||
let claimed = app.session.tool_claims_key(tk, view);
|
||
let mods = ctx.input(|i| {
|
||
let m = i
|
||
.events
|
||
.iter()
|
||
.rev()
|
||
.find_map(|e| match e {
|
||
egui::Event::Key { key, pressed: true, modifiers, .. } if *key == k => Some(*modifiers),
|
||
_ => None,
|
||
})
|
||
.unwrap_or(i.modifiers);
|
||
crate::canvas::mods(m, false)
|
||
});
|
||
let r = app.session.tool_key(tk, mods, view);
|
||
// Enter also takes what the tool asks of the UI: Rotate, Scale, Reflect and Shear open
|
||
// their dialog with it.
|
||
if claimed || (k == Key::Enter && !busy) {
|
||
crate::canvas::apply_requests(app, r);
|
||
}
|
||
if claimed {
|
||
ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k));
|
||
ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, k));
|
||
}
|
||
if k == Key::Escape && !busy && !claimed && !escape_held_by_drag {
|
||
if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) {
|
||
let _ = app.run("object.pattern.done", json!({}));
|
||
} else if app.session.active().is_some_and(|d| d.isolation.is_some()) {
|
||
let _ = app.run("object.exitIsolation", json!({}));
|
||
} else if app.session.active().is_some_and(|d| d.global_edit.is_some()) {
|
||
let _ = app.run("select.globalEdit", json!({}));
|
||
} else if app.ui.flyout.is_some() {
|
||
app.ui.flyout = None;
|
||
} else if escape_pair.is_some_and(|second| escape_resets_rotation(app, ctx, second)) {
|
||
// Escape pressed twice resets a rotated view (with the Rotate View tool, once).
|
||
let _ = app.run("view.rotateReset", json!({}));
|
||
escape_pair = None;
|
||
} else if matches!(app.ui.screen_mode, 1 | 2)
|
||
&& !egui::Popup::is_any_open(ctx)
|
||
&& !text_focused_in_previous_frame(ctx)
|
||
&& ctx.input(|i| {
|
||
i.events.iter().any(|e| {
|
||
matches!(e, egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, modifiers, .. } if *modifiers == Modifiers::NONE)
|
||
})
|
||
})
|
||
{
|
||
let _ = app.run("view.screenMode", json!({ "mode": 0 }));
|
||
}
|
||
}
|
||
// An Escape that ended something can start a pair too. One that closed a popup cannot.
|
||
remember_escape_pair(ctx, k, escape_pair);
|
||
if k == Key::Enter && !busy && !claimed {
|
||
// Enter with a selection tool opens the Move dialog, as a double-click on its button
|
||
// does.
|
||
let tool = app.session.tool_id();
|
||
if app.ui.dialog.is_none() && vectorcraft_tools::catalog::is_selection_tool(tool) {
|
||
// Nothing selected: it fails and nothing opens (the menu item is disabled then too).
|
||
let _ = crate::toolbar::open_options(app, tool);
|
||
}
|
||
// The Enter that opened a dialog isn't also its OK: dialogs take Enter, this frame too.
|
||
if app.ui.dialog.is_some() {
|
||
ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if typing {
|
||
crate::ime::controls_without_editor(app, ctx);
|
||
return;
|
||
}
|
||
ctx.input_mut(unclip_chords);
|
||
// Tab is ours when no field has the keyboard (it shows and hides the panels, or goes to the
|
||
// Type tool): left to egui, it would also move the focus on to a field, and every key after it
|
||
// would count as typing.
|
||
if ctx.input(|i| i.key_pressed(Key::Tab)) {
|
||
ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
|
||
}
|
||
// While the Type tool edits text, a character typed with AltGr runs no command chord.
|
||
if app.session.tool_wants_text() {
|
||
ctx.input_mut(drop_typing_presses);
|
||
}
|
||
if app.session.tool_wants_text() {
|
||
type_tool_input(app, ctx, composing);
|
||
return;
|
||
}
|
||
crate::ime::controls_without_editor(app, ctx);
|
||
handle_clipboard(app, ctx, textless_paste);
|
||
if busy {
|
||
busy_tool_keys(app, ctx, view);
|
||
return;
|
||
}
|
||
if claimed_tool_keys(app, ctx, view) {
|
||
return;
|
||
}
|
||
// Command, panel and tool shortcuts. A bound shifted punctuation chord uses its key press;
|
||
// other text keys use the typed character below.
|
||
let hits = dispatch(app, ctx, |hit| {
|
||
let chord = hit.trigger.chord;
|
||
let shifted_punctuation = chord.mods.shift && chord.key.name().chars().all(|c| !c.is_alphanumeric());
|
||
!(chord.is_plain() && types_text(chord.key) && !shifted_punctuation)
|
||
});
|
||
let ran = !hits.is_empty();
|
||
for c in hits {
|
||
run_hit(app, ctx, &c);
|
||
}
|
||
if settings_on_a_mac(app, ctx, mac(ctx)) || ran {
|
||
return;
|
||
}
|
||
if alt_type_arrows(app, ctx) {
|
||
return;
|
||
}
|
||
nudge_and_clear(app, ctx);
|
||
single_key_shortcuts(app, ctx);
|
||
}
|
||
|
||
fn remember_escape_pair(ctx: &egui::Context, key: Key, escape_pair: Option<bool>) {
|
||
if key == Key::Escape && escape_pair.is_some() && !egui::Popup::is_any_open(ctx) {
|
||
let now = ctx.input(|i| i.time);
|
||
ctx.data_mut(|d| d.insert_temp(escape_pair_id(), now));
|
||
}
|
||
}
|
||
|
||
/// Dispatch unmodified text keys to command bindings or tools.
|
||
fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||
// Single-key tool shortcuts (no Cmd/Ctrl/Alt). With a layout without Latin letters (Persian,
|
||
// Arabic, Russian, Greek…) the key's position stands in for what it types (#793).
|
||
let events: Vec<(String, Modifiers)> = 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 = match by.and_then(|k| i.events.get(k)) {
|
||
Some(egui::Event::Key { physical_key, .. }) => *physical_key,
|
||
_ => None,
|
||
};
|
||
Some((latin(t, physical), i.modifiers))
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
});
|
||
let keymap = crate::shortcut_editor::current();
|
||
for (text, m) in events {
|
||
if m.command || m.alt || m.ctrl {
|
||
continue;
|
||
}
|
||
let upper = text.to_uppercase();
|
||
let key = if m.shift && text.chars().all(|c| c.is_alphabetic()) { format!("Shift+{upper}") } else { upper.clone() };
|
||
// A shifted punctuation character can also be written with its Shift (the Curvature tool's
|
||
// Shift+~ arrives as `~`).
|
||
let shifted = m.shift.then(|| format!("Shift+{text}"));
|
||
let hits: Vec<CommandRef> = [Some(key), Some(text), shifted]
|
||
.into_iter()
|
||
.flatten()
|
||
.filter_map(|t| crate::keymap::vc_chord(&t).and_then(|c| crate::keymap::lookup(&keymap.keymap, &c)))
|
||
.collect();
|
||
// Single-key command shortcuts (X, Shift+X, D, /, Shift+D, F, Shift+F by default) before
|
||
// 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),
|
||
None if hit.params.is_null() => {
|
||
let _ = app.run(&hit.id, json!({}));
|
||
}
|
||
None => crate::menus::invoke(app, &hit.id, shortcut_params(hit)),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Route Alt+arrows to spacing controls when text objects are selected.
|
||
fn alt_type_arrows(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
|
||
let m = ctx.input(|i| i.modifiers);
|
||
if !m.alt {
|
||
return false;
|
||
}
|
||
let orientation = app.session.active().and_then(|st| {
|
||
if !st.selection.guides.is_empty() || st.selection.objects.is_empty() {
|
||
return None;
|
||
}
|
||
let mut all_vertical = true;
|
||
for id in &st.selection.objects {
|
||
let node = st.doc.node(*id)?;
|
||
let vectorcraft_doc::NodeKind::Text(t) = &node.kind else { return None };
|
||
all_vertical &= t.vertical;
|
||
}
|
||
Some(all_vertical)
|
||
});
|
||
let Some(vertical) = orientation else {
|
||
return false;
|
||
};
|
||
let keys = [(Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right), (Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)];
|
||
for (key, tool_key) in keys {
|
||
if ctx.input_mut(|i| i.consume_key(m, key)) {
|
||
if let Some((attribute, by)) = vectorcraft_tools::text::step_key(tool_key, m.shift, false, vertical) {
|
||
let by = if m.command { by * 5.0 } else { by };
|
||
let _ = app.run("type.step", json!({"attribute": attribute, "by": by}));
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Route text-tool input and command chords while preserving IME composition ownership.
|
||
fn type_tool_input(app: &mut VectorcraftApp, ctx: &egui::Context, composing: bool) {
|
||
// Route-level loss (no current target or caret geometry) retires a live guard before any
|
||
// marker in this pass can be considered. The finalizer may re-arm on a later activation.
|
||
crate::ime::suspend_if_route_lost(app, ctx);
|
||
let old_composition_retired = type_text(app, ctx);
|
||
// Keys of a frame that began composing are the IME's too (a Backspace that empties the
|
||
// marked text must not delete the committed character before it).
|
||
if composing && !old_composition_retired || app.session.tool_composing() {
|
||
// The IME owns the keyboard until it commits: no editing keys, clipboard or shortcuts.
|
||
ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { .. } | egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_))));
|
||
return;
|
||
}
|
||
// A deferred pointer/navigation notice has not established its destination yet. Its
|
||
// clipboard suffix must not cut or paste at the old caret; only the notice's own actual
|
||
// handler (kept in the raw vector) may run.
|
||
if crate::ime::deferred(app, ctx) {
|
||
ctx.input_mut(|input| {
|
||
input.events.retain(|event| match event {
|
||
egui::Event::Cut | egui::Event::Paste(_) => false,
|
||
egui::Event::Key { key: egui::Key::X | egui::Key::V, pressed: true, modifiers, .. }
|
||
if modifiers.command && !modifiers.shift && !modifiers.alt =>
|
||
{
|
||
false
|
||
}
|
||
_ => true,
|
||
})
|
||
});
|
||
}
|
||
// Editing keys with modifiers and the clipboard (Cmd+A is Select All's, below: the text).
|
||
crate::panels::character::route_type_input(app, ctx);
|
||
// Enter was already delivered above as ToolKey::Enter (newline). Only chords with the
|
||
// command key run commands: the rest type or edit.
|
||
let hits = dispatch(app, ctx, |hit| hit.trigger.chord.mods.cmd);
|
||
for c in hits {
|
||
run_hit(app, ctx, &c);
|
||
}
|
||
settings_on_a_mac(app, ctx, mac(ctx));
|
||
}
|
||
|
||
/// Apply arrow-key movement and clear selected art when no text editor owns input.
|
||
fn nudge_and_clear(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||
// Arrow nudges (Shift = ×10, Alt = copy).
|
||
let arrows = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)];
|
||
for (k, dx, dy) in arrows {
|
||
let m = ctx.input(|i| i.modifiers);
|
||
if ctx.input_mut(|i| i.consume_key(m, k)) && app.session.active().is_some_and(|d| d.selection.has_objects_or_guides()) {
|
||
let _ = app.run("object.nudge", json!({"dx": dx, "dy": dy, "big": m.shift, "copy": m.alt}));
|
||
}
|
||
}
|
||
// Delete / Backspace clear the selection.
|
||
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Backspace)) && app.session.active().is_some_and(|d| d.selection.has_objects_or_guides())
|
||
{
|
||
let _ = app.run("edit.clear", json!({}));
|
||
}
|
||
}
|
||
|
||
/// Route clipboard events to the system clipboard or the app's rich-format paste path.
|
||
fn handle_clipboard(app: &mut VectorcraftApp, ctx: &egui::Context, textless_paste: Option<Modifiers>) {
|
||
// A clipboard image (eframe's `clipboard` feature, 0.37): the host's text paste path never
|
||
// carries it. With the app's rich-format `SystemClipboard`, it is only the signal that the
|
||
// clipboard holds no text — the next tick invokes Paste, whose `adopt_system_clipboard` reads
|
||
// the formats once (never paste twice). Without one (the web/simple host), turn the pixels
|
||
// into a PNG flavour and paste that.
|
||
let image_paste = app.services.system_clipboard.is_none();
|
||
let mut pasted_image: Option<Vec<u8>> = None;
|
||
let mut read_image_formats = false;
|
||
// `MiddleClickPaste` is deliberately left in `i.events`: the Type tool above already took it,
|
||
// and otherwise egui's text field under the pointer (focused or not) pastes it later in this
|
||
// pass. It is not a shortcut.
|
||
ctx.input_mut(|i| {
|
||
i.events.retain(|e| match e {
|
||
egui::Event::PasteImage(image) => {
|
||
if image_paste {
|
||
if pasted_image.is_none() {
|
||
let pixels: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
|
||
let rendered = vectorcraft_render::Rendered {
|
||
width: u32::try_from(image.size[0]).unwrap_or(0),
|
||
height: u32::try_from(image.size[1]).unwrap_or(0),
|
||
pixels,
|
||
};
|
||
// A renderer failure leaves the paste as the no-op an empty flavour gets.
|
||
pasted_image = rendered.to_png().ok();
|
||
}
|
||
} else {
|
||
read_image_formats = true;
|
||
}
|
||
false
|
||
}
|
||
_ => true,
|
||
})
|
||
});
|
||
if read_image_formats {
|
||
// A text paste in the same frame wins, as it does for a clipboard with text and a picture:
|
||
// the paste chord path below reads the text. An image-only clipboard is read here.
|
||
if pasted_image.is_none() && textless_paste.is_none() && !ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Paste(_)))) {
|
||
// Remember the chord as it was when the image was pasted: the next `tick` runs in a
|
||
// later input pass whose modifiers need not describe this paste. The request also
|
||
// schedules that tick; nothing else may be coming to run it.
|
||
app.clipboard_image_request = Some(ctx.input(|i| i.modifiers));
|
||
ctx.request_repaint();
|
||
}
|
||
}
|
||
if let Some(png) = pasted_image {
|
||
let held = ctx.input(|i| i.modifiers);
|
||
app.clipboard_in = Some(Flavour { mime: vectorcraft_engine::cmd::clipboard::PNG, data: png });
|
||
crate::menus::invoke(app, paste_command(held), json!({}));
|
||
return;
|
||
}
|
||
// Clipboard keys arrive as events, not key presses; the chord held says which paste.
|
||
let mut clip = vec![];
|
||
ctx.input_mut(|i| {
|
||
let held = i.modifiers;
|
||
i.events.retain(|e| {
|
||
let (id, text) = match e {
|
||
egui::Event::Copy => ("edit.copy", None),
|
||
egui::Event::Cut => ("edit.cut", None),
|
||
egui::Event::Paste(t) => (paste_command(held), Some(t.clone())),
|
||
_ => return true,
|
||
};
|
||
clip.push((id, text));
|
||
false
|
||
})
|
||
});
|
||
// Only the system clipboard service reads what isn't text.
|
||
if let Some(id) = textless_paste.map(paste_command)
|
||
&& app.services.system_clipboard.is_some()
|
||
{
|
||
clip.push((id, None));
|
||
}
|
||
for (id, text) in clip {
|
||
app.clipboard_in = text.map(|t| Flavour { mime: TEXT, data: t.into_bytes() });
|
||
crate::menus::invoke(app, id, json!({}));
|
||
}
|
||
}
|
||
|
||
/// Route arrows, digits and letters to a tool while a gesture is active.
|
||
fn busy_tool_keys(app: &mut VectorcraftApp, ctx: &egui::Context, view: ViewInfo) {
|
||
// The arrows while dragging: a polygon's sides, a grid's rows (↑/↓) and columns (←/→)…
|
||
let arrows = [(Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)];
|
||
for (k, tk) in arrows {
|
||
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) {
|
||
let _ = app.session.tool_key(tk, Mods::default(), view);
|
||
}
|
||
}
|
||
// Digits (5 while dragging with the Perspective Selection tool), once per press.
|
||
let digits: Vec<u8> = ctx.input(|i| {
|
||
i.events
|
||
.iter()
|
||
.filter_map(|e| match e {
|
||
egui::Event::Key { key, pressed: true, repeat: false, .. } => digit_of(*key),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
});
|
||
for d in digits {
|
||
let _ = app.session.tool_key(ToolKey::Digit(d), Mods::default(), view);
|
||
}
|
||
// Tab, when the tool takes it (the Smart Pen Tool placing an anchor).
|
||
if app.session.tool_claims_key(ToolKey::Tab, view) && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Tab)) {
|
||
let _ = app.session.tool_key(ToolKey::Tab, Mods::default(), view);
|
||
}
|
||
// The tool gets each letter it takes (E while dragging a selection marquee) once per press,
|
||
// without Cmd, Ctrl or Alt. The key is the one egui reports, which on a layout without Latin
|
||
// letters is the key at that position on a US layout.
|
||
let letters: Vec<char> = ctx.input(|i| {
|
||
i.events
|
||
.iter()
|
||
.filter_map(|e| match e {
|
||
egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. } if !(modifiers.command || modifiers.alt || modifiers.ctrl) => {
|
||
letter_of(*key)
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
});
|
||
for c in letters {
|
||
if app.session.tool_claims_key(ToolKey::Letter(c), view) {
|
||
let _ = app.session.tool_key(ToolKey::Letter(c), Mods::default(), view);
|
||
}
|
||
}
|
||
// Between clicks, the active tool's shortcut chooses that tool again (W with the Blend
|
||
// tool's first pick held finishes the blend). While the button is held, it does nothing.
|
||
if !app.session.pointer_held() {
|
||
let presses = ctx.input(|i| shortcut_presses(&i.events, i.modifiers));
|
||
let keymap = crate::shortcut_editor::current();
|
||
let active = app.session.tool_id();
|
||
let again = presses.iter().any(|(names, letter)| {
|
||
let claimed = letter.is_some_and(|c| app.session.tool_claims_key(ToolKey::Letter(c), view));
|
||
// The first name bound to anything is the press's shortcut (Shift+W is the Width
|
||
// tool's, not W's).
|
||
!claimed
|
||
&& names
|
||
.iter()
|
||
.find_map(|name| crate::keymap::vc_chord(name).and_then(|c| crate::keymap::lookup(&keymap.keymap, &c)))
|
||
.is_some_and(|c| c.id.strip_prefix("tool:") == Some(active))
|
||
});
|
||
if again {
|
||
app.select_tool(active);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Give keys claimed by the active tool precedence over their command shortcuts.
|
||
fn claimed_tool_keys(app: &mut VectorcraftApp, ctx: &egui::Context, view: ViewInfo) -> bool {
|
||
let m = ctx.input(|i| i.modifiers);
|
||
for (k, tk) in [
|
||
(Key::Delete, ToolKey::Delete),
|
||
(Key::Backspace, ToolKey::Backspace),
|
||
(Key::ArrowLeft, ToolKey::Left),
|
||
(Key::ArrowRight, ToolKey::Right),
|
||
(Key::ArrowUp, ToolKey::Up),
|
||
(Key::ArrowDown, ToolKey::Down),
|
||
// The Smart Pen Tool's Tab while it draws a path (the key otherwise hides the panels).
|
||
(Key::Tab, ToolKey::Tab),
|
||
] {
|
||
if ctx.input(|i| i.key_pressed(k)) && app.session.tool_claims_key(tk, view) && ctx.input_mut(|i| i.consume_key(m, k)) {
|
||
let r = app.session.tool_key(tk, crate::canvas::mods(m, false), view);
|
||
// Keyboard actions can change the Artboard tool's active board too. Keep the
|
||
// navigator aligned with its valid survivor, as pointer dispatch does.
|
||
if r.is_ok()
|
||
&& app.session.tool_id() == "artboard"
|
||
&& let Some(i) = app.session.tool_options()["active"].as_u64().and_then(|i| usize::try_from(i).ok())
|
||
&& app.session.active().is_some_and(|d| i < d.doc.artboards.len())
|
||
&& let Some(v) = app.view_mut()
|
||
{
|
||
v.artboard = i;
|
||
}
|
||
crate::canvas::apply_requests(app, r);
|
||
return true;
|
||
}
|
||
}
|
||
// Digits the active tool or the perspective grid takes (1–4 pick the plane while it shows).
|
||
if !(m.command || m.alt || m.ctrl) {
|
||
let digits: Vec<(u8, Key)> = ctx.input(|i| {
|
||
i.events
|
||
.iter()
|
||
.filter_map(|e| match e {
|
||
egui::Event::Key { key, pressed: true, .. } => digit_of(*key).map(|d| (d, *key)),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
});
|
||
for (d, k) in digits {
|
||
if app.session.tool_claims_key(ToolKey::Digit(d), view) && ctx.input_mut(|i| i.consume_key(m, k)) {
|
||
// The digit's text is the key's too: no single-key shortcut sees it.
|
||
let text = d.to_string();
|
||
ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Text(t) if *t == text)));
|
||
let r = app.session.tool_key(ToolKey::Digit(d), crate::canvas::mods(m, false), view);
|
||
crate::canvas::apply_requests(app, r);
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
// `[` and `]` the active tool takes (the Blob Brush's and the Eraser's size, the Symbolism
|
||
// tools' diameter, with Shift their intensity). Read as typed, so
|
||
// layouts where a bracket takes Option or AltGr count too; `{` and `}` are Shift's.
|
||
if !(m.command || m.ctrl) {
|
||
let typed: Vec<(String, ToolKey, bool)> = ctx.input(|i| {
|
||
i.events
|
||
.iter()
|
||
.filter_map(|e| match e {
|
||
egui::Event::Text(t) => match t.as_str() {
|
||
"[" => Some((t.clone(), ToolKey::BracketLeft, false)),
|
||
"]" => Some((t.clone(), ToolKey::BracketRight, false)),
|
||
"{" => Some((t.clone(), ToolKey::BracketLeft, true)),
|
||
"}" => Some((t.clone(), ToolKey::BracketRight, true)),
|
||
_ => None,
|
||
},
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
});
|
||
let mut taken = false;
|
||
for (text, key, shift) in typed {
|
||
if app.session.tool_claims_key(key, view) {
|
||
ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Text(t) if *t == text)));
|
||
let r = app.session.tool_key(key, Mods { shift, ..Mods::default() }, view);
|
||
crate::canvas::apply_requests(app, r);
|
||
taken = true;
|
||
}
|
||
}
|
||
if taken {
|
||
return true;
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Runs this frame's key presses against the keymap in effect; `accept` declines a hit, leaving
|
||
/// its press (and the text it typed) to what follows.
|
||
fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, accept: impl FnMut(&Hit) -> bool) -> Vec<CommandRef> {
|
||
let mirror = crate::shortcut_editor::current();
|
||
let dispatcher = &mut app.dispatcher;
|
||
ctx.input_mut(|i| {
|
||
// Shift+Comma and Shift+Period follow their key positions when the logical character
|
||
// differs. An explicitly bound logical shifted chord takes precedence.
|
||
for e in &mut i.events {
|
||
if let egui::Event::Key { key, physical_key: Some(physical), modifiers, pressed: true, .. } = e
|
||
&& modifiers.shift
|
||
&& !modifiers.command
|
||
&& !modifiers.ctrl
|
||
&& !modifiers.alt
|
||
&& !modifiers.mac_cmd
|
||
&& matches!(*physical, Key::Comma | Key::Period)
|
||
&& *key != *physical
|
||
&& let (Some(logical), Some(position)) = (craft_keymap::Key::from_egui(*key), craft_keymap::Key::from_egui(*physical))
|
||
{
|
||
let mods = craft_keymap::Mods::from_egui(modifiers, crate::keymap::platform());
|
||
let logical_chord = craft_keymap::Chord::new(mods, logical);
|
||
let position_chord = craft_keymap::Chord::new(mods, position);
|
||
if crate::keymap::lookup(&mirror.keymap, &logical_chord).is_some() {
|
||
*physical = *key;
|
||
} else if crate::keymap::lookup(&mirror.keymap, &position_chord).is_some() {
|
||
*key = *physical;
|
||
}
|
||
}
|
||
}
|
||
// Shift+`=` types `+`: as the Plus key it also reaches a `=` chord held with Shift
|
||
// (Cmd+Shift+= zooms in as Cmd+= does).
|
||
for e in &mut i.events {
|
||
if let egui::Event::Key { key, modifiers, .. } = e
|
||
&& *key == Key::Equals
|
||
&& modifiers.shift
|
||
{
|
||
*key = Key::Plus;
|
||
}
|
||
}
|
||
dispatcher.dispatch_filtered(i, &mirror.keymap, &Active::NONE, accept).into_iter().map(|h| h.command).collect()
|
||
})
|
||
}
|
||
|
||
/// Params used when a shortcut invokes its command. The named panel function keys retain their
|
||
/// toggle action when their bindings are reassigned.
|
||
fn shortcut_params(c: &CommandRef) -> serde_json::Value {
|
||
let mut params = if c.params.is_null() { json!({}) } else { c.params.clone() };
|
||
if c.id == "window.panel"
|
||
&& matches!(
|
||
params.get("panel").and_then(serde_json::Value::as_str),
|
||
Some(
|
||
"brushes"
|
||
| "color"
|
||
| "layers"
|
||
| "info"
|
||
| "gradient"
|
||
| "stroke"
|
||
| "attributes"
|
||
| "graphicStyles"
|
||
| "appearance"
|
||
| "align"
|
||
| "transform"
|
||
| "pathfinder"
|
||
| "transparency"
|
||
| "symbols"
|
||
)
|
||
)
|
||
{
|
||
params["toggle"] = json!(true);
|
||
params["toggleGroup"] = json!(true);
|
||
}
|
||
params
|
||
}
|
||
|
||
/// Runs what a shortcut is bound to: a tool, a panel (`window.panel {panel}`) or a command.
|
||
fn run_hit(app: &mut VectorcraftApp, ctx: &egui::Context, c: &CommandRef) {
|
||
if let Some(tool) = c.id.strip_prefix("tool:") {
|
||
app.select_tool(tool);
|
||
} else if c.id == "select.all" && crate::panels::artboards::selects_all(app, ctx) {
|
||
// With the Artboard tool, Select All's keys after a click on an Artboards panel row select
|
||
// every artboard instead of the art.
|
||
crate::panels::artboards::select_all(app);
|
||
} else {
|
||
crate::menus::invoke(app, &c.id, shortcut_params(c));
|
||
}
|
||
}
|
||
|
||
/// Keys that type a character: letters, digits and punctuation (not Space).
|
||
fn types_text(key: craft_keymap::Key) -> bool {
|
||
key.name().chars().count() == 1
|
||
}
|
||
|
||
/// Whether the app runs on a Mac (on the web, in a browser on a Mac).
|
||
fn mac(ctx: &egui::Context) -> bool {
|
||
ctx.os() == egui::os::OperatingSystem::Mac
|
||
}
|
||
|
||
/// On a Mac, Cmd+, opens Settings as in every Mac app (#663), beside Preferences' own shortcut,
|
||
/// when no shortcut took the press this frame. Runs after [`dispatch`]. `true` when it ran.
|
||
fn settings_on_a_mac(app: &mut VectorcraftApp, ctx: &egui::Context, mac: bool) -> bool {
|
||
if !mac {
|
||
return false;
|
||
}
|
||
let pressed = ctx.input_mut(|i| {
|
||
let at = i.events.iter().position(
|
||
|e| matches!(e, egui::Event::Key { key: Key::Comma, pressed: true, modifiers, .. } if modifiers.matches_exact(Modifiers::COMMAND)),
|
||
);
|
||
at.map(|at| i.events.remove(at)).is_some()
|
||
});
|
||
if pressed {
|
||
crate::menus::invoke(app, "edit.preferences", json!({}));
|
||
}
|
||
pressed
|
||
}
|
||
|
||
/// What a single-key shortcut reads for `text`, typed by the key at `physical`: the text itself,
|
||
/// or, when it holds no ASCII character (a layout without Latin letters), what that key types on a
|
||
/// US layout, so V selects the Selection tool whatever the V key types.
|
||
fn latin(text: &str, physical: Option<Key>) -> String {
|
||
match physical {
|
||
Some(k) if !text.is_ascii() => k.symbol_or_name().to_lowercase(),
|
||
_ => text.to_string(),
|
||
}
|
||
}
|
||
|
||
/// For each event, the index of the key press that typed it when it is text: the press right
|
||
/// before it (egui-winit) unless an earlier text took that press, or else the press right after it
|
||
/// (eframe's web build with no text field focused). Modifier key presses type no text. Each press
|
||
/// types at most one text. A text takes the press after it only when every later text in the same
|
||
/// run of alternating texts and presses has a press after it too; otherwise the text after that
|
||
/// press takes it as the press before it, and this text has none.
|
||
fn typing_presses(events: &[egui::Event]) -> Vec<Option<usize>> {
|
||
let is_press = |k: usize| matches!(events.get(k), Some(egui::Event::Key { key, pressed: true, .. }) if !is_modifier(*key));
|
||
let is_text = |k: usize| matches!(events.get(k), Some(egui::Event::Text(_)));
|
||
// Whether the run of alternating texts and presses that starts with the text at `j` ends
|
||
// with a press.
|
||
let ends_with_press = |mut j: usize| loop {
|
||
if !is_press(j + 1) {
|
||
break false;
|
||
}
|
||
if !is_text(j + 2) {
|
||
break true;
|
||
}
|
||
j += 2;
|
||
};
|
||
let mut taken = None;
|
||
events
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(j, e)| {
|
||
if !matches!(e, egui::Event::Text(_)) {
|
||
return None;
|
||
}
|
||
let before = j.checked_sub(1).filter(|&k| is_press(k) && taken != Some(k));
|
||
let by = before.or_else(|| ends_with_press(j).then_some(j + 1));
|
||
taken = by.or(taken);
|
||
by
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Each new press's shortcut names, first match first, and the letter it types, without Cmd, Ctrl
|
||
/// or Alt. A text-only press counts as well (Shift+~ arrives as the text `~` with Shift), unless
|
||
/// the key that typed it repeated. A text's key is the one `typing_presses` pairs it with. Text
|
||
/// reads its key's modifiers, or the latest modifier change (`held` before any), and tries its
|
||
/// key's own name first.
|
||
fn shortcut_presses(events: &[egui::Event], held: Modifiers) -> Vec<(Vec<String>, Option<char>)> {
|
||
let key_name = |key: &Key, m: &Modifiers| if m.shift { format!("Shift+{}", key.name()) } else { key.name().to_string() };
|
||
let press_at = |j: Option<usize>| match j.and_then(|j| events.get(j)) {
|
||
Some(egui::Event::Key { key, pressed: true, repeat, modifiers, .. }) => Some((*repeat, *modifiers, key_name(key, modifiers))),
|
||
_ => None,
|
||
};
|
||
let typed_by = typing_presses(events);
|
||
let mut latest = held;
|
||
events
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(j, e)| match e {
|
||
egui::Event::ModifiersChanged(m) => {
|
||
latest = *m;
|
||
None
|
||
}
|
||
egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. } if !(modifiers.command || modifiers.alt || modifiers.ctrl) => {
|
||
Some((vec![key_name(key, modifiers)], letter_of(*key)))
|
||
}
|
||
egui::Event::Text(t) => {
|
||
let (repeated, mods, key_name) =
|
||
press_at(typed_by.get(j).copied().flatten()).map_or((false, latest, None), |(r, m, n)| (r, m, Some(n)));
|
||
if repeated || mods.command || mods.alt || mods.ctrl {
|
||
return None;
|
||
}
|
||
let upper = t.to_uppercase();
|
||
let alpha = t.chars().all(|c| c.is_alphabetic());
|
||
// A shifted letter is only Shift+letter; other shifted text may also be written
|
||
// without its Shift.
|
||
let text_names = match (mods.shift, alpha) {
|
||
(true, true) => vec![format!("Shift+{upper}")],
|
||
(true, false) => vec![upper, t.clone(), format!("Shift+{t}")],
|
||
(false, _) => vec![upper, t.clone()],
|
||
};
|
||
let names: Vec<String> = key_name.into_iter().chain(text_names).collect();
|
||
let mut chars = t.chars();
|
||
let letter = chars.next().filter(|c| c.is_ascii_alphabetic() && chars.next().is_none()).map(|c| c.to_ascii_lowercase());
|
||
Some((names, letter))
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The digit a number-row or keypad key types.
|
||
fn digit_of(k: Key) -> Option<u8> {
|
||
const DIGITS: [Key; 10] = [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9];
|
||
DIGITS.iter().position(|d| *d == k).map(|i| i as u8)
|
||
}
|
||
|
||
/// The lowercase letter a letter key A–Z stands for.
|
||
fn letter_of(k: Key) -> Option<char> {
|
||
let mut name = k.name().chars();
|
||
match (name.next(), name.next()) {
|
||
(Some(c), None) if c.is_ascii_alphabetic() => Some(c.to_ascii_lowercase()),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|