From 1b329b9422c0e05a7e5dbd09edb4ba9e53a92621 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 02:39:41 +0000 Subject: [PATCH 1/2] keymap: a web clipboard press takes its clipboard event and the pasted text with it --- crates/keymap/README.md | 4 ++- crates/keymap/src/egui.rs | 71 +++++++++++++++++++++---------------- crates/keymap/tests/egui.rs | 25 +++++++++++++ 3 files changed, 69 insertions(+), 31 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 1f2d1b75..70308245 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -228,6 +228,8 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File 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`. An app whose + 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. diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index ca8b3192..70330db6 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -173,7 +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 ([`Dispatcher::with_clipboard_events`]). + /// 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`]). pub pasted: Option, } @@ -240,7 +241,8 @@ impl Dispatcher { /// 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`]. An app that asks egui for a paste (`ViewportCommand::RequestPaste`) to run + /// [`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; @@ -266,11 +268,12 @@ impl Dispatcher { let mut kept = Vec::with_capacity(input.events.len()); // The Text event egui sends after a key press belongs to that press. let mut drop_text = false; - // The web build sends both a clipboard key press and the clipboard event it causes. - let mut clipboard_press = None; let events = std::mem::take(&mut input.events); let typed = if active.typing { typed_text(&events) } else { Vec::new() }; - let causes_event = if self.clipboard { Vec::new() } else { clipboard_event_follows(&events, platform) }; + // The web build sends both a clipboard key press and the clipboard event it causes. The + // press decides: its event goes with it when it runs a command and stays otherwise. + let causes = clipboard_event_follows(&events, platform); + let mut caused_taken: Vec> = vec![None; events.len()]; for (i, event) in events.into_iter().enumerate() { match &event { Event::Key { key, pressed: true, repeat, modifiers, .. } if !*repeat || self.repeat => { @@ -281,16 +284,22 @@ impl Dispatcher { let keep = if active.typing { typed.get(i) == Some(&true) || clip.is_some() || may_be_alt_gr(key, held, platform) } else { - causes_event.get(i) == Some(&true) + !self.clipboard && causes[i].is_some() }; if keep { kept.push(event); continue; } - if clip.is_some() { - clipboard_press = clip; + let caused = causes[i].clone(); + let hit = self.press(key, held, *repeat, now, keymap, active).map(|mut hit| { + hit.pasted = caused.as_ref().and_then(|(_, text)| text.clone()); + hit + }); + let hit = hit.filter(&mut accept); + if let Some((j, _)) = caused { + caused_taken[j] = Some(hit.is_some()); } - if let Some(hit) = self.press(key, held, *repeat, now, keymap, active).filter(&mut accept) { + if let Some(hit) = hit { hits.push(hit); drop_text = !(held.cmd || held.ctrl); continue; @@ -303,9 +312,11 @@ impl Dispatcher { } // 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, clip) = clipboard_chord(&event, frame_mods); - if clipboard_press == Some(clip) { - clipboard_press = None; + 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()); @@ -349,19 +360,19 @@ enum Clip { Paste, } -/// For each event, whether it is a clipboard key press whose clipboard event comes later in the -/// frame, as the web build sends them. -fn clipboard_event_follows(events: &[Event], platform: Platform) -> Vec { - let mut out = vec![false; events.len()]; - let mut ahead = [false; 3]; - for (event, follows) in events.iter().zip(out.iter_mut()).rev() { +/// For each clipboard key press whose clipboard event comes later in the frame, as the web build +/// sends them: the index of that event, and the text of a Paste event. +fn clipboard_event_follows(events: &[Event], platform: Platform) -> Vec)>> { + let mut out = vec![None; events.len()]; + let mut ahead: [Option; 3] = [None; 3]; + for (i, event) in events.iter().enumerate().rev() { if let Some(seen) = event_clip(event).and_then(|c| ahead.get_mut(c as usize)) { - *seen = true; + *seen = Some(i); } else if let Event::Key { key, pressed: true, modifiers, .. } = event { let clip = Key::from_egui(*key).and_then(|k| clipboard_kind(k, Mods::from_egui(modifiers, platform))); - if let Some(seen) = clip.and_then(|c| ahead.get_mut(c as usize)) { - *follows = *seen; - *seen = false; + if let Some(j) = clip.and_then(|c| ahead.get_mut(c as usize)).and_then(Option::take) { + let text = if let Some(Event::Paste(text)) = events.get(j) { Some(text.clone()) } else { None }; + out[i] = Some((j, text)); } } } @@ -434,16 +445,16 @@ fn clipboard_kind(key: Key, held: Mods) -> Option { } } -/// The press a clipboard event stands for, with the modifiers held, and the event's kind. Off -/// macOS egui-winit also sends Cut for Shift+Delete and Paste for Shift+Insert. -fn clipboard_chord(event: &Event, held: Mods) -> (Key, Mods, Clip) { +/// 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) { let with_cmd = Mods { cmd: true, ..held }; let shift_only = held.shift && !held.cmd; match event { - Event::Cut if shift_only => (Key::Delete, held, Clip::Cut), - Event::Cut => (Key::X, with_cmd, Clip::Cut), - Event::Paste(_) if shift_only => (Key::Insert, held, Clip::Paste), - Event::Paste(_) => (Key::V, with_cmd, Clip::Paste), - _ => (Key::C, with_cmd, Clip::Copy), + Event::Cut if shift_only => (Key::Delete, held), + Event::Cut => (Key::X, with_cmd), + Event::Paste(_) if shift_only => (Key::Insert, held), + Event::Paste(_) => (Key::V, with_cmd), + _ => (Key::C, with_cmd), } } diff --git a/crates/keymap/tests/egui.rs b/crates/keymap/tests/egui.rs index 5487c807..567a852e 100644 --- a/crates/keymap/tests/egui.rs +++ b/crates/keymap/tests/egui.rs @@ -193,6 +193,31 @@ fn clipboard_events_run_their_chords_outside_text_fields_when_turned_on() { } } +#[test] +fn a_web_paste_carries_its_text_and_takes_its_event() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new().with_clipboard_events(true); + // The web build sends the Cmd+V press and then the Paste event with the clipboard text. + let events = vec![press(EguiKey::V, CTRL), Event::Paste("text".into()), press(EguiKey::C, CTRL), Event::Copy]; + let mut input = frame(1.0, CTRL, events.clone()); + let mut seen = Vec::new(); + let hits = d.dispatch_filtered(&mut input, &km, &Active::NONE, |hit| { + seen.push(hit.pasted.clone()); + true + }); + let ran: Vec<_> = hits.iter().map(|h| (h.command.id.as_str(), h.pasted.as_deref())).collect(); + assert_eq!(ran, [("edit.paste", Some("text")), ("edit.copy", None)]); + assert_eq!(seen, [Some("text".to_string()), None], "the filter sees the pasted text"); + assert!(input.events.is_empty(), "{:?}", input.events); + + // A declined press leaves the press and its event in place, and the event does not run again. + let mut input = frame(2.0, CTRL, events.clone()); + let hits = d.dispatch_filtered(&mut input, &km, &Active::NONE, |hit| hit.command.id != "edit.paste"); + let ran: Vec<_> = hits.iter().map(|h| h.command.id.as_str()).collect(); + assert_eq!(ran, ["edit.copy"]); + assert_eq!(input.events, events[..2]); +} + #[test] fn declined_hits_leave_their_events_in_order() { let km = dispatch_keymap(); From 75a42cb95fc7cab183d86b3449ca4d6c8bddd1c4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 02:40:57 +0000 Subject: [PATCH 2/2] keymap: an import change that cannot take a chord from another command leaves both commands as they were --- crates/keymap/src/import.rs | 29 +++++++++++++++++++++-------- crates/keymap/tests/import.rs | 11 ++++++----- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/crates/keymap/src/import.rs b/crates/keymap/src/import.rs index 89e4b2f8..6cafe2b0 100644 --- a/crates/keymap/src/import.rs +++ b/crates/keymap/src/import.rs @@ -279,8 +279,7 @@ pub struct Failed { pub command: CommandRef, pub label: String, pub change: FailedChange, - /// The set's shortcuts for [`FailedChange::Bind`], the one left on the command for - /// [`FailedChange::Unbind`]; empty for [`FailedChange::Clear`]. + /// The set's shortcuts for [`FailedChange::Bind`]; empty for [`FailedChange::Clear`]. pub shortcuts: Vec, } @@ -288,13 +287,11 @@ pub struct Failed { #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum FailedChange { - /// Giving the command the set's bindings. + /// Giving the command the set's bindings, or taking one of them from another command. Bind, /// Leaving the command without shortcuts: the set has it without keys and /// [`NoneDefined::Clear`] applies. Clear, - /// Taking a chord the set gives to another command; both commands keep it. - Unbind, } /// Why keys of an entry were not used. @@ -548,7 +545,8 @@ impl ImportPlan { /// /// A change that finds the overrides full is left out and reported in /// [`ImportReport::failed`]; the others still apply, and a chord it would have taken from an - /// earlier entry stays with that entry. With [`Start::Defaults`] that cannot happen. + /// earlier entry stays with that entry. That includes taking a chord from another command: + /// both commands keep the bindings they had. With [`Start::Defaults`] that cannot happen. pub fn apply(&self, keymap: &mut Keymap) -> ImportReport { let before: Vec> = self.commands.iter().map(|p| keymap.bindings(&p.command)).collect(); // Dropping every entry leaves room for all of them: each catalogue command takes at most one @@ -598,7 +596,10 @@ impl ImportPlan { decided.insert(p.command.key()); } - for (p, list) in self.commands.iter().zip(&accepted) { + // A command that cannot take a chord from another command is dropped, so both keep + // the bindings they had. + let mut unbind_failed = false; + for (c, (p, list)) in self.commands.iter().zip(&accepted).enumerate() { if list.is_empty() || !decided.contains(&p.command.key()) { continue; } @@ -612,10 +613,22 @@ impl ImportPlan { if keymap.remove(&h.command, &h.binding) { report.conflicts_cleared.push(Cleared { command: h.command, label, shortcut }); } else { - report.failed.push(Failed { command: h.command, label, change: FailedChange::Unbind, shortcuts: vec![shortcut] }); + let home = home_of(keymap, &p.command); + let list = in_default_order(keymap, &p.command, list); + let shortcuts = list.iter().map(|b| b.text(&home)).collect(); + dropped.insert( + c, + Failed { command: p.command.clone(), label: label_of(keymap, &p.command), change: FailedChange::Bind, shortcuts }, + ); + unbind_failed = true; + break; } } } + if unbind_failed { + *keymap = start.clone(); + continue 'attempt; + } for (p, from) in self.commands.iter().zip(&before) { if !decided.contains(&p.command.key()) { diff --git a/crates/keymap/tests/import.rs b/crates/keymap/tests/import.rs index e89428b6..586e02df 100644 --- a/crates/keymap/tests/import.rs +++ b/crates/keymap/tests/import.rs @@ -330,10 +330,11 @@ fn changes_that_find_the_overrides_full_are_reported() { }; let options = |start| ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], start, ..ImportOptions::default() }; - // file.close has an entry to rewrite; the other changes each need a new one. + // file.close has an entry to rewrite, but taking Cmd+J from view.goToPage needs a new one, so + // both stay as they were. The other changes each need a new entry too. let mut km = keymap(Platform::Windows).with_overrides(full()); let r = plan(&set, &km, &options(Start::Current)).apply(&mut km); - has(&km, "file.close", &["Cmd+J"]); + has(&km, "file.close", &["Cmd+Q"]); has(&km, "view.goToPage", &["Cmd+J"]); has(&km, "file.save", &["Cmd+S"]); has(&km, "edit.duplicate", &["Cmd+Alt+Shift+D"]); @@ -346,16 +347,16 @@ fn changes_that_find_the_overrides_full_are_reported() { assert_eq!( r.failed, [ + row("file.close", "Close", FailedChange::Bind, &["Cmd+J"]), row("file.save", "Save", FailedChange::Bind, &["Cmd+Alt+S"]), row("edit.duplicate", "Duplicate", FailedChange::Clear, &[]), - row("view.goToPage", "Go to Page…", FailedChange::Unbind, &["Cmd+J"]), ] ); - assert_eq!(r.imported, 1); + assert_eq!(r.imported, 0); assert!(r.conflicts_cleared.is_empty()); let v = serde_json::to_value(&r).unwrap(); assert_eq!( - v["failed"][0], + v["failed"][1], json!({"command": {"id": "file.save", "params": null}, "label": "Save", "change": "bind", "shortcuts": ["Cmd+Alt+S"]}) );