Merge #548: Part of #516: the in-window menus' Select All, Cut, Copy and Paste act on a focused field

# Conflicts:
#	ROADMAP.md
This commit is contained in:
bflatastic 2026-10-09 00:58:46 +07:00
commit aa4fa48751
3 changed files with 144 additions and 25 deletions

File diff suppressed because one or more lines are too long

View File

@ -2661,9 +2661,24 @@ pub fn ruler_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Op
/// The in-window menu bar. Returns where its titles end (x): the bar itself takes the full width.
pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
let t = Tokens::get(ui.ctx());
let ctx = ui.ctx().clone();
// The press that opens a menu takes the keyboard from a focused text field: the field and its
// selection are kept while the menus are open, so their Select All, Cut, Copy and Paste act on
// its text (#516), the frame after the click that chose them (that click takes the keyboard
// from it again).
type Field = (egui::Id, egui::text_edit::TextEditState);
let (field_key, chosen_key) = (egui::Id::new("menu-bar-field"), egui::Id::new("menu-bar-field-event"));
if let Some(((field, state), e)) = ctx.data(|d| d.get_temp::<(Field, egui::Event)>(chosen_key)) {
ctx.data_mut(|d| d.remove::<(Field, egui::Event)>(chosen_key));
state.store(&ctx, field);
to_field(&ctx, field, e);
}
if let Some(field) = focused_text_field(&ctx).and_then(|id| Some((id, egui::TextEdit::load_state(&ctx, id)?))) {
ctx.data_mut(|d| d.insert_temp::<Field>(field_key, field));
}
let mut clicked: Option<(String, Value)> = None;
let tree = menu_tree();
let end = egui::MenuBar::new()
let (end, open) = egui::MenuBar::new()
.ui(ui, |ui| {
let mut titles = Vec::with_capacity(tree.len());
for (i, (title, items)) in tree.iter().enumerate() {
@ -2674,12 +2689,21 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
};
titles.push(ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked)).response);
}
switch_on_hover(ui.ctx(), &titles);
ui.cursor().min.x
(ui.cursor().min.x, switch_on_hover(ui.ctx(), &titles))
})
.inner;
let field = ctx.data(|d| d.get_temp::<Field>(field_key));
if !open && focused_text_field(&ctx).is_none() {
ctx.data_mut(|d| d.remove::<Field>(field_key));
}
if let Some((id, p)) = clicked {
invoke(app, &id, p);
match field.and_then(|f| Some((f, field_event(app, &id)?))) {
Some(chosen) => {
ctx.data_mut(|d| d.insert_temp(chosen_key, chosen));
ctx.request_repaint();
}
None => invoke(app, &id, p),
}
}
end
}
@ -2692,29 +2716,25 @@ fn pointer_reaches_title(ctx: &egui::Context, title: &egui::Response, p: egui::P
}
/// Like a native menu bar: while one top-level menu is open, moving the pointer onto another
/// title opens that menu instead (egui alone needs a click on each title).
fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) {
/// title opens that menu instead (egui alone needs a click on each title). Returns whether a menu
/// is open.
fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) -> bool {
let ids: Vec<egui::Id> = titles.iter().map(egui::Popup::default_response_id).collect();
let Some(open) = ids.iter().position(|id| egui::Popup::is_id_open(ctx, *id)) else {
return;
return false;
};
// `Response::hovered` is false while a menu's popup is open, so hit-test the titles here.
let Some(p) = ctx.pointer_hover_pos() else {
return;
};
// Only a moving pointer switches: one resting on a title leaves the open menu alone.
if ctx.input(|i| i.pointer.delta() == egui::Vec2::ZERO) {
return;
}
let Some(i) = titles.iter().position(|title| pointer_reaches_title(ctx, title, p)) else {
return;
};
if i != open
let moving = ctx.input(|i| i.pointer.delta() != egui::Vec2::ZERO);
if let Some(p) = ctx.pointer_hover_pos().filter(|_| moving)
&& let Some(i) = titles.iter().position(|title| pointer_reaches_title(ctx, title, p))
&& i != open
&& let Some(id) = ids.get(i)
{
egui::Popup::open_id(ctx, *id);
ctx.request_repaint();
}
true
}
/// A top-level menu's popup: as wide as its widest item (label plus shortcut), at least 230 pt;
@ -2859,13 +2879,21 @@ fn field_event(app: &mut VectorcraftApp, id: &str) -> Option<egui::Event> {
/// Copy and Paste act on the field's text, as their keys do; everything else (and those commands
/// with no field focused) goes to [`invoke`].
pub fn invoke_from_system_menu(app: &mut VectorcraftApp, ctx: &egui::Context, id: &str, p: Value) {
if ctx.text_edit_focused()
&& let Some(e) = field_event(app, id)
{
ctx.input_mut(|i| i.events.push(e));
return;
match focused_text_field(ctx).and_then(|f| Some((f, field_event(app, id)?))) {
Some((field, e)) => to_field(ctx, field, e),
None => invoke(app, id, p),
}
invoke(app, id, p);
}
/// The text field that has the keyboard, if any.
fn focused_text_field(ctx: &egui::Context) -> Option<egui::Id> {
ctx.memory(|m| m.focused()).filter(|&id| egui::TextEdit::load_state(ctx, id).is_some())
}
/// Give text field `field` the keyboard (back) and event `e`, a [`field_event`], this frame.
fn to_field(ctx: &egui::Context, field: egui::Id, e: egui::Event) {
ctx.memory_mut(|m| m.request_focus(field));
ctx.input_mut(|i| i.events.push(e));
}
/// Invoke a menu/command id with UI side effects (dialogs for "…" commands that need input).

View File

@ -171,3 +171,94 @@ fn cmd_a_on_the_canvas_selects_all_objects() {
a.menu("select.all");
assert_eq!(a.selected(), 2, "so does the menu's Select All");
}
/// The copied texts of a frame's output.
fn copied(out: &egui::FullOutput) -> Vec<String> {
out.platform_output.commands.iter().filter_map(|c| if let egui::OutputCommand::CopyText(t) = c { Some(t.clone()) } else { None }).collect()
}
/// #516: Ctrl/Cmd+C, X and V in a focused field (the window's own clipboard keys, which egui sends
/// as Copy, Cut and Paste events) edit the field's text, never the art.
#[test]
fn the_clipboard_keys_in_a_focused_field_edit_its_text() {
let mut a = App::new();
a.app.run("window.panel", json!({"panel": "color"})).unwrap();
for _ in 0..3 {
a.frame(vec![]);
}
for text in ["37 pt", "FFFFFF"] {
let (id, rect) = a.field(text);
a.frame(vec![Event::PointerMoved(rect.center()), click(rect.center(), true)]);
a.frame(vec![click(rect.center(), false)]);
a.frame(vec![press(Key::A, Modifiers::COMMAND)]);
assert!(a.ctx.memory(|m| m.has_focus(id)), "{text}: the field has the keyboard");
let out = a.frame(vec![Event::Copy]);
assert_eq!(copied(&out), [text], "Copy copies the field's text");
assert!(a.app.session.clipboard.is_empty(), "{text}: no art copied");
let out = a.frame(vec![Event::Cut]);
assert_eq!(copied(&out), [text], "Cut cuts the field's text");
a.frame(vec![Event::Paste("12".into())]);
assert_eq!(a.ctx.data(|d| d.get_temp::<String>(id)).as_deref(), Some("12"), "Paste pastes into the field");
assert_eq!(a.selected(), 1, "{text}: the art stays");
a.frame(vec![press(Key::Escape, Modifiers::NONE)]);
}
}
/// The centre of the text `label` painted last (in the topmost layer) in a frame's output; with
/// `title`, the first one painted (the menu bar's).
fn text_at(out: &egui::FullOutput, label: &str, title: bool) -> Pos2 {
let mut shapes: Box<dyn Iterator<Item = _>> = if title { Box::new(out.shapes.iter()) } else { Box::new(out.shapes.iter().rev()) };
shapes
.find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text().trim() == label => Some(Rect::from_min_size(t.pos, t.galley.size()).center()),
_ => None,
})
.unwrap_or_else(|| panic!("no {label:?} painted"))
}
/// A click on `at` → the frame after it.
fn click_at(a: &mut App, at: Pos2) -> egui::FullOutput {
a.frame(vec![Event::PointerMoved(at), click(at, true)]);
a.frame(vec![click(at, false)]);
a.frame(vec![])
}
/// The in-window menus' Select › All, Edit › Copy and Edit › Paste act on the text field that had the keyboard
/// when the menu was opened (the click on the menu took it), and give it the keyboard back.
#[test]
fn the_in_window_edit_menu_acts_on_the_field_it_was_opened_from() {
let mut a = App::new();
// Text on the system clipboard.
a.app.services.clipboard_read = Some(Box::new(|| Some("42 pt".into())));
let (id, rect) = a.field("37 pt");
a.frame(vec![Event::PointerMoved(rect.center()), click(rect.center(), true)]);
a.frame(vec![click(rect.center(), false)]);
let out = a.frame(vec![press(Key::End, Modifiers::NONE)]);
let (edit, select) = (text_at(&out, "Edit", true), text_at(&out, "Select", true));
let menu = |a: &mut App, title: Pos2, item: &str| {
let out = click_at(a, title);
assert!(!a.ctx.text_edit_focused(), "the menu took the keyboard");
click_at(a, text_at(&out, item, false))
};
menu(&mut a, select, "All");
assert!(a.ctx.memory(|m| m.has_focus(id)), "the field has the keyboard back");
assert_eq!(a.field_selection(id), Some((0, 5)), "Select All selected the field's text");
assert_eq!(a.selected(), 1, "not the art");
a.frame(vec![press(Key::End, Modifiers::NONE)]);
a.frame(vec![press(Key::A, Modifiers::COMMAND)]);
a.frame(vec![]);
let out = menu(&mut a, edit, "Copy");
assert_eq!(copied(&out), ["37 pt"], "Copy copied the field's text");
assert!(a.app.session.clipboard.is_empty(), "no art copied");
// Art on the app's clipboard (Paste is enabled): the field still takes the text.
a.app.run("edit.copy", json!({})).unwrap();
a.frame(vec![press(Key::A, Modifiers::COMMAND)]);
menu(&mut a, edit, "Paste");
assert_eq!(a.ctx.data(|d| d.get_temp::<String>(id)).as_deref(), Some("42 pt"), "Paste pasted into the field");
assert_eq!(a.selected(), 1, "nothing pasted on the canvas");
// With no field focused, the menu's Select All selects the art.
a.frame(vec![press(Key::Escape, Modifiers::NONE)]);
a.frame(vec![]);
menu(&mut a, select, "All");
assert_eq!(a.selected(), 2);
}