Files
vectorcraft/crates/ui-egui/src/tests_selectall.rs
T
bflatasticandClaude Opus 5.5 8e753d746e Part of #516: the in-window menus' Select All, Cut, Copy and Paste act on a focused field
The clipboard keys already went to a focused text field, but on Windows,
Linux and the web the press that opens a menu takes the keyboard from the
field, so Edit > Copy (and Cut, Paste, Select > All) acted on the art.
The menu bar now keeps the field that had the keyboard, and its
selection, while the menus are open; those four commands go to it as the
events their keys send, the frame after the click that chose them, and
give it the keyboard back. Everything else, and those commands with no
field, run as before. The macOS menu bar shares the same helpers.

Tests: Copy, Cut and Paste events in the W field and the Color panel's
hex field edit the text, never the art; Select > All, Edit > Copy and
Edit > Paste clicked in the in-window menus act on the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:51:02 +07:00

265 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Select All (Cmd+A) follows the keyboard, from the window and from the system menu bar (macOS):
//! a focused text field selects its own text, the Type tool editing selects the text being edited,
//! and only otherwise are all the objects selected.
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, vec2};
use serde_json::json;
use vectorcraft_engine::Session;
use vectorcraft_tools::{PointerEvent, PointerKind};
use crate::{VectorcraftApp, menus};
struct App {
app: VectorcraftApp,
ctx: egui::Context,
time: f64,
}
impl App {
/// A document with a 37 pt wide rectangle (selected) and a second, unselected one.
fn new() -> Self {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("shape.rectangle", json!({"x": 200, "y": 150, "width": 20, "height": 20})).unwrap();
app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 37, "height": 40})).unwrap();
let mut a = Self { app, ctx: egui::Context::default(), time: 0.0 };
for _ in 0..3 {
a.frame(vec![]);
}
a
}
/// One whole app frame with `events`; `menu`: a system menu item chosen first, as the macOS
/// host dispatches it before the app's logic.
fn frame_with(&mut self, events: Vec<Event>, menu: Option<&str>) -> egui::FullOutput {
self.time += 0.1;
let raw = egui::RawInput {
events,
time: Some(self.time),
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 850.0))),
..Default::default()
};
let app = &mut self.app;
let mut out = self.ctx.run_ui(raw, |ui| {
if let Some(id) = menu {
menus::invoke_from_system_menu(app, ui.ctx(), id, json!({}));
}
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
out
}
fn frame(&mut self, events: Vec<Event>) -> egui::FullOutput {
self.frame_with(events, None)
}
fn menu(&mut self, id: &str) -> egui::FullOutput {
self.frame_with(vec![], Some(id))
}
fn selected(&self) -> usize {
self.app.session.active().unwrap().selection.objects.len()
}
/// The panel number field showing `text` (the field keeps its text under its id): id, rect.
fn field(&self, text: &str) -> (egui::Id, Rect) {
let widgets: Vec<(egui::Id, Rect)> =
self.ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).map(|w| (w.id, w.rect)).collect());
widgets
.into_iter()
.find(|&(id, _)| {
self.ctx.data(|d| d.get_temp::<String>(id)).is_some_and(|t| t == text) && egui::TextEdit::load_state(&self.ctx, id).is_some()
})
.unwrap_or_else(|| panic!("no field shows {text:?}"))
}
/// The selected character range of text field `id`.
fn field_selection(&self, id: egui::Id) -> Option<(usize, usize)> {
let r = egui::TextEdit::load_state(&self.ctx, id)?.cursor.char_range()?;
let (a, b) = (r.primary.index, r.secondary.index);
Some((a.min(b).into(), a.max(b).into()))
}
}
fn press(key: Key, modifiers: Modifiers) -> Event {
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }
}
fn click(at: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
}
#[test]
fn cmd_a_in_a_panel_field_selects_its_text_and_leaves_the_art() {
let mut a = App::new();
assert_eq!(a.selected(), 1);
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)]);
a.frame(vec![]);
assert!(a.ctx.text_edit_focused(), "the W field has the keyboard");
let all = Some((0, "37 pt".chars().count()));
// The window's Cmd+A (Windows, Linux, the web, macOS without the system menu).
a.frame(vec![press(Key::End, Modifiers::NONE)]);
assert_eq!(a.field_selection(id), Some((5, 5)), "End collapses the selection");
a.frame(vec![press(Key::A, Modifiers::COMMAND)]);
assert_eq!(a.field_selection(id), all, "Cmd+A selects the field's text");
assert_eq!(a.selected(), 1, "and not the art");
// Select All from the macOS menu bar (its key equivalent or a click): the same.
a.frame(vec![press(Key::End, Modifiers::NONE)]);
a.menu("select.all");
a.frame(vec![]);
assert_eq!(a.field_selection(id), all, "the menu's Select All selects the field's text");
assert_eq!(a.selected(), 1, "and not the art");
// The menu's Copy copies the field's text, not the art.
let out = a.menu("edit.copy");
let copied: Vec<_> = out
.platform_output
.commands
.iter()
.filter_map(|c| match c {
egui::OutputCommand::CopyText(t) => Some(t.as_str()),
_ => None,
})
.collect();
assert_eq!(copied, ["37 pt"]);
assert!(a.app.session.clipboard.is_empty(), "no art copied");
assert!(a.ctx.text_edit_focused(), "the field keeps the keyboard");
}
#[test]
fn cmd_a_while_editing_type_selects_all_of_its_text() {
let mut a = App::new();
a.app.select_tool("type");
let view = a.app.view_info();
for kind in [PointerKind::Down, PointerKind::Up] {
a.app.session.pointer(&PointerEvent::new(kind, 100.0, 200.0), view).unwrap();
}
a.frame(vec![Event::Text("abc".into())]);
let text = a.app.session.active().unwrap().selection.objects.clone();
let range = |a: &App| {
let o = a.app.session.tool_options();
(o["start"].as_u64().unwrap(), o["end"].as_u64().unwrap().min(3))
};
assert_eq!(range(&a), (3, 3), "the caret after the text");
// The window's Cmd+A.
a.frame(vec![press(Key::A, Modifiers::COMMAND)]);
assert_eq!(range(&a), (0, 3), "Cmd+A selects the text being edited");
assert_eq!(a.app.session.active().unwrap().selection.objects, text, "and not the art");
a.frame(vec![press(Key::End, Modifiers::NONE)]);
assert_eq!(range(&a), (3, 3));
// The macOS menu bar's Select All.
a.menu("select.all");
assert_eq!(range(&a), (0, 3), "the menu's Select All selects the text being edited");
assert_eq!(a.app.session.active().unwrap().selection.objects, text);
// Typing replaces it.
a.frame(vec![Event::Text("Z".into())]);
let doc = a.app.session.execute("document.inspect", &json!({})).unwrap();
let names: Vec<_> = doc["layers"][0]["children"].as_array().unwrap().iter().map(|c| c["name"].clone()).collect();
assert!(names.contains(&json!("Z")), "{names:?}");
}
#[test]
fn cmd_a_on_the_canvas_selects_all_objects() {
let mut a = App::new();
assert!(!a.ctx.egui_wants_keyboard_input());
a.frame(vec![press(Key::A, Modifiers::COMMAND)]);
assert_eq!(a.selected(), 2, "Cmd+A selects every object");
a.app.run("select.none", json!({})).unwrap();
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);
}