diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 0b1c0181..d9d6f225 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -220,16 +220,20 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File when the press matched through a stand-in (`:` for `Shift+;`, `+` for `=`). `dispatch_filtered` takes a predicate that can decline a hit, for example a chord the native menu has already handled: the press and its text then stay in egui's events, in their place. - While a text field has the keyboard, clipboard key presses and presses that typed text in the + While a text field has the keyboard, plain clipboard key presses and presses that typed text in the same frame stay with it. That covers AltGr, which a browser on Windows reports as Ctrl+Alt followed by the text; a Ctrl+Alt press that types nothing is a command chord. `Dispatcher::new().with_repeat(true)` also runs commands on key repeats (held arrows). - egui's Copy, Cut and Paste events stay with the app by default. On the desktop, egui-winit - sends them in place of the Cmd+C, Cmd+X and Cmd+V presses (Ctrl off macOS), so a binding on - those chords runs only with `with_clipboard_events(true)`. Then the events count as their - chords outside text fields, and a Paste event's text moves to `Hit::pasted`. The web build - sends the key press and then the event; the press runs the command and takes the event, with - its text, along. An app whose - paste command asks egui for a paste gets a Paste event back, which runs that command again; - such an app keeps the default. + On the desktop, egui-winit sends egui's Copy, Cut and Paste events in place of the key press + for any chord of Cmd with C, X or V (Ctrl off macOS); the web build sends the press and then + the event. For the plain chords Cmd+C, Cmd+X and Cmd+V (and Cmd+Insert, Shift+Delete, + Shift+Insert), the events stay with the app by default, so a binding on those chords runs only + with `with_clipboard_events(true)`. Then the events count as their chords outside text fields, + and a Paste event's text moves to `Hit::pasted`. On the web, the press runs the command and + takes the event, with its text, along. An app whose paste command asks egui for a paste gets a + Paste event back, which runs that command again; such an app keeps the default. A clipboard + event with more modifiers held (Cmd+Shift+V, Cmd+Alt+C) always counts as its chord, also while + typing, and stays when that chord has no binding. egui-winit sends no Paste event when the + clipboard is empty or holds an image, so a binding on a Cmd+V chord cannot run then on the + desktop. diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index 4947ad72..a42e7d9d 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -173,8 +173,8 @@ pub struct Hit { /// the shifted key for a Shift binding (`:` for `Shift+;`), Shift held to type a symbol, or /// `+` and `=` standing for each other. pub exact: bool, - /// The text of the Paste event that ran the command, or on the web the Paste event that came - /// with the press that ran it ([`Dispatcher::with_clipboard_events`]). + /// The text of the Paste event that ran the command, or on the web of the Paste event that + /// came with the press that ran it. pub pasted: Option, } @@ -204,16 +204,21 @@ struct LastPress { /// that runs a command is removed from the events, together with the text it typed when it has /// no command modifier, so no widget sees it too. Other events stay. /// -/// egui's Copy, Cut and Paste events stay with the app unless -/// [`with_clipboard_events`](Self::with_clipboard_events) is on. On the desktop, egui-winit sends -/// these events in place of the key presses for Cmd+C, Cmd+X and Cmd+V (Ctrl off macOS, plus -/// Shift+Delete, Ctrl+Insert and Shift+Insert on Windows), so bindings on those chords run only -/// with clipboard events on. The web build sends the key press and then the clipboard event; with -/// clipboard events off, such a press stays too. +/// On the desktop, egui-winit sends egui's Copy, Cut and Paste events in place of the key press +/// for any chord of Cmd with C, X or V (Ctrl off macOS; on Windows also Shift+Delete, Ctrl+Insert +/// and Shift+Insert). The web build sends the key press and then the event. For the plain +/// clipboard chords (Cmd+C, Cmd+X, Cmd+V, Cmd+Insert, Shift+Delete, Shift+Insert), the events and +/// their presses stay with the app unless [`with_clipboard_events`](Self::with_clipboard_events) +/// is on, so bindings on those chords run only with it on. An event with more modifiers held +/// counts as its chord, read from the frame's modifiers (Cmd+Shift+V), and runs that chord's +/// binding; without one, it stays. On Windows, egui-winit also sends Cut for Ctrl+Shift+Delete +/// and Copy for Ctrl+Shift+Insert, which read as Cmd+Shift+X and Cmd+Shift+C. +/// +/// egui-winit sends no Paste event when the clipboard is empty or holds no text (an image), so a +/// binding on Cmd+V, or on Cmd and V with more modifiers, cannot run then on the desktop. /// /// While [`Active::typing`], the text field keeps, without lookup: -/// - clipboard events, and the clipboard key presses the web build sends with them (Cmd+C, Cmd+X, -/// Cmd+V, Shift+Delete, Shift+Insert, Ctrl+Insert); +/// - the plain clipboard chords' events and presses (other clipboard chords are looked up); /// - a press with a Text event of its own in the frame, since it typed that text. This covers /// AltGr: the browser on Windows reports AltGr+Q (`@` on a German layout) as Ctrl+Alt+Q, and /// the web build's text agent sends the `@` after it. egui-winit reports AltGr with neither Ctrl @@ -246,13 +251,14 @@ impl Dispatcher { self } - /// Whether egui's Copy, Cut and Paste events count as the chords that cause them (Cmd+C, - /// Cmd+X, Cmd+V; Shift+Delete and Shift+Insert for Cut and Paste with Shift alone) outside text - /// fields. Off by default: the events stay in egui's input for the app's own handlers. With it - /// on, an event that runs a command is removed, and a Paste event's text moves to - /// [`Hit::pasted`]. On the web, the key press runs the command and its clipboard event goes - /// with it the same way; when the press runs nothing, or the hit is declined, both stay. An app that asks egui for a paste (`ViewportCommand::RequestPaste`) to run - /// its paste command gets a Paste event back, which with this on runs that command again. + /// Whether egui's Copy, Cut and Paste events for the plain clipboard chords count as those + /// chords (Cmd+C, Cmd+X, Cmd+V; Shift+Delete and Shift+Insert for Cut and Paste with Shift + /// alone) outside text fields. Off by default: the events stay in egui's input for the app's + /// own handlers. With it on, an event that runs a command is removed, and a Paste event's text + /// moves to [`Hit::pasted`]. On the web, the key press runs the command and its clipboard + /// event goes with it the same way; when the press runs nothing, or the hit is declined, both + /// stay. An app that asks egui for a paste (`ViewportCommand::RequestPaste`) to run its paste + /// command gets a Paste event back, which with this on runs that command again. pub fn with_clipboard_events(mut self, on: bool) -> Dispatcher { self.clipboard = on; self @@ -288,13 +294,16 @@ impl Dispatcher { Event::Key { key, pressed: true, repeat, modifiers, .. } if !*repeat || self.repeat => { let held = Mods::from_egui(modifiers, platform); if let Some(key) = Key::from_egui(*key) { - let clip = clipboard_kind(key, held); + let plain_clip = is_plain_clipboard(key, held); let keep = if active.typing { - text_of[i].is_some() || clip.is_some() + text_of[i].is_some() || plain_clip } else { - !self.clipboard && causes[i].is_some() + !self.clipboard && plain_clip && causes[i].is_some() }; if keep { + if let Some((j, _)) = causes[i] { + caused_taken[j] = Some(false); + } kept.push((i, event)); continue; } @@ -322,23 +331,26 @@ impl Dispatcher { caused_taken[j] = Some(false); } } - // A focused text field copies and pastes on these events itself. - Event::Copy | Event::Cut | Event::Paste(_) if self.clipboard && !active.typing => { - let (key, held) = clipboard_chord(&event, frame_mods); - if let Some(taken) = caused_taken[i] { - if taken { - continue; - } - } else if let Some(mut hit) = self.press(key, held, false, now, keymap, active) { - if let Event::Paste(text) = &event { - hit.pasted = Some(text.clone()); - } - if accept(&hit) { - hits.push(hit); - continue; + // Desktop clipboard events, and web ones whose press stayed or ran nothing. + Event::Copy | Event::Cut | Event::Paste(_) => match caused_taken[i] { + Some(true) => continue, + Some(false) => {} + None => { + let (key, held) = clipboard_chord(&event, frame_mods); + // A focused text field copies and pastes on the plain chords itself. + let look = !is_plain_clipboard(key, held) || (self.clipboard && !active.typing); + let hit = if look { self.press(key, held, false, now, keymap, active) } else { None }; + if let Some(mut hit) = hit { + if let Event::Paste(text) = &event { + hit.pasted = Some(text.clone()); + } + if accept(&hit) { + hits.push(hit); + continue; + } } } - } + }, _ => {} } kept.push((i, event)); @@ -444,6 +456,17 @@ fn clipboard_kind(key: Key, held: Mods) -> Option { } } +/// Whether a chord is one of the plain clipboard chords, which egui's text fields act on: Cmd+C, +/// Cmd+X, Cmd+V, Cmd+Insert, Shift+Delete and Shift+Insert. +fn is_plain_clipboard(key: Key, held: Mods) -> bool { + match key { + Key::C | Key::X | Key::V => held == Mods::CMD, + Key::Insert => held == Mods::CMD || held == Mods::SHIFT, + Key::Delete => held == Mods::SHIFT, + _ => false, + } +} + /// The press a clipboard event stands for, with the modifiers held. Off macOS egui-winit also /// sends Cut for Shift+Delete and Paste for Shift+Insert. fn clipboard_chord(event: &Event, held: Mods) -> (Key, Mods) { diff --git a/crates/keymap/tests/egui.rs b/crates/keymap/tests/egui.rs index 4b31b9bf..b1fb7630 100644 --- a/crates/keymap/tests/egui.rs +++ b/crates/keymap/tests/egui.rs @@ -81,6 +81,8 @@ fn dispatch_keymap() -> Keymap { let catalog = vec![ app("edit.copy", &["Cmd+C", "Cmd+Insert"]), app("edit.paste", &["Cmd+V", "Shift+Insert"]), + app("edit.pasteInPlace", &["Cmd+Shift+V"]), + app("edit.copyMerged", &["Cmd+Shift+C"]), app("app.quit", &["Cmd+Alt+Q"]), app("type.pageNumber", &["Cmd+Alt+Shift+N"]), app("view.refresh", &["Cmd+Alt+F5"]), @@ -168,6 +170,39 @@ fn clipboard_events_stay_with_the_app_by_default() { assert_eq!(ran, ["edit.copy"]); } +#[test] +fn clipboard_chords_with_more_modifiers_run_their_bindings() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let typing = Active { typing: true, ..Active::NONE }; + let ctrl_shift = Modifiers { shift: true, ..CTRL }; + // The desktop: egui-winit sends the Paste event in place of Ctrl+Shift+V. + let mut input = frame(1.0, ctrl_shift, vec![Event::Paste("text".into())]); + let hits = d.dispatch(&mut input, &km, &Active::NONE); + let ran: Vec<_> = hits.iter().map(|h| (h.command.id.as_str(), h.pasted.as_deref())).collect(); + assert_eq!(ran, [("edit.pasteInPlace", Some("text"))]); + assert!(input.events.is_empty()); + let (ran, left) = run(&mut d, &km, &typing, frame(2.0, ctrl_shift, vec![Event::Copy])); + assert_eq!(ran, ["edit.copyMerged"]); + assert!(left.is_empty()); + // Ctrl+Shift+X has no binding: the event stays. + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(3.0, ctrl_shift, vec![Event::Cut])); + assert!(ran.is_empty()); + assert_eq!(left, [Event::Cut]); + + // The web: the press decides for itself and its event, also while typing. + for (time, active) in [(4.0, &Active::NONE), (5.0, &typing)] { + let events = vec![press(EguiKey::C, ctrl_shift), Event::Copy]; + let (ran, left) = run(&mut d, &km, active, frame(time, ctrl_shift, events)); + assert_eq!(ran, ["edit.copyMerged"]); + assert!(left.is_empty(), "{left:?}"); + } + let events = vec![press(EguiKey::X, ctrl_shift), Event::Cut]; + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(6.0, ctrl_shift, events.clone())); + assert!(ran.is_empty()); + assert_eq!(left, events); +} + #[test] fn clipboard_events_run_their_chords_outside_text_fields_when_turned_on() { let km = dispatch_keymap();