mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 19:56:51 -06:00
Merge #111: macOS menu Select All/Copy/Cut/Paste/Undo/Redo go to the focused text field
This commit is contained in:
@@ -28,7 +28,7 @@ impl eframe::App for App {
|
||||
self.1 = Some(native_menu::NativeMenu::install(&mut self.0));
|
||||
}
|
||||
if let Some(m) = &mut self.1 {
|
||||
m.poll(&mut self.0);
|
||||
m.poll(&mut self.0, ctx);
|
||||
}
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
|
||||
@@ -72,10 +72,10 @@ impl NativeMenu {
|
||||
Self { _menu: menu, items, last_refresh: 0.0 }
|
||||
}
|
||||
|
||||
pub fn poll(&mut self, app: &mut DesignApp) {
|
||||
pub fn poll(&mut self, app: &mut DesignApp, ctx: &egui::Context) {
|
||||
while let Ok(ev) = MenuEvent::receiver().try_recv() {
|
||||
if let Some((cmd, params, _)) = self.items.get(ev.id.as_ref()) {
|
||||
designcraft_ui_egui::menus::activate(app, &cmd.clone(), ¶ms.clone());
|
||||
menus::activate_native(app, ctx, &cmd.clone(), ¶ms.clone());
|
||||
}
|
||||
}
|
||||
let now = designcraft_ui_egui::now_ms();
|
||||
@@ -84,7 +84,7 @@ impl NativeMenu {
|
||||
}
|
||||
self.last_refresh = now;
|
||||
for (cmd, params, h) in self.items.values() {
|
||||
let en = menus::menu_enabled(app, cmd);
|
||||
let en = menus::native_menu_enabled(app, ctx, cmd);
|
||||
match h {
|
||||
Handle::Plain(i) => i.set_enabled(en),
|
||||
Handle::Check(c) => {
|
||||
|
||||
@@ -1763,6 +1763,44 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) {
|
||||
let _ = app.run(id, p);
|
||||
}
|
||||
|
||||
/// A native menu bar item was chosen (macOS): like [`activate`], except that the menu bar takes
|
||||
/// ⌘A, ⌘C, ⌘X, ⌘V, ⌘Z and ⇧⌘Z before the window sees them, so while a text field has keyboard
|
||||
/// focus, Select All, Copy, Cut, Paste, Undo and Redo act on that field, as in any Mac app.
|
||||
pub fn activate_native(app: &mut DesignApp, ctx: &egui::Context, id: &str, params: &Value) {
|
||||
if !(ctx.text_edit_focused() && params.is_null() && forward_to_text_field(app, ctx, id)) {
|
||||
activate(app, id, params);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a native menu bar item can be chosen: as in the in-window menus, but the editing
|
||||
/// commands a focused text field takes are always available to it.
|
||||
pub fn native_menu_enabled(app: &DesignApp, ctx: &egui::Context, id: &str) -> bool {
|
||||
(ctx.text_edit_focused() && TEXT_FIELD_COMMANDS.contains(&id)) || menu_enabled(app, id)
|
||||
}
|
||||
|
||||
/// The editing commands a focused text field takes from the native menu bar.
|
||||
const TEXT_FIELD_COMMANDS: [&str; 6] = ["edit.selectAll", "edit.copy", "edit.cut", "edit.paste", "edit.undo", "edit.redo"];
|
||||
|
||||
/// Hand a standard editing command to the focused text field as next frame's input; false when
|
||||
/// `id` isn't one of them.
|
||||
fn forward_to_text_field(app: &mut DesignApp, ctx: &egui::Context, id: &str) -> bool {
|
||||
let key = |key: egui::Key, modifiers: egui::Modifiers| {
|
||||
[true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers })
|
||||
};
|
||||
match id {
|
||||
"edit.selectAll" => app.synthetic.extend(key(egui::Key::A, egui::Modifiers::COMMAND)),
|
||||
"edit.undo" => app.synthetic.extend(key(egui::Key::Z, egui::Modifiers::COMMAND)),
|
||||
"edit.redo" => app.synthetic.extend(key(egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT)),
|
||||
"edit.copy" => app.synthetic.push(egui::Event::Copy),
|
||||
"edit.cut" => app.synthetic.push(egui::Event::Cut),
|
||||
// Only the integration reads the system clipboard: it answers with an `Event::Paste`.
|
||||
"edit.paste" => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste),
|
||||
_ => return false,
|
||||
}
|
||||
ctx.request_repaint();
|
||||
true
|
||||
}
|
||||
|
||||
/// The menu bar contents (inside the app bar; macOS uses the native menu instead).
|
||||
pub fn menu_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let lang = app.ui.language.clone();
|
||||
@@ -2453,6 +2491,129 @@ mod tests {
|
||||
assert!(run_ui(&mut app, "window.floatPanel", &json!({"panel": "nope"})).unwrap().is_err());
|
||||
}
|
||||
|
||||
/// One frame as the desktop app runs it: queued synthetic input, then the native menu item
|
||||
/// chosen during the frame (`menu`), then the app's logic and ui.
|
||||
fn native_frame(app: &mut crate::DesignApp, ctx: &egui::Context, events: Vec<egui::Event>, menu: Option<&str>) -> egui::FullOutput {
|
||||
let mut input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
app.raw_input_hook(&mut input);
|
||||
let mut menu = menu;
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
let ctx = ui.ctx().clone();
|
||||
if let Some(id) = menu.take() {
|
||||
activate_native(app, &ctx, id, &Value::Null);
|
||||
}
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
|
||||
/// A document with two frames, nothing selected, and Quick Apply's search field focused
|
||||
/// holding `text`.
|
||||
fn app_with_focused_field(ctx: &egui::Context, text: &str) -> crate::DesignApp {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", &json!({})).unwrap();
|
||||
app.session.execute("frame.create", &json!({"rect": [36, 36, 200, 200]})).unwrap();
|
||||
app.session.execute("frame.create", &json!({"rect": [236, 36, 400, 200]})).unwrap();
|
||||
app.session.execute("edit.deselectAll", &json!({})).unwrap();
|
||||
app.ui.palette = Some(text.into());
|
||||
for _ in 0..3 {
|
||||
native_frame(&mut app, ctx, vec![], None);
|
||||
}
|
||||
assert!(ctx.text_edit_focused());
|
||||
app
|
||||
}
|
||||
|
||||
fn field_selection(ctx: &egui::Context) -> Option<(usize, usize)> {
|
||||
let id = ctx.memory(|m| m.focused())?;
|
||||
let r = egui::TextEdit::load_state(ctx, id)?.cursor.char_range()?;
|
||||
Some((r.primary.index.min(r.secondary.index).into(), r.primary.index.max(r.secondary.index).into()))
|
||||
}
|
||||
|
||||
fn document_items(app: &crate::DesignApp) -> (usize, usize) {
|
||||
let d = app.session.active().unwrap();
|
||||
(d.doc.spreads.iter().flat_map(|sp| &sp.items).count(), d.selection.items.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_select_all_selects_the_focused_fields_text() {
|
||||
// The Mac menu bar takes ⌘A before the window sees it (#30).
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_focused_field(&ctx, "frame");
|
||||
native_frame(&mut app, &ctx, vec![], Some("edit.selectAll"));
|
||||
native_frame(&mut app, &ctx, vec![], None);
|
||||
assert_eq!(field_selection(&ctx), Some((0, 5)), "the field's whole text is selected");
|
||||
assert_eq!(document_items(&app), (2, 0), "the document selection is unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_paste_and_copy_act_on_the_focused_field() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_focused_field(&ctx, "frame");
|
||||
native_frame(&mut app, &ctx, vec![], Some("edit.selectAll"));
|
||||
native_frame(&mut app, &ctx, vec![], None);
|
||||
// Copy puts the field's selected text on the clipboard.
|
||||
native_frame(&mut app, &ctx, vec![], Some("edit.copy"));
|
||||
let out = native_frame(&mut app, &ctx, vec![], None);
|
||||
assert!(out.platform_output.commands.contains(&egui::OutputCommand::CopyText("frame".into())), "{:?}", out.platform_output.commands);
|
||||
// Paste asks the integration for the clipboard, which it hands to the field as a paste.
|
||||
let out = native_frame(&mut app, &ctx, vec![], Some("edit.paste"));
|
||||
let commands = &out.viewport_output[&egui::ViewportId::ROOT].commands;
|
||||
assert!(commands.contains(&egui::ViewportCommand::RequestPaste), "{commands:?}");
|
||||
native_frame(&mut app, &ctx, vec![egui::Event::Paste("text".into())], None);
|
||||
assert_eq!(app.ui.palette.as_deref(), Some("text"));
|
||||
assert_eq!(document_items(&app), (2, 0), "nothing was pasted into the document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_undo_and_redo_act_on_the_focused_field() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_focused_field(&ctx, "");
|
||||
native_frame(&mut app, &ctx, vec![egui::Event::Text("abc".into())], None);
|
||||
assert_eq!(app.ui.palette.as_deref(), Some("abc"));
|
||||
native_frame(&mut app, &ctx, vec![], Some("edit.undo"));
|
||||
native_frame(&mut app, &ctx, vec![], None);
|
||||
assert_eq!(app.ui.palette.as_deref(), Some(""), "the field's typing is undone");
|
||||
assert_eq!(document_items(&app), (2, 0), "the document's last frame is still there");
|
||||
native_frame(&mut app, &ctx, vec![], Some("edit.redo"));
|
||||
native_frame(&mut app, &ctx, vec![], None);
|
||||
assert_eq!(app.ui.palette.as_deref(), Some("abc"));
|
||||
assert_eq!(document_items(&app), (2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_editing_items_are_enabled_for_a_focused_field() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_focused_field(&ctx, "frame");
|
||||
// Nothing is selected in the document, so its Copy and Cut are off; the field's are on.
|
||||
assert!(!menu_enabled(&app, "edit.copy") && !menu_enabled(&app, "edit.cut"));
|
||||
for id in ["edit.selectAll", "edit.copy", "edit.cut", "edit.paste", "edit.undo", "edit.redo"] {
|
||||
assert!(native_menu_enabled(&app, &ctx, id), "{id}");
|
||||
}
|
||||
assert!(!native_menu_enabled(&app, &ctx, "edit.duplicate"), "other items follow the document");
|
||||
app.ui.palette = None;
|
||||
native_frame(&mut app, &ctx, vec![], None);
|
||||
native_frame(&mut app, &ctx, vec![], None);
|
||||
assert!(!native_menu_enabled(&app, &ctx, "edit.copy"), "without a focused field, as the document says");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_select_all_without_a_focused_field_selects_the_documents_items() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_focused_field(&ctx, "frame");
|
||||
app.ui.palette = None;
|
||||
native_frame(&mut app, &ctx, vec![], None);
|
||||
native_frame(&mut app, &ctx, vec![], None);
|
||||
assert!(!ctx.text_edit_focused());
|
||||
native_frame(&mut app, &ctx, vec![], Some("edit.selectAll"));
|
||||
assert_eq!(document_items(&app), (2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_apply_lists_styles_then_commands() {
|
||||
let mut s = designcraft_engine::Session::new();
|
||||
|
||||
Reference in New Issue
Block a user