macOS Edit menu keys edit a focused text field (#2139)

* 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.
This commit is contained in:
aiongg
2026-10-25 21:58:11 -04:00
committed by GitHub
parent d1cf06cbea
commit 2a2d4e2582
4 changed files with 124 additions and 2 deletions
+3 -2
View File
@@ -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);
}
}
+55
View File
@@ -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<egui::ViewportId> {
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<egui::Event>),
}
fn text_command(id: &str, mac: bool) -> Option<TextCommand> {
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) {
+1
View File
@@ -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;
@@ -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");
}