From 2a2d4e25824bd6d06bef4aaad9fa6a3139ee7ff8 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:58:11 -0400 Subject: [PATCH] macOS Edit menu keys edit a focused text field (#2139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * macOS Edit menu keys edit a focused text field The menu bar takes ⌘C, ⌘X, ⌘V, ⌘A, ⌘Z and ⇧⌘Z before any window sees them, so they ran Copy, Paste or Undo on the drawing while the user was typing in a field. While a text field has the keyboard, in the main window or a floating panel's own window, the menu now hands the field its key instead. craft-libs is pinned at #318's head for typing_window and send_keys. * Edit menu items a text field takes stay enabled while one has the keyboard Edit ▸ Paste follows the drawing clipboard, so with nothing copied in the drawing it was greyed out even while a text field could paste. --- apps/cadcraft/src/native_menu.rs | 5 +- crates/ui-egui/src/menus.rs | 55 ++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + .../ui-egui/tests/it/native_menu_text_keys.rs | 65 +++++++++++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/it/native_menu_text_keys.rs diff --git a/apps/cadcraft/src/native_menu.rs b/apps/cadcraft/src/native_menu.rs index 1e987870d..8649ed8e4 100644 --- a/apps/cadcraft/src/native_menu.rs +++ b/apps/cadcraft/src/native_menu.rs @@ -118,7 +118,7 @@ impl NativeMenu { if cmd == "ui.discord" { ctx.open_url(egui::OpenUrl::new_tab("https://discord.gg/artcraft")); } else { - menus::activate(app, &cmd.clone()); + menus::activate_native(app, ctx, &cmd.clone()); } ctx.request_repaint(); } @@ -142,8 +142,9 @@ impl NativeMenu { return; } self.last_refresh = now; + let typing = menus::text_field_window(ctx).is_some(); for (cmd, _, item) in self.items.values() { - let en = cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok()); + let en = (typing && menus::edits_text(cmd)) || cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok()); item.set_enabled(en); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 40115388b..43b135d19 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -649,6 +649,61 @@ pub fn activate(app: &mut CadApp, id: &str) { app.start(id); } +/// Activate the menu command `id` from a native menu bar, which takes the Edit menu's key +/// equivalents (⌘C, ⌘X, ⌘V, ⌘A, ⌘Z, ⇧⌘Z) before any window sees them: while a text field has +/// the keyboard, in the main window or a floating panel's own window, the field gets the key +/// and the drawing command does not run. +pub fn activate_native(app: &mut CadApp, ctx: &egui::Context, id: &str) { + let (Some(window), Some(text)) = (text_field_window(ctx), text_command(id, ctx.os().is_mac())) else { + activate(app, id); + return; + }; + match text { + TextCommand::Paste => ctx.send_viewport_cmd_to(window, egui::ViewportCommand::RequestPaste), + TextCommand::Events(events) if window == egui::ViewportId::ROOT => { + app.synthetic.extend(events); + ctx.request_repaint(); + } + TextCommand::Events(events) => craft_ui::docking::send_keys(ctx, window, events), + } +} + +/// The window whose text field has the keyboard: a floating panel's own OS window, or the main +/// window. +pub fn text_field_window(ctx: &egui::Context) -> Option { + craft_ui::docking::typing_window(ctx).or_else(|| ctx.text_edit_focused().then_some(egui::ViewportId::ROOT)) +} + +/// Whether a focused text field takes the menu command `id` ([`activate_native`]): a native +/// menu keeps such items enabled while one has the keyboard, whatever the drawing allows. +pub fn edits_text(id: &str) -> bool { + text_command(id, false).is_some() +} + +/// What a focused text field does for an Edit menu command. +#[derive(Debug, PartialEq)] +enum TextCommand { + /// The clipboard text, which the window asks for and receives as `Paste`. + Paste, + Events(Vec), +} + +fn text_command(id: &str, mac: bool) -> Option { + let key = |key, shift| { + let modifiers = egui::Modifiers { mac_cmd: mac, ctrl: !mac, command: true, shift, alt: false }; + TextCommand::Events([true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).into()) + }; + Some(match id { + "copyclip" => TextCommand::Events(vec![egui::Event::Copy]), + "cutclip" => TextCommand::Events(vec![egui::Event::Cut]), + "pasteclip" => TextCommand::Paste, + "u" => key(egui::Key::Z, false), + "redo" => key(egui::Key::Z, true), + "selectall" => key(egui::Key::A, false), + _ => return None, + }) +} + /// Run `id` with default parameters without cancelling the active command, as the drafting /// toggles (F3, F7-F12, the status bar buttons) are in AutoCAD. pub fn run_transparent(app: &mut CadApp, id: &str) { diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 5faf3135b..c908bc9e2 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -17,6 +17,7 @@ mod lisp_files; mod measure_distance_snapping; mod menubar_hover; mod mspace_viewport_view; +mod native_menu_text_keys; mod options_dialog; mod parameters_rename; mod pickdrag; diff --git a/crates/ui-egui/tests/it/native_menu_text_keys.rs b/crates/ui-egui/tests/it/native_menu_text_keys.rs new file mode 100644 index 000000000..f68e5828e --- /dev/null +++ b/crates/ui-egui/tests/it/native_menu_text_keys.rs @@ -0,0 +1,65 @@ +//! The macOS menu bar takes the Edit menu's key equivalents before the window does: with a text +//! field focused they edit the field, and only without one do they run the drawing command. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services, menus}; +use egui::{accesskit::Role, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +fn harness() -> Harness<'static, CadApp> { + let mut app = CadApp::new(Session::default(), Services::default()); + app.ui.start_tab = false; + app.ui.in_window_menu = false; + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.ctx.set_os(egui::os::OperatingSystem::Mac); + h.run_steps(5); + h +} + +/// A native menu choice, then the frames that deliver what it queued for the main window. +fn menu(h: &mut Harness<'_, CadApp>, id: &str) { + let ctx = h.ctx.clone(); + menus::activate_native(h.state_mut(), &ctx, id); + let mut raw = egui::RawInput::default(); + h.state_mut().raw_input_hook(&ctx, &mut raw); + h.input_mut().events.extend(raw.events); + h.run_steps(3); +} + +fn expression<'a>(h: &'a Harness<'_, CadApp>) -> egui_kittest::Node<'a> { + h.get_by_role_and_label(Role::Tab, "QuickCalc").parent().unwrap().get_all_by_role(Role::TextInput).next().unwrap() +} + +#[test] +fn edit_menu_keys_edit_the_focused_text_field_and_otherwise_the_drawing() { + let mut h = harness(); + h.state_mut().run("line", json!({"points": [[0, 0], [10, 0]]})).unwrap(); + let drawing = h.state().session.doc().unwrap().clone(); + menus::activate(h.state_mut(), "ui.toggle.quickcalc"); + h.run_steps(5); + expression(&h).click(); + h.run_steps(3); + expression(&h).type_text("2+3"); + h.run_steps(3); + assert_eq!(menus::text_field_window(&h.ctx), Some(egui::ViewportId::ROOT)); + assert!(menus::edits_text("pasteclip") && !menus::edits_text("line")); + menu(&mut h, "selectall"); + menu(&mut h, "cutclip"); + assert_eq!(expression(&h).value().as_deref(), Some(""), "Select All and Cut act on the field"); + assert!(h.state().session.running.is_none()); + assert_eq!(h.state().session.doc().unwrap(), &drawing); + + h.ctx.memory_mut(|m| m.stop_text_input()); + h.run_steps(3); + assert_eq!(menus::text_field_window(&h.ctx), None); + menu(&mut h, "u"); + assert_ne!(h.state().session.doc().unwrap(), &drawing, "without a focused field, Undo undoes the line"); +}