mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 03:37:00 -06:00
refactor(ui-egui): move shortcuts.rs items to shortcuts/{paste,presentation,typing}.rs
This commit is contained in:
+13
-488
@@ -11,6 +11,19 @@ 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};
|
||||
|
||||
/// 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.
|
||||
@@ -91,18 +104,6 @@ pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json
|
||||
v
|
||||
}
|
||||
|
||||
/// Keys that paste with Cmd, or alone. (Shift+Insert is left out: Ctrl+Insert copies.)
|
||||
const PASTE_KEYS: [Key; 2] = [Key::V, Key::Paste];
|
||||
|
||||
/// The paste command whose V chord `held` holds (Paste in Place for Cmd+Shift+V by default), else
|
||||
/// Paste: egui reports every Cmd+V chord as a paste, never as its key.
|
||||
pub(crate) fn paste_command(held: Modifiers) -> &'static str {
|
||||
all_shortcuts()
|
||||
.into_iter()
|
||||
.find(|(sc, id, _)| sc.logical_key == Key::V && id.starts_with("edit.paste") && held.matches_logically(sc.modifiers))
|
||||
.map_or("edit.paste", |(_, id, _)| id)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -140,380 +141,6 @@ pub fn runs_a_shortcut(key: &str, code: &str, modifiers: Modifiers) -> bool {
|
||||
craft_keymap::Layout::default().candidates(&input).iter().any(|c| mirror.keymap.lookup(c, false, &Active::NONE) != craft_keymap::Lookup::None)
|
||||
}
|
||||
|
||||
/// Give a copy or cut chord held with more modifiers its key back when it has a shortcut of its own:
|
||||
/// egui reports every Cmd+C and Cmd+X chord as Copy or Cut, Shift and Alt held too, so Cmd+Shift+C
|
||||
/// (Align center) copied instead (#1940).
|
||||
fn unclip_chords(i: &mut egui::InputState) {
|
||||
if !i.events.iter().any(|e| matches!(e, egui::Event::Copy | egui::Event::Cut)) {
|
||||
return;
|
||||
}
|
||||
let (held, shortcuts) = (i.modifiers, all_shortcuts());
|
||||
for e in &mut i.events {
|
||||
let (key, own) = match e {
|
||||
egui::Event::Copy => (Key::C, "edit.copy"),
|
||||
egui::Event::Cut => (Key::X, "edit.cut"),
|
||||
_ => continue,
|
||||
};
|
||||
if shortcuts.iter().any(|(sc, id, _)| sc.logical_key == key && *id != own && held.matches_exact(sc.modifiers)) {
|
||||
*e = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: held };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop each key press made with Ctrl and Alt held that is followed by the text it typed. Firefox
|
||||
/// on Windows reports AltGr as Ctrl and Alt held, so AltGr+L on a Polish layout arrives as a
|
||||
/// Ctrl+Alt+L press and then the text "ł", which the Type tool types. Without the press no command
|
||||
/// chord runs for it: neither Cmd+Alt+L nor Cmd+L, which egui matches with the extra Alt held.
|
||||
fn drop_typing_presses(i: &mut egui::InputState) {
|
||||
let next = i.events.iter().skip(1).map(Some).chain([None]);
|
||||
let typed: Vec<bool> = i
|
||||
.events
|
||||
.iter()
|
||||
.zip(next)
|
||||
.map(|(e, next)| {
|
||||
matches!(e, egui::Event::Key { pressed: true, modifiers, .. } if modifiers.ctrl && modifiers.alt)
|
||||
&& matches!(next, Some(egui::Event::Text(t)) if !t.is_empty())
|
||||
})
|
||||
.collect();
|
||||
let mut typed = typed.into_iter();
|
||||
i.events.retain(|_| !typed.next().unwrap_or(false));
|
||||
}
|
||||
|
||||
/// Keyboard pastes of something other than text. egui swallows a paste chord's key press and
|
||||
/// sends a Paste event only when the clipboard holds text, so a paste key released without its
|
||||
/// press (or a Paste event) reported was a paste of a bitmap or a PDF.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct PasteChord {
|
||||
/// Paste keys whose press was reported (typed, not pasting).
|
||||
pressed: Vec<Key>,
|
||||
/// A Paste event came since the last paste key was released.
|
||||
pasted: bool,
|
||||
}
|
||||
|
||||
impl PasteChord {
|
||||
/// Follow one frame's `events` → the modifiers of a paste egui sent no event for (the chord's,
|
||||
/// as its key is released).
|
||||
pub(crate) fn textless_paste(&mut self, events: &[egui::Event]) -> Option<Modifiers> {
|
||||
let mut fire = None;
|
||||
for e in events {
|
||||
match e {
|
||||
egui::Event::Paste(_) => self.pasted = true,
|
||||
// A release that comes while the window is away is never seen.
|
||||
egui::Event::WindowFocused(false) => *self = Self::default(),
|
||||
egui::Event::Key { key, pressed: true, .. } if PASTE_KEYS.contains(key) && !self.pressed.contains(key) => self.pressed.push(*key),
|
||||
egui::Event::Key { key, pressed: false, modifiers, .. } if PASTE_KEYS.contains(key) => {
|
||||
match self.pressed.iter().position(|k| k == key) {
|
||||
Some(i) => {
|
||||
self.pressed.swap_remove(i);
|
||||
}
|
||||
// Unless a Paste event came (the guard takes it either way).
|
||||
None if !std::mem::take(&mut self.pasted) => fire = Some(*modifiers),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fire
|
||||
}
|
||||
}
|
||||
|
||||
/// End an IME composition outside the IME (a click away, the IME going away): the
|
||||
/// marked text stays as typed, and the system IME is told to drop it.
|
||||
pub(crate) fn keep_marked_text(app: &mut VectorcraftApp) {
|
||||
let Some(m) = app.ime_marked.take() else { return };
|
||||
app.ime_discard = true;
|
||||
if app.session.tool_composing() {
|
||||
// Errors are already reported by the engine's interaction.
|
||||
let _ = app.session.tool_text(&m, app.view_info());
|
||||
}
|
||||
}
|
||||
|
||||
/// Cmd/Ctrl chords are shortcuts (Create Outlines, Bold, …): their letter is never typed into the
|
||||
/// text if a host still emits `Text` for them (#2094). Alt is left to type: Option+key on macOS
|
||||
/// and AltGr (Ctrl+Alt) on Windows keyboards type characters such as é, ø and @.
|
||||
fn is_shortcut_chord(m: egui::Modifiers) -> bool {
|
||||
(m.command || m.ctrl) && !m.alt
|
||||
}
|
||||
|
||||
enum Seq {
|
||||
Text(String),
|
||||
Preedit(String, Option<std::ops::Range<usize>>),
|
||||
Commit(String),
|
||||
Erase(ToolKey, Mods),
|
||||
/// An input method replaces text around the composition (upstream's character counts or
|
||||
/// the fork's exact UTF-8 byte counts), then commits.
|
||||
DeleteSurrounding(usize, usize, SurroundingUnit),
|
||||
// The actual pointer/key still belongs to its existing handler. Its position in this
|
||||
// sequence must nevertheless interrupt an old composition before later terminal input.
|
||||
InterruptPointer,
|
||||
InterruptNavigation,
|
||||
InterruptCommand,
|
||||
ResetCompleted(egui::ImeResetId),
|
||||
}
|
||||
|
||||
/// Typed text and IME events, in the order they came, to the Type tool.
|
||||
fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
|
||||
let view = app.view_info();
|
||||
// Switching apps mid-composition needs nothing: the macOS IME keeps its marked text and goes
|
||||
// on with it when the window comes back (measured with Kotoeri), as the tool does.
|
||||
let shortcut_mods = ctx.input(|i| is_shortcut_chord(i.modifiers));
|
||||
// A frame that began composing keeps its editing keys for the IME: a Backspace that empties the
|
||||
// marked text must not then delete the committed character before it.
|
||||
let was_composing = app.session.tool_composing();
|
||||
// Text, IME events and plain Backspace/Delete are taken together, in the order they arrived, so
|
||||
// a keyboard-hook IME (UniKey/OpenKey) whose tone key sends Backspace(s) and then re-injects
|
||||
// characters in one frame interleaves correctly. Taken as two passes (all text, then the keys)
|
||||
// the deletes land after the inserts and the diacritics are lost. Each handled event is removed
|
||||
// so `route_type_input` below does not process the same Backspace/Delete again.
|
||||
let seq = type_sequence(ctx);
|
||||
let viewport = ctx.viewport_id();
|
||||
let mut old_composition_retired = false;
|
||||
for e in seq {
|
||||
// Before the UI, a changed composition or guard is settled `pending`: a token bound here
|
||||
// could outlive a route this pass invalidates later (a palette/dialog/Home) and attach to
|
||||
// the stock descriptor. The post-UI finalizer binds the confirmed replacement.
|
||||
crate::ime::sync_incoming_before_ui(app, ctx);
|
||||
if let Seq::ResetCompleted(request) = &e {
|
||||
old_composition_retired |= crate::ime::complete(app, ctx, *request);
|
||||
continue;
|
||||
}
|
||||
if matches!(app.ime_compositions.get(&viewport), Some(crate::ime::IncomingComposition::Stale)) {
|
||||
match &e {
|
||||
// Accepted old payloads precede the fence even when their terminal arrives.
|
||||
// None of these events proves the local reset has reached its boundary.
|
||||
Seq::Commit(_) | Seq::DeleteSurrounding(..) | Seq::Preedit(..) => continue,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// A pointer/navigation/command notice has not reached its real handler yet. Text and
|
||||
// erases after it belong to the old caret, not to the destination it will establish.
|
||||
if crate::ime::deferred(app, ctx) {
|
||||
match &e {
|
||||
Seq::Text(_) | Seq::Erase(..) => continue,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let preedit_empty = match &e {
|
||||
Seq::Preedit(text, _) => Some(text.is_empty()),
|
||||
_ => None,
|
||||
};
|
||||
let terminal_commit = matches!(&e, Seq::Commit(_));
|
||||
let from_ime = matches!(&e, Seq::Preedit(..) | Seq::Commit(_) | Seq::DeleteSurrounding(..));
|
||||
// Errors are already reported by the engine's interaction (a failed preview keeps it).
|
||||
let result = apply_type_event(app, ctx, view, e, shortcut_mods, was_composing, &mut old_composition_retired);
|
||||
if result.is_ok() && from_ime {
|
||||
if terminal_commit {
|
||||
app.ime_compositions.remove(&viewport);
|
||||
} else {
|
||||
crate::ime::remember_incoming(app, ctx, preedit_empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
old_composition_retired
|
||||
}
|
||||
|
||||
/// Take this frame's typed text, IME events and plain Backspace/Delete, in the order they arrived.
|
||||
fn type_sequence(ctx: &egui::Context) -> Vec<Seq> {
|
||||
let mut seq = vec![];
|
||||
ctx.input_mut(|i| {
|
||||
// Move the events out instead of cloning their strings: a keyboard hook that reinjects a
|
||||
// batch keeps the batch in `seq` only, not in both the input list and a second copy.
|
||||
let mut kept = Vec::with_capacity(i.events.len());
|
||||
// InputState's held flag describes the end of the batch. Recover its initial state from
|
||||
// the first transition, so a move before a later press cannot cancel an earlier commit.
|
||||
let mut primary_down = i
|
||||
.events
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => Some(!pressed),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| i.pointer.primary_down());
|
||||
for e in std::mem::take(&mut i.events) {
|
||||
match e {
|
||||
egui::Event::Text(t) => seq.push(Seq::Text(t)),
|
||||
egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => {
|
||||
seq.push(Seq::Preedit(text, active_range_chars));
|
||||
}
|
||||
egui::Event::Ime(egui::ImeEvent::Commit(t)) => seq.push(Seq::Commit(t)),
|
||||
egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => {
|
||||
seq.push(Seq::DeleteSurrounding(before_chars, after_chars, SurroundingUnit::Chars));
|
||||
}
|
||||
egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }) => {
|
||||
seq.push(Seq::DeleteSurrounding(before_bytes, after_bytes, SurroundingUnit::Bytes));
|
||||
}
|
||||
egui::Event::ImeResetCompleted { request_id } => seq.push(Seq::ResetCompleted(request_id)),
|
||||
// X11/Wayland PRIMARY middle-click: text at the pointer, inserted as typed text (never
|
||||
// routed as a key or shortcut).
|
||||
egui::Event::MiddleClickPaste { text, .. } => seq.push(Seq::Text(text)),
|
||||
// Only unmodified Backspace/Delete interleave with injected text; word-delete and other
|
||||
// modified forms stay with `route_type_input`.
|
||||
egui::Event::Key { key: key @ (Key::Backspace | Key::Delete), pressed: true, modifiers: m, .. }
|
||||
if !m.command && !m.alt && !m.ctrl =>
|
||||
{
|
||||
let tk = if key == Key::Backspace { ToolKey::Backspace } else { ToolKey::Delete };
|
||||
seq.push(Seq::Erase(tk, Mods { shift: m.shift, ..Mods::default() }));
|
||||
}
|
||||
e @ egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => {
|
||||
primary_down = pressed;
|
||||
if pressed {
|
||||
seq.push(Seq::InterruptPointer);
|
||||
}
|
||||
kept.push(e);
|
||||
}
|
||||
e @ egui::Event::PointerMoved(_) => {
|
||||
if primary_down {
|
||||
seq.push(Seq::InterruptPointer);
|
||||
}
|
||||
kept.push(e);
|
||||
}
|
||||
e @ egui::Event::Key { key, pressed: true, modifiers, .. }
|
||||
if matches!(key, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | Key::Home | Key::End)
|
||||
|| key == Key::A && modifiers.command =>
|
||||
{
|
||||
seq.push(Seq::InterruptNavigation);
|
||||
kept.push(e);
|
||||
}
|
||||
e @ egui::Event::Key { pressed: true, modifiers, .. } if is_shortcut_chord(modifiers) => {
|
||||
seq.push(Seq::InterruptCommand);
|
||||
kept.push(e);
|
||||
}
|
||||
e @ (egui::Event::Cut | egui::Event::Paste(_)) => {
|
||||
seq.push(Seq::InterruptCommand);
|
||||
kept.push(e);
|
||||
}
|
||||
other => kept.push(other),
|
||||
}
|
||||
}
|
||||
i.events = kept;
|
||||
});
|
||||
seq
|
||||
}
|
||||
|
||||
/// Apply one sequence item to the active tool; the caller filters stale/deferred events and
|
||||
/// turns a successful IME mutation into its receipt.
|
||||
fn apply_type_event(
|
||||
app: &mut VectorcraftApp,
|
||||
ctx: &egui::Context,
|
||||
view: ViewInfo,
|
||||
e: Seq,
|
||||
shortcut_mods: bool,
|
||||
was_composing: bool,
|
||||
old_composition_retired: &mut bool,
|
||||
) -> EngineResult<Vec<UiRequest>> {
|
||||
match e {
|
||||
Seq::InterruptPointer => {
|
||||
*old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
|
||||
Ok(vec![])
|
||||
}
|
||||
Seq::InterruptNavigation => {
|
||||
// Active composition owns its navigation keys. Once cleared, application caret
|
||||
// navigation interrupts its still-pending terminal event, even in this batch.
|
||||
if !app.session.tool_composing() {
|
||||
*old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
Seq::InterruptCommand => {
|
||||
if crate::ime::blocked(app, ctx) {
|
||||
*old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
Seq::ResetCompleted(_) => Ok(vec![]),
|
||||
// Keys the IME passed through come as text only when nothing is marked. This is an
|
||||
// application edit (also a hook IME's reinjected characters): `Other` dominates.
|
||||
Seq::Text(t) if !app.session.tool_composing() && !shortcut_mods => {
|
||||
let target_before = crate::ime::target(&mut app.session, view);
|
||||
let before = crate::ime::stamp(&mut app.session, view);
|
||||
let r = app.session.tool_text(&t, view);
|
||||
if r.is_ok() {
|
||||
crate::ime::note_application_edit(app, ctx, view, target_before, before);
|
||||
}
|
||||
r
|
||||
}
|
||||
Seq::Text(_) => Ok(vec![]),
|
||||
// A pre-edit is not committed text: it changes neither the outgoing snapshot (the
|
||||
// engine query removes it) nor the cause.
|
||||
Seq::Preedit(text, active_range_chars) => {
|
||||
app.ime_marked = Some(text.clone()).filter(|t| !t.is_empty());
|
||||
app.session.tool_preedit(&text, active_range_chars, view)
|
||||
}
|
||||
// A bare line break confirms the composition (Enter): the marked text, not a newline.
|
||||
// It is an accepted IME commit of that marked text when the tool inserts the marked
|
||||
// text unchanged; a commit the tool rewrites (a quoted or filtered marked text) is an
|
||||
// application edit instead.
|
||||
Seq::Commit(t) if t == "\n" || t == "\r" => {
|
||||
let target_before = crate::ime::target(&mut app.session, view);
|
||||
let before = crate::ime::stamp(&mut app.session, view);
|
||||
let composing = app.session.tool_composing();
|
||||
let verbatim = app.session.tool_ime_session_matches(view)
|
||||
&& match app.ime_marked.as_deref() {
|
||||
Some(m) => app.session.tool_text_is_verbatim(m, view),
|
||||
None => true,
|
||||
};
|
||||
keep_marked_text(app);
|
||||
if composing {
|
||||
if verbatim {
|
||||
crate::ime::note_ime_apply(app, ctx, view, target_before, before);
|
||||
} else {
|
||||
crate::ime::note_application_edit(app, ctx, view, target_before, before);
|
||||
}
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
// An accepted IME commit: stamp the bounded committed context immediately before and
|
||||
// after, so only a real mutation (not a no-op, rejection or failure) earns the receipt.
|
||||
// A commit the tool rewrites (Typographer's Quotes, a filtered control character) is an
|
||||
// application edit and must never earn `InputMethod`.
|
||||
Seq::Commit(t) => {
|
||||
app.ime_marked = None;
|
||||
let target_before = crate::ime::target(&mut app.session, view);
|
||||
let before = crate::ime::stamp(&mut app.session, view);
|
||||
// A document edit since the composition means this commit starts a fresh typing
|
||||
// session: that reset is application-side, so it cannot earn `InputMethod`.
|
||||
let verbatim = app.session.tool_ime_session_matches(view) && app.session.tool_text_is_verbatim(&t, view);
|
||||
let r = app.session.tool_text(&t, view);
|
||||
if r.is_ok() {
|
||||
if verbatim {
|
||||
crate::ime::note_ime_apply(app, ctx, view, target_before, before);
|
||||
} else {
|
||||
crate::ime::note_application_edit(app, ctx, view, target_before, before);
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
// While composing (or in a frame that began composing), editing keys belong to the IME.
|
||||
Seq::Erase(tk, m) if !was_composing && !app.session.tool_composing() => {
|
||||
// Mirror `route_type_input`/`text_key`: surface an erase error in the status bar
|
||||
// instead of discarding it, and report the successful edit as application-origin.
|
||||
let target_before = crate::ime::target(&mut app.session, view);
|
||||
let before = crate::ime::stamp(&mut app.session, view);
|
||||
let r = app.session.tool_key(tk, m, view);
|
||||
match &r {
|
||||
Err(e) => app.status(e.to_string()),
|
||||
Ok(_) => crate::ime::note_application_edit(app, ctx, view, target_before, before),
|
||||
}
|
||||
r
|
||||
}
|
||||
Seq::Erase(..) => Ok(vec![]),
|
||||
// Deleting around the composition starts the same typing session as the commit that
|
||||
// follows, so the replacement is one undo step; an error reaches the status bar. The
|
||||
// receipt covers only this accepted deletion.
|
||||
Seq::DeleteSurrounding(before, after, unit) => {
|
||||
let target_before = crate::ime::target(&mut app.session, view);
|
||||
let stamp_before = crate::ime::stamp(&mut app.session, view);
|
||||
let r = app.session.tool_ime_delete_surrounding(before, after, unit, view);
|
||||
match &r {
|
||||
Err(e) => app.status(e.to_string()),
|
||||
Ok(_) => crate::ime::note_ime_apply(app, ctx, view, target_before, stamp_before),
|
||||
}
|
||||
r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -1036,108 +663,6 @@ fn settings_on_a_mac(app: &mut VectorcraftApp, ctx: &egui::Context, mac: bool) -
|
||||
pressed
|
||||
}
|
||||
|
||||
/// The second press of Escape pressed twice comes less than this many seconds after the first.
|
||||
const ESCAPE_PAIR_SECONDS: f64 = 1.0;
|
||||
|
||||
/// The key of the last counted Escape's time in egui's temporary data.
|
||||
fn escape_pair_id() -> egui::Id {
|
||||
egui::Id::unique("escape-pair")
|
||||
}
|
||||
|
||||
/// Reads this frame's presses for Escape pressed twice. `None` when the frame has no Escape press, or
|
||||
/// has another key or pointer button press as well (a key repeat counts as another press, and the
|
||||
/// modifier keys do not count). Otherwise, whether the last counted Escape came less than
|
||||
/// [`ESCAPE_PAIR_SECONDS`] before. A press of Escape, another key or a pointer button removes the
|
||||
/// last counted Escape's time.
|
||||
fn escape_press(ctx: &egui::Context) -> Option<bool> {
|
||||
let (escape, other, now) = ctx.input(|i| {
|
||||
let (mut escape, mut other) = (false, false);
|
||||
for e in &i.events {
|
||||
match e {
|
||||
egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, .. } => escape = true,
|
||||
egui::Event::Key { key, pressed: true, .. } if !is_modifier(*key) => other = true,
|
||||
egui::Event::PointerButton { pressed: true, .. } => other = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(escape, other, i.time)
|
||||
});
|
||||
if !escape && !other {
|
||||
return None;
|
||||
}
|
||||
let counted = ctx.data_mut(|d| d.remove_temp::<f64>(escape_pair_id()));
|
||||
(escape && !other).then(|| counted.is_some_and(|t| now - t < ESCAPE_PAIR_SECONDS))
|
||||
}
|
||||
|
||||
/// Does this Escape reset the view's rotation? It does when the view is rotated, no popup is open,
|
||||
/// the ruler origin is not being dragged, and the press is the second of a pair (`second`) or the
|
||||
/// Rotate View tool is chosen.
|
||||
fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context, second: bool) -> bool {
|
||||
app.view().is_some_and(|v| v.rotation != 0.0)
|
||||
&& (second || app.session.tool_id() == "rotateView")
|
||||
&& !egui::Popup::is_any_open(ctx)
|
||||
&& !crate::canvas::ruler_origin_dragging(ctx)
|
||||
}
|
||||
|
||||
/// The keys in Presentation Mode, where nothing is edited: Escape leaves the mode, and ← and → go to
|
||||
/// the previous and the next artboard ([`presentation_step`]). Every other key press, the text it
|
||||
/// types and the clipboard keys are removed from the input, so no shortcut, tool or field acts on
|
||||
/// them. The macOS menu bar's key equivalents arrive as key presses too ([`crate::native_menu`]).
|
||||
fn presentation_keys(app: &mut VectorcraftApp, ctx: &egui::Context, escape_pair: Option<bool>) {
|
||||
let (mut leave, mut steps) = (false, vec![]);
|
||||
ctx.input_mut(|i| {
|
||||
i.events.retain(|e| match e {
|
||||
egui::Event::Key { key: Key::Escape, pressed: true, .. } => {
|
||||
leave = true;
|
||||
false
|
||||
}
|
||||
egui::Event::Key { key: Key::ArrowLeft, pressed: true, .. } => {
|
||||
steps.push(false);
|
||||
false
|
||||
}
|
||||
egui::Event::Key { key: Key::ArrowRight, pressed: true, .. } => {
|
||||
steps.push(true);
|
||||
false
|
||||
}
|
||||
egui::Event::Key { pressed: true, .. }
|
||||
| egui::Event::Text(_)
|
||||
| egui::Event::Ime(_)
|
||||
| egui::Event::Copy
|
||||
| egui::Event::Cut
|
||||
| egui::Event::Paste(_)
|
||||
| egui::Event::PasteImage(_)
|
||||
| egui::Event::MiddleClickPaste { .. } => false,
|
||||
_ => true,
|
||||
})
|
||||
});
|
||||
for next in steps {
|
||||
presentation_step(app, next);
|
||||
}
|
||||
if leave {
|
||||
let _ = app.run("view.presentation", json!({}));
|
||||
// The Escape that left the mode can start a pair, as in `handle`.
|
||||
if escape_pair.is_some() && !egui::Popup::is_any_open(ctx) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
ctx.data_mut(|d| d.insert_temp(escape_pair_id(), now));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Go to the next artboard (`next`) or the previous one with `view.goToArtboard`, as the status bar
|
||||
/// navigator's Next and Previous buttons do, for the arrow keys and a click in Presentation Mode.
|
||||
/// On the last artboard a step forward leaves the view as it is, and so does a step back on the
|
||||
/// first.
|
||||
pub(crate) fn presentation_step(app: &mut VectorcraftApp, next: bool) {
|
||||
let n = app.session.active().map_or(0, |d| d.doc.artboards.len());
|
||||
let at = crate::panels::artboards::selected(app, n);
|
||||
let to = if next { (at + 1 < n).then_some("next") } else { (at > 0).then_some("previous") };
|
||||
if let Some(to) = to
|
||||
&& let Err(e) = app.run("view.goToArtboard", json!({ "index": to }))
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
use super::*;
|
||||
|
||||
/// Keys that paste with Cmd, or alone. (Shift+Insert is left out: Ctrl+Insert copies.)
|
||||
const PASTE_KEYS: [Key; 2] = [Key::V, Key::Paste];
|
||||
|
||||
/// The paste command whose V chord `held` holds (Paste in Place for Cmd+Shift+V by default), else
|
||||
/// Paste: egui reports every Cmd+V chord as a paste, never as its key.
|
||||
pub(crate) fn paste_command(held: Modifiers) -> &'static str {
|
||||
all_shortcuts()
|
||||
.into_iter()
|
||||
.find(|(sc, id, _)| sc.logical_key == Key::V && id.starts_with("edit.paste") && held.matches_logically(sc.modifiers))
|
||||
.map_or("edit.paste", |(_, id, _)| id)
|
||||
}
|
||||
|
||||
/// Give a copy or cut chord held with more modifiers its key back when it has a shortcut of its own:
|
||||
/// egui reports every Cmd+C and Cmd+X chord as Copy or Cut, Shift and Alt held too, so Cmd+Shift+C
|
||||
/// (Align center) copied instead (#1940).
|
||||
pub(super) fn unclip_chords(i: &mut egui::InputState) {
|
||||
if !i.events.iter().any(|e| matches!(e, egui::Event::Copy | egui::Event::Cut)) {
|
||||
return;
|
||||
}
|
||||
let (held, shortcuts) = (i.modifiers, all_shortcuts());
|
||||
for e in &mut i.events {
|
||||
let (key, own) = match e {
|
||||
egui::Event::Copy => (Key::C, "edit.copy"),
|
||||
egui::Event::Cut => (Key::X, "edit.cut"),
|
||||
_ => continue,
|
||||
};
|
||||
if shortcuts.iter().any(|(sc, id, _)| sc.logical_key == key && *id != own && held.matches_exact(sc.modifiers)) {
|
||||
*e = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: held };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyboard pastes of something other than text. egui swallows a paste chord's key press and
|
||||
/// sends a Paste event only when the clipboard holds text, so a paste key released without its
|
||||
/// press (or a Paste event) reported was a paste of a bitmap or a PDF.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct PasteChord {
|
||||
/// Paste keys whose press was reported (typed, not pasting).
|
||||
pressed: Vec<Key>,
|
||||
/// A Paste event came since the last paste key was released.
|
||||
pasted: bool,
|
||||
}
|
||||
|
||||
impl PasteChord {
|
||||
/// Follow one frame's `events` → the modifiers of a paste egui sent no event for (the chord's,
|
||||
/// as its key is released).
|
||||
pub(crate) fn textless_paste(&mut self, events: &[egui::Event]) -> Option<Modifiers> {
|
||||
let mut fire = None;
|
||||
for e in events {
|
||||
match e {
|
||||
egui::Event::Paste(_) => self.pasted = true,
|
||||
// A release that comes while the window is away is never seen.
|
||||
egui::Event::WindowFocused(false) => *self = Self::default(),
|
||||
egui::Event::Key { key, pressed: true, .. } if PASTE_KEYS.contains(key) && !self.pressed.contains(key) => self.pressed.push(*key),
|
||||
egui::Event::Key { key, pressed: false, modifiers, .. } if PASTE_KEYS.contains(key) => {
|
||||
match self.pressed.iter().position(|k| k == key) {
|
||||
Some(i) => {
|
||||
self.pressed.swap_remove(i);
|
||||
}
|
||||
// Unless a Paste event came (the guard takes it either way).
|
||||
None if !std::mem::take(&mut self.pasted) => fire = Some(*modifiers),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fire
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use super::*;
|
||||
|
||||
/// The second press of Escape pressed twice comes less than this many seconds after the first.
|
||||
const ESCAPE_PAIR_SECONDS: f64 = 1.0;
|
||||
|
||||
/// The key of the last counted Escape's time in egui's temporary data.
|
||||
pub(super) fn escape_pair_id() -> egui::Id {
|
||||
egui::Id::unique("escape-pair")
|
||||
}
|
||||
|
||||
/// Reads this frame's presses for Escape pressed twice. `None` when the frame has no Escape press, or
|
||||
/// has another key or pointer button press as well (a key repeat counts as another press, and the
|
||||
/// modifier keys do not count). Otherwise, whether the last counted Escape came less than
|
||||
/// [`ESCAPE_PAIR_SECONDS`] before. A press of Escape, another key or a pointer button removes the
|
||||
/// last counted Escape's time.
|
||||
pub(super) fn escape_press(ctx: &egui::Context) -> Option<bool> {
|
||||
let (escape, other, now) = ctx.input(|i| {
|
||||
let (mut escape, mut other) = (false, false);
|
||||
for e in &i.events {
|
||||
match e {
|
||||
egui::Event::Key { key: Key::Escape, pressed: true, repeat: false, .. } => escape = true,
|
||||
egui::Event::Key { key, pressed: true, .. } if !is_modifier(*key) => other = true,
|
||||
egui::Event::PointerButton { pressed: true, .. } => other = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(escape, other, i.time)
|
||||
});
|
||||
if !escape && !other {
|
||||
return None;
|
||||
}
|
||||
let counted = ctx.data_mut(|d| d.remove_temp::<f64>(escape_pair_id()));
|
||||
(escape && !other).then(|| counted.is_some_and(|t| now - t < ESCAPE_PAIR_SECONDS))
|
||||
}
|
||||
|
||||
/// Does this Escape reset the view's rotation? It does when the view is rotated, no popup is open,
|
||||
/// the ruler origin is not being dragged, and the press is the second of a pair (`second`) or the
|
||||
/// Rotate View tool is chosen.
|
||||
pub(super) fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context, second: bool) -> bool {
|
||||
app.view().is_some_and(|v| v.rotation != 0.0)
|
||||
&& (second || app.session.tool_id() == "rotateView")
|
||||
&& !egui::Popup::is_any_open(ctx)
|
||||
&& !crate::canvas::ruler_origin_dragging(ctx)
|
||||
}
|
||||
|
||||
/// The keys in Presentation Mode, where nothing is edited: Escape leaves the mode, and ← and → go to
|
||||
/// the previous and the next artboard ([`presentation_step`]). Every other key press, the text it
|
||||
/// types and the clipboard keys are removed from the input, so no shortcut, tool or field acts on
|
||||
/// them. The macOS menu bar's key equivalents arrive as key presses too ([`crate::native_menu`]).
|
||||
pub(super) fn presentation_keys(app: &mut VectorcraftApp, ctx: &egui::Context, escape_pair: Option<bool>) {
|
||||
let (mut leave, mut steps) = (false, vec![]);
|
||||
ctx.input_mut(|i| {
|
||||
i.events.retain(|e| match e {
|
||||
egui::Event::Key { key: Key::Escape, pressed: true, .. } => {
|
||||
leave = true;
|
||||
false
|
||||
}
|
||||
egui::Event::Key { key: Key::ArrowLeft, pressed: true, .. } => {
|
||||
steps.push(false);
|
||||
false
|
||||
}
|
||||
egui::Event::Key { key: Key::ArrowRight, pressed: true, .. } => {
|
||||
steps.push(true);
|
||||
false
|
||||
}
|
||||
egui::Event::Key { pressed: true, .. }
|
||||
| egui::Event::Text(_)
|
||||
| egui::Event::Ime(_)
|
||||
| egui::Event::Copy
|
||||
| egui::Event::Cut
|
||||
| egui::Event::Paste(_)
|
||||
| egui::Event::PasteImage(_)
|
||||
| egui::Event::MiddleClickPaste { .. } => false,
|
||||
_ => true,
|
||||
})
|
||||
});
|
||||
for next in steps {
|
||||
presentation_step(app, next);
|
||||
}
|
||||
if leave {
|
||||
let _ = app.run("view.presentation", json!({}));
|
||||
// The Escape that left the mode can start a pair, as in `handle`.
|
||||
if escape_pair.is_some() && !egui::Popup::is_any_open(ctx) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
ctx.data_mut(|d| d.insert_temp(escape_pair_id(), now));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Go to the next artboard (`next`) or the previous one with `view.goToArtboard`, as the status bar
|
||||
/// navigator's Next and Previous buttons do, for the arrow keys and a click in Presentation Mode.
|
||||
/// On the last artboard a step forward leaves the view as it is, and so does a step back on the
|
||||
/// first.
|
||||
pub(crate) fn presentation_step(app: &mut VectorcraftApp, next: bool) {
|
||||
let n = app.session.active().map_or(0, |d| d.doc.artboards.len());
|
||||
let at = crate::panels::artboards::selected(app, n);
|
||||
let to = if next { (at + 1 < n).then_some("next") } else { (at > 0).then_some("previous") };
|
||||
if let Some(to) = to
|
||||
&& let Err(e) = app.run("view.goToArtboard", json!({ "index": to }))
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
use super::*;
|
||||
|
||||
/// Drop each key press made with Ctrl and Alt held that is followed by the text it typed. Firefox
|
||||
/// on Windows reports AltGr as Ctrl and Alt held, so AltGr+L on a Polish layout arrives as a
|
||||
/// Ctrl+Alt+L press and then the text "ł", which the Type tool types. Without the press no command
|
||||
/// chord runs for it: neither Cmd+Alt+L nor Cmd+L, which egui matches with the extra Alt held.
|
||||
pub(super) fn drop_typing_presses(i: &mut egui::InputState) {
|
||||
let next = i.events.iter().skip(1).map(Some).chain([None]);
|
||||
let typed: Vec<bool> = i
|
||||
.events
|
||||
.iter()
|
||||
.zip(next)
|
||||
.map(|(e, next)| {
|
||||
matches!(e, egui::Event::Key { pressed: true, modifiers, .. } if modifiers.ctrl && modifiers.alt)
|
||||
&& matches!(next, Some(egui::Event::Text(t)) if !t.is_empty())
|
||||
})
|
||||
.collect();
|
||||
let mut typed = typed.into_iter();
|
||||
i.events.retain(|_| !typed.next().unwrap_or(false));
|
||||
}
|
||||
|
||||
/// End an IME composition outside the IME (a click away, the IME going away): the
|
||||
/// marked text stays as typed, and the system IME is told to drop it.
|
||||
pub(crate) fn keep_marked_text(app: &mut VectorcraftApp) {
|
||||
let Some(m) = app.ime_marked.take() else { return };
|
||||
app.ime_discard = true;
|
||||
if app.session.tool_composing() {
|
||||
// Errors are already reported by the engine's interaction.
|
||||
let _ = app.session.tool_text(&m, app.view_info());
|
||||
}
|
||||
}
|
||||
|
||||
/// Cmd/Ctrl chords are shortcuts (Create Outlines, Bold, …): their letter is never typed into the
|
||||
/// text if a host still emits `Text` for them (#2094). Alt is left to type: Option+key on macOS
|
||||
/// and AltGr (Ctrl+Alt) on Windows keyboards type characters such as é, ø and @.
|
||||
pub(super) fn is_shortcut_chord(m: egui::Modifiers) -> bool {
|
||||
(m.command || m.ctrl) && !m.alt
|
||||
}
|
||||
|
||||
enum Seq {
|
||||
Text(String),
|
||||
Preedit(String, Option<std::ops::Range<usize>>),
|
||||
Commit(String),
|
||||
Erase(ToolKey, Mods),
|
||||
/// An input method replaces text around the composition (upstream's character counts or
|
||||
/// the fork's exact UTF-8 byte counts), then commits.
|
||||
DeleteSurrounding(usize, usize, SurroundingUnit),
|
||||
// The actual pointer/key still belongs to its existing handler. Its position in this
|
||||
// sequence must nevertheless interrupt an old composition before later terminal input.
|
||||
InterruptPointer,
|
||||
InterruptNavigation,
|
||||
InterruptCommand,
|
||||
ResetCompleted(egui::ImeResetId),
|
||||
}
|
||||
|
||||
/// Typed text and IME events, in the order they came, to the Type tool.
|
||||
pub(super) fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
|
||||
let view = app.view_info();
|
||||
// Switching apps mid-composition needs nothing: the macOS IME keeps its marked text and goes
|
||||
// on with it when the window comes back (measured with Kotoeri), as the tool does.
|
||||
let shortcut_mods = ctx.input(|i| is_shortcut_chord(i.modifiers));
|
||||
// A frame that began composing keeps its editing keys for the IME: a Backspace that empties the
|
||||
// marked text must not then delete the committed character before it.
|
||||
let was_composing = app.session.tool_composing();
|
||||
// Text, IME events and plain Backspace/Delete are taken together, in the order they arrived, so
|
||||
// a keyboard-hook IME (UniKey/OpenKey) whose tone key sends Backspace(s) and then re-injects
|
||||
// characters in one frame interleaves correctly. Taken as two passes (all text, then the keys)
|
||||
// the deletes land after the inserts and the diacritics are lost. Each handled event is removed
|
||||
// so `route_type_input` below does not process the same Backspace/Delete again.
|
||||
let seq = type_sequence(ctx);
|
||||
let viewport = ctx.viewport_id();
|
||||
let mut old_composition_retired = false;
|
||||
for e in seq {
|
||||
// Before the UI, a changed composition or guard is settled `pending`: a token bound here
|
||||
// could outlive a route this pass invalidates later (a palette/dialog/Home) and attach to
|
||||
// the stock descriptor. The post-UI finalizer binds the confirmed replacement.
|
||||
crate::ime::sync_incoming_before_ui(app, ctx);
|
||||
if let Seq::ResetCompleted(request) = &e {
|
||||
old_composition_retired |= crate::ime::complete(app, ctx, *request);
|
||||
continue;
|
||||
}
|
||||
if matches!(app.ime_compositions.get(&viewport), Some(crate::ime::IncomingComposition::Stale)) {
|
||||
match &e {
|
||||
// Accepted old payloads precede the fence even when their terminal arrives.
|
||||
// None of these events proves the local reset has reached its boundary.
|
||||
Seq::Commit(_) | Seq::DeleteSurrounding(..) | Seq::Preedit(..) => continue,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// A pointer/navigation/command notice has not reached its real handler yet. Text and
|
||||
// erases after it belong to the old caret, not to the destination it will establish.
|
||||
if crate::ime::deferred(app, ctx) {
|
||||
match &e {
|
||||
Seq::Text(_) | Seq::Erase(..) => continue,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let preedit_empty = match &e {
|
||||
Seq::Preedit(text, _) => Some(text.is_empty()),
|
||||
_ => None,
|
||||
};
|
||||
let terminal_commit = matches!(&e, Seq::Commit(_));
|
||||
let from_ime = matches!(&e, Seq::Preedit(..) | Seq::Commit(_) | Seq::DeleteSurrounding(..));
|
||||
// Errors are already reported by the engine's interaction (a failed preview keeps it).
|
||||
let result = apply_type_event(app, ctx, view, e, shortcut_mods, was_composing, &mut old_composition_retired);
|
||||
if result.is_ok() && from_ime {
|
||||
if terminal_commit {
|
||||
app.ime_compositions.remove(&viewport);
|
||||
} else {
|
||||
crate::ime::remember_incoming(app, ctx, preedit_empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
old_composition_retired
|
||||
}
|
||||
|
||||
/// Take this frame's typed text, IME events and plain Backspace/Delete, in the order they arrived.
|
||||
fn type_sequence(ctx: &egui::Context) -> Vec<Seq> {
|
||||
let mut seq = vec![];
|
||||
ctx.input_mut(|i| {
|
||||
// Move the events out instead of cloning their strings: a keyboard hook that reinjects a
|
||||
// batch keeps the batch in `seq` only, not in both the input list and a second copy.
|
||||
let mut kept = Vec::with_capacity(i.events.len());
|
||||
// InputState's held flag describes the end of the batch. Recover its initial state from
|
||||
// the first transition, so a move before a later press cannot cancel an earlier commit.
|
||||
let mut primary_down = i
|
||||
.events
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => Some(!pressed),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| i.pointer.primary_down());
|
||||
for e in std::mem::take(&mut i.events) {
|
||||
match e {
|
||||
egui::Event::Text(t) => seq.push(Seq::Text(t)),
|
||||
egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => {
|
||||
seq.push(Seq::Preedit(text, active_range_chars));
|
||||
}
|
||||
egui::Event::Ime(egui::ImeEvent::Commit(t)) => seq.push(Seq::Commit(t)),
|
||||
egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => {
|
||||
seq.push(Seq::DeleteSurrounding(before_chars, after_chars, SurroundingUnit::Chars));
|
||||
}
|
||||
egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }) => {
|
||||
seq.push(Seq::DeleteSurrounding(before_bytes, after_bytes, SurroundingUnit::Bytes));
|
||||
}
|
||||
egui::Event::ImeResetCompleted { request_id } => seq.push(Seq::ResetCompleted(request_id)),
|
||||
// X11/Wayland PRIMARY middle-click: text at the pointer, inserted as typed text (never
|
||||
// routed as a key or shortcut).
|
||||
egui::Event::MiddleClickPaste { text, .. } => seq.push(Seq::Text(text)),
|
||||
// Only unmodified Backspace/Delete interleave with injected text; word-delete and other
|
||||
// modified forms stay with `route_type_input`.
|
||||
egui::Event::Key { key: key @ (Key::Backspace | Key::Delete), pressed: true, modifiers: m, .. }
|
||||
if !m.command && !m.alt && !m.ctrl =>
|
||||
{
|
||||
let tk = if key == Key::Backspace { ToolKey::Backspace } else { ToolKey::Delete };
|
||||
seq.push(Seq::Erase(tk, Mods { shift: m.shift, ..Mods::default() }));
|
||||
}
|
||||
e @ egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => {
|
||||
primary_down = pressed;
|
||||
if pressed {
|
||||
seq.push(Seq::InterruptPointer);
|
||||
}
|
||||
kept.push(e);
|
||||
}
|
||||
e @ egui::Event::PointerMoved(_) => {
|
||||
if primary_down {
|
||||
seq.push(Seq::InterruptPointer);
|
||||
}
|
||||
kept.push(e);
|
||||
}
|
||||
e @ egui::Event::Key { key, pressed: true, modifiers, .. }
|
||||
if matches!(key, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | Key::Home | Key::End)
|
||||
|| key == Key::A && modifiers.command =>
|
||||
{
|
||||
seq.push(Seq::InterruptNavigation);
|
||||
kept.push(e);
|
||||
}
|
||||
e @ egui::Event::Key { pressed: true, modifiers, .. } if is_shortcut_chord(modifiers) => {
|
||||
seq.push(Seq::InterruptCommand);
|
||||
kept.push(e);
|
||||
}
|
||||
e @ (egui::Event::Cut | egui::Event::Paste(_)) => {
|
||||
seq.push(Seq::InterruptCommand);
|
||||
kept.push(e);
|
||||
}
|
||||
other => kept.push(other),
|
||||
}
|
||||
}
|
||||
i.events = kept;
|
||||
});
|
||||
seq
|
||||
}
|
||||
|
||||
/// Apply one sequence item to the active tool; the caller filters stale/deferred events and
|
||||
/// turns a successful IME mutation into its receipt.
|
||||
fn apply_type_event(
|
||||
app: &mut VectorcraftApp,
|
||||
ctx: &egui::Context,
|
||||
view: ViewInfo,
|
||||
e: Seq,
|
||||
shortcut_mods: bool,
|
||||
was_composing: bool,
|
||||
old_composition_retired: &mut bool,
|
||||
) -> EngineResult<Vec<UiRequest>> {
|
||||
match e {
|
||||
Seq::InterruptPointer => {
|
||||
*old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
|
||||
Ok(vec![])
|
||||
}
|
||||
Seq::InterruptNavigation => {
|
||||
// Active composition owns its navigation keys. Once cleared, application caret
|
||||
// navigation interrupts its still-pending terminal event, even in this batch.
|
||||
if !app.session.tool_composing() {
|
||||
*old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
Seq::InterruptCommand => {
|
||||
if crate::ime::blocked(app, ctx) {
|
||||
*old_composition_retired |= crate::ime::defer_interrupt(app, ctx);
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
Seq::ResetCompleted(_) => Ok(vec![]),
|
||||
// Keys the IME passed through come as text only when nothing is marked. This is an
|
||||
// application edit (also a hook IME's reinjected characters): `Other` dominates.
|
||||
Seq::Text(t) if !app.session.tool_composing() && !shortcut_mods => {
|
||||
let target_before = crate::ime::target(&mut app.session, view);
|
||||
let before = crate::ime::stamp(&mut app.session, view);
|
||||
let r = app.session.tool_text(&t, view);
|
||||
if r.is_ok() {
|
||||
crate::ime::note_application_edit(app, ctx, view, target_before, before);
|
||||
}
|
||||
r
|
||||
}
|
||||
Seq::Text(_) => Ok(vec![]),
|
||||
// A pre-edit is not committed text: it changes neither the outgoing snapshot (the
|
||||
// engine query removes it) nor the cause.
|
||||
Seq::Preedit(text, active_range_chars) => {
|
||||
app.ime_marked = Some(text.clone()).filter(|t| !t.is_empty());
|
||||
app.session.tool_preedit(&text, active_range_chars, view)
|
||||
}
|
||||
// A bare line break confirms the composition (Enter): the marked text, not a newline.
|
||||
// It is an accepted IME commit of that marked text when the tool inserts the marked
|
||||
// text unchanged; a commit the tool rewrites (a quoted or filtered marked text) is an
|
||||
// application edit instead.
|
||||
Seq::Commit(t) if t == "\n" || t == "\r" => {
|
||||
let target_before = crate::ime::target(&mut app.session, view);
|
||||
let before = crate::ime::stamp(&mut app.session, view);
|
||||
let composing = app.session.tool_composing();
|
||||
let verbatim = app.session.tool_ime_session_matches(view)
|
||||
&& match app.ime_marked.as_deref() {
|
||||
Some(m) => app.session.tool_text_is_verbatim(m, view),
|
||||
None => true,
|
||||
};
|
||||
keep_marked_text(app);
|
||||
if composing {
|
||||
if verbatim {
|
||||
crate::ime::note_ime_apply(app, ctx, view, target_before, before);
|
||||
} else {
|
||||
crate::ime::note_application_edit(app, ctx, view, target_before, before);
|
||||
}
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
// An accepted IME commit: stamp the bounded committed context immediately before and
|
||||
// after, so only a real mutation (not a no-op, rejection or failure) earns the receipt.
|
||||
// A commit the tool rewrites (Typographer's Quotes, a filtered control character) is an
|
||||
// application edit and must never earn `InputMethod`.
|
||||
Seq::Commit(t) => {
|
||||
app.ime_marked = None;
|
||||
let target_before = crate::ime::target(&mut app.session, view);
|
||||
let before = crate::ime::stamp(&mut app.session, view);
|
||||
// A document edit since the composition means this commit starts a fresh typing
|
||||
// session: that reset is application-side, so it cannot earn `InputMethod`.
|
||||
let verbatim = app.session.tool_ime_session_matches(view) && app.session.tool_text_is_verbatim(&t, view);
|
||||
let r = app.session.tool_text(&t, view);
|
||||
if r.is_ok() {
|
||||
if verbatim {
|
||||
crate::ime::note_ime_apply(app, ctx, view, target_before, before);
|
||||
} else {
|
||||
crate::ime::note_application_edit(app, ctx, view, target_before, before);
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
// While composing (or in a frame that began composing), editing keys belong to the IME.
|
||||
Seq::Erase(tk, m) if !was_composing && !app.session.tool_composing() => {
|
||||
// Mirror `route_type_input`/`text_key`: surface an erase error in the status bar
|
||||
// instead of discarding it, and report the successful edit as application-origin.
|
||||
let target_before = crate::ime::target(&mut app.session, view);
|
||||
let before = crate::ime::stamp(&mut app.session, view);
|
||||
let r = app.session.tool_key(tk, m, view);
|
||||
match &r {
|
||||
Err(e) => app.status(e.to_string()),
|
||||
Ok(_) => crate::ime::note_application_edit(app, ctx, view, target_before, before),
|
||||
}
|
||||
r
|
||||
}
|
||||
Seq::Erase(..) => Ok(vec![]),
|
||||
// Deleting around the composition starts the same typing session as the commit that
|
||||
// follows, so the replacement is one undo step; an error reaches the status bar. The
|
||||
// receipt covers only this accepted deletion.
|
||||
Seq::DeleteSurrounding(before, after, unit) => {
|
||||
let target_before = crate::ime::target(&mut app.session, view);
|
||||
let stamp_before = crate::ime::stamp(&mut app.session, view);
|
||||
let r = app.session.tool_ime_delete_surrounding(before, after, unit, view);
|
||||
match &r {
|
||||
Err(e) => app.status(e.to_string()),
|
||||
Ok(_) => crate::ime::note_ime_apply(app, ctx, view, target_before, stamp_before),
|
||||
}
|
||||
r
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user