diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 354a06a5b..013b585d8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3308,6 +3308,54 @@ mod tests { assert_eq!(o.frame().unwrap().2.wrap, wordcraft_doc::para::Wrap::Square); } + /// #996: on macOS a Control-click is the right-click (the trackpad's and the one-button + /// mouse's way to it), so it opens the same menu and leaves the caret where the menu needs it. + #[cfg(target_os = "macos")] + #[test] + fn control_click_on_macos_opens_the_context_menu() { + use egui_kittest::kittest::Queryable; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + a.run("text.insert", json!({"text": "The quick brwn fox"})).unwrap(); + a.session.sel = wordcraft_engine::Selection::caret(Pos::body(0, 12)); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + for _ in 0..6 { + h.step(); + } + let (p, ht) = caret_screen(h.state_mut()).unwrap(); + let at = p + egui::vec2(0.0, ht / 2.0); + h.state_mut().session.sel = wordcraft_engine::Selection::caret(Pos::body(0, 0)); + h.step(); + let ctrl = egui::Modifiers { ctrl: true, ..Default::default() }; + // The events winit delivers for a Control-click, through the app's input hook as eframe runs it. + let send = |h: &mut egui_kittest::Harness<'_, WordApp>, events: Vec| { + let mut raw = egui::RawInput { events, ..Default::default() }; + h.state_mut().raw_input_hook(&mut raw); + for e in raw.events { + h.event(e); + } + h.step(); + }; + send(&mut h, vec![egui::Event::ModifiersChanged(ctrl), egui::Event::PointerMoved(at)]); + for pressed in [true, false] { + send(&mut h, vec![egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: ctrl }]); + } + h.event(egui::Event::ModifiersChanged(Default::default())); + h.step(); + assert!(h.state().canvas.context_menu_open, "the Control-click opened the menu"); + h.get_by_label("brown").click(); + for _ in 0..3 { + h.step(); + } + assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brown fox"); + } + #[test] fn only_web_and_email_links_are_followed() { for ok in ["https://example.com/a?b#c", "http://example.com", "HTTPS://EXAMPLE.COM", "mailto:someone@example.com"] { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 21961482b..af91b3f4e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -442,6 +442,8 @@ pub struct WordApp { queued_shots: Vec<(u64, f64, u32)>, pending_shots: Vec<(u64, Option, std::sync::mpsc::Sender, f64)>, pub(crate) synthetic: Vec, + /// macOS: the primary button went down with Control, so it is being read as the secondary. + control_click: bool, /// Badge rects recorded during layout for the current keytip phase (cleared by `logic`, painted by `show`). pub(crate) keytip_rects: Vec<(egui::Rect, String)>, /// The Navigation pane's search box takes the keyboard on its next frame (Ctrl+F, #712). @@ -571,6 +573,7 @@ impl WordApp { queued_shots: Vec::new(), pending_shots: Vec::new(), synthetic: Vec::new(), + control_click: false, keytip_rects: Vec::new(), nav_search_focus: false, styled: false, @@ -1776,8 +1779,12 @@ impl WordApp { } } - /// Inject synthetic input events from the control channel (one pointer event per frame). + /// Read a Control-click as the right-click on macOS, then inject synthetic input events from + /// the control channel (one pointer event per frame). pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { + if cfg!(target_os = "macos") { + control_click_as_secondary(&mut raw.events, &mut self.control_click); + } if self.synthetic.is_empty() { return; } @@ -2227,6 +2234,25 @@ pub fn now_ms() -> f64 { } } +/// macOS reads a Control-click as the right-click (winit reports the primary button with Control +/// held), so menus open on it as on a two-finger click. The release follows its press even if +/// Control was let go in between. +fn control_click_as_secondary(events: &mut [egui::Event], held: &mut bool) { + for e in events { + if let egui::Event::PointerButton { button: button @ egui::PointerButton::Primary, pressed, modifiers, .. } = e { + if *pressed { + *held = modifiers.ctrl && !modifiers.mac_cmd; + } + if *held { + *button = egui::PointerButton::Secondary; + } + if !*pressed { + *held = false; + } + } + } +} + #[cfg(test)] mod tests_perf;