242 lines
10 KiB
Rust
242 lines
10 KiB
Rust
//! Text-box editing through real pointer and keyboard input, including document boundaries.
|
|
|
|
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
|
|
use gridcraft_engine::Session;
|
|
use gridcraft_engine::core::{CellRef, Value};
|
|
use gridcraft_engine::model::{Shape, Workbook};
|
|
use gridcraft_ui_egui::{SheetApp, grid::Geo};
|
|
use serde_json::json;
|
|
|
|
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
|
|
|
const ORIGINAL: &str = "First line\nSecond line";
|
|
|
|
fn at(s: &str) -> CellRef {
|
|
CellRef::parse(s).unwrap()
|
|
}
|
|
|
|
fn fixture() -> (Harness, u32) {
|
|
let mut session = Session::new();
|
|
session.new_workbook();
|
|
session.execute("cell.set", json!({"cell": "B2", "input": "cell stays"})).unwrap();
|
|
session.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
|
let id = session.execute("insert.textBox", json!({"at": "B2", "width": 240, "height": 120, "text": ORIGINAL})).unwrap()["shape"].as_u64().unwrap()
|
|
as u32;
|
|
let mut h = egui_kittest::Harness::builder()
|
|
.with_size(vec2(1200.0, 800.0))
|
|
.with_step_dt(1.0 / 60.0)
|
|
.with_os(egui::os::OperatingSystem::Mac)
|
|
.build_ui_state(
|
|
|ui, app: &mut SheetApp| {
|
|
let ctx = ui.ctx().clone();
|
|
app.logic(&ctx);
|
|
app.ui(ui);
|
|
},
|
|
SheetApp::new(session, Default::default()),
|
|
);
|
|
h.run_steps(4);
|
|
(h, id)
|
|
}
|
|
|
|
fn shape(h: &Harness, id: u32) -> &Shape {
|
|
h.state().session.doc().unwrap().wb.active().unwrap().shapes.iter().find(|s| s.id == id).unwrap()
|
|
}
|
|
|
|
fn shape_text(wb: &Workbook, sheet: usize, id: u32) -> &str {
|
|
&wb.sheet(sheet).unwrap().shapes.iter().find(|s| s.id == id).unwrap().text
|
|
}
|
|
|
|
fn shape_rect(h: &Harness, id: u32) -> Rect {
|
|
let app = h.state();
|
|
let sh = app.session.doc().unwrap().wb.active().unwrap();
|
|
let geo = Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll);
|
|
let a = shape(h, id).anchor;
|
|
Rect::from_min_size(pos2(geo.x(sh, a.cell.col) + a.dx * geo.z, geo.y(sh, a.cell.row) + a.dy * geo.z), vec2(a.width, a.height) * geo.z)
|
|
}
|
|
|
|
fn cell_center(h: &Harness, address: &str) -> Pos2 {
|
|
let app = h.state();
|
|
let sh = app.session.doc().unwrap().wb.active().unwrap();
|
|
Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll).cell_rect(sh, at(address)).center()
|
|
}
|
|
|
|
fn pointer(h: &mut Harness, p: Pos2, pressed: bool) {
|
|
h.input_mut().events.push(Event::PointerMoved(p));
|
|
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
|
h.step();
|
|
}
|
|
|
|
fn click(h: &mut Harness, p: Pos2) {
|
|
pointer(h, p, true);
|
|
pointer(h, p, false);
|
|
h.run_steps(2);
|
|
}
|
|
|
|
fn edit_box(h: &mut Harness, id: u32) {
|
|
// Separate this gesture from egui's 600 ms triple-click window as well.
|
|
h.run_steps(45);
|
|
let p = shape_rect(h, id).center();
|
|
click(h, p);
|
|
click(h, p);
|
|
}
|
|
|
|
fn key(h: &mut Harness, key: Key, modifiers: Modifiers) {
|
|
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
|
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers });
|
|
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers });
|
|
h.step();
|
|
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
|
h.step();
|
|
}
|
|
|
|
fn event(h: &mut Harness, event: Event) {
|
|
h.input_mut().events.push(event);
|
|
h.run_steps(2);
|
|
}
|
|
|
|
fn replace(h: &mut Harness, text: &str) {
|
|
key(h, Key::A, Modifiers::COMMAND);
|
|
event(h, Event::Paste(text.into()));
|
|
}
|
|
|
|
fn assert_cell_unchanged(h: &Harness) {
|
|
assert_eq!(h.state().session.doc().unwrap().wb.active().unwrap().value(at("B2")), Value::text("cell stays"));
|
|
assert!(h.state().editor.is_none(), "text-box input must not start a cell editor");
|
|
}
|
|
|
|
#[test]
|
|
fn double_click_edits_multiline_text_and_commits_on_outside_click() {
|
|
let (mut h, id) = fixture();
|
|
edit_box(&mut h, id);
|
|
assert_eq!(h.state().selected_chart, Some(id));
|
|
let anchor = shape(&h, id).anchor;
|
|
let start = shape_rect(&h, id).min + vec2(5.0, 10.0);
|
|
drag(&mut h, start, start + vec2(60.0, 0.0));
|
|
assert_eq!(shape(&h, id).anchor, anchor, "dragging inside the editor selects text instead of moving the box");
|
|
h.input_mut().events.push(Event::Copy);
|
|
h.step();
|
|
assert!(h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(text) if !text.is_empty())));
|
|
key(&mut h, Key::A, Modifiers::COMMAND);
|
|
h.input_mut().events.push(Event::Copy);
|
|
h.step();
|
|
assert!(h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(text) if text == ORIGINAL)));
|
|
event(&mut h, Event::Cut);
|
|
event(&mut h, Event::Paste("New first line".into()));
|
|
key(&mut h, Key::Enter, Modifiers::NONE);
|
|
event(&mut h, Event::Text("New second line".into()));
|
|
assert_cell_unchanged(&h);
|
|
assert_eq!(h.state().session.doc().unwrap().selection.active, at("B2"));
|
|
|
|
let outside = cell_center(&h, "J15");
|
|
click(&mut h, outside);
|
|
assert_eq!(shape(&h, id).text, "New first line\nNew second line");
|
|
assert_cell_unchanged(&h);
|
|
}
|
|
|
|
fn drag(h: &mut Harness, from: Pos2, to: Pos2) {
|
|
pointer(h, from, true);
|
|
// Cross egui's drag threshold while still inside the object/resize handle.
|
|
h.input_mut().events.push(Event::PointerMoved(from + vec2(5.0, 5.0)));
|
|
h.step();
|
|
h.input_mut().events.push(Event::PointerMoved(to));
|
|
h.step();
|
|
pointer(h, to, false);
|
|
h.run_steps(2);
|
|
}
|
|
|
|
#[test]
|
|
fn escape_discards_text_and_the_box_still_moves_and_resizes() {
|
|
let (mut h, id) = fixture();
|
|
edit_box(&mut h, id);
|
|
replace(&mut h, "discard this edit");
|
|
key(&mut h, Key::Escape, Modifiers::NONE);
|
|
assert_eq!(shape(&h, id).text, ORIGINAL);
|
|
assert_cell_unchanged(&h);
|
|
h.run_steps(30);
|
|
|
|
let before = shape_rect(&h, id);
|
|
drag(&mut h, before.center(), before.center() + vec2(40.0, 30.0));
|
|
let moved = shape_rect(&h, id);
|
|
assert!(moved.left() > before.left() && moved.top() > before.top(), "ordinary dragging still moves the box");
|
|
assert!((moved.size() - before.size()).abs().max_elem() < 0.1);
|
|
|
|
let handle = moved.right_bottom() - vec2(7.0, 7.0);
|
|
drag(&mut h, handle, handle + vec2(45.0, 25.0));
|
|
let resized = shape_rect(&h, id);
|
|
assert!(resized.width() > moved.width() && resized.height() > moved.height(), "the corner still resizes the box");
|
|
assert_eq!(shape(&h, id).text, ORIGINAL);
|
|
assert_cell_unchanged(&h);
|
|
|
|
edit_box(&mut h, id);
|
|
replace(&mut h, "Offscreen draft");
|
|
h.state_mut().view_mut().unwrap().scroll = vec2(900.0, 700.0);
|
|
h.run_steps(2);
|
|
assert_eq!(h.state().text_box_editor.as_ref().unwrap().text, "Offscreen draft");
|
|
key(&mut h, Key::Escape, Modifiers::NONE);
|
|
assert_eq!(shape(&h, id).text, ORIGINAL);
|
|
assert_cell_unchanged(&h);
|
|
}
|
|
|
|
#[test]
|
|
fn saving_and_switching_context_commit_to_the_original_text_box() {
|
|
let (mut h, id) = fixture();
|
|
edit_box(&mut h, id);
|
|
replace(&mut h, "Saved draft\nSecond paragraph");
|
|
h.state_mut().run("sheet.read", json!({})).unwrap();
|
|
assert_eq!(shape(&h, id).text, ORIGINAL, "inspecting the sheet must not commit a draft");
|
|
assert!(h.state().text_box_editor.is_some());
|
|
let saved = h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
|
let bytes = gridcraft_engine::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap();
|
|
let (reopened, _) = gridcraft_engine::io::open_bytes("edited.xlsx", &bytes).unwrap();
|
|
assert_eq!(shape_text(&reopened, 0, id), "Saved draft\nSecond paragraph");
|
|
assert_eq!(reopened.sheet(0).unwrap().value(at("B2")), Value::text("cell stays"));
|
|
|
|
// Save and Save As must work while the editor still owns keyboard focus.
|
|
let downloaded = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
let sink = downloaded.clone();
|
|
h.state_mut().services.download = Some(Box::new(move |_, bytes| *sink.lock().unwrap() = bytes.to_vec()));
|
|
for modifiers in [Modifiers::COMMAND, Modifiers::COMMAND | Modifiers::SHIFT] {
|
|
edit_box(&mut h, id);
|
|
replace(&mut h, "Saved from the text editor");
|
|
key(&mut h, Key::S, modifiers);
|
|
let (saved, _) = gridcraft_engine::io::open_bytes("shortcut.xlsx", &downloaded.lock().unwrap()).unwrap();
|
|
assert_eq!(shape_text(&saved, 0, id), "Saved from the text editor");
|
|
downloaded.lock().unwrap().clear();
|
|
}
|
|
|
|
h.state_mut().run("home.insertSheet", json!({"name": "Other"})).unwrap();
|
|
let other_id = h.state_mut().run("insert.textBox", json!({"at": "B2", "text": "Other sheet"})).unwrap()["shape"].as_u64().unwrap() as u32;
|
|
h.state_mut().run("sheet.activate", json!({"sheet": 0})).unwrap();
|
|
h.run_steps(3);
|
|
edit_box(&mut h, id);
|
|
replace(&mut h, "Original sheet only");
|
|
h.state_mut().run("sheet.activate", json!({"sheet": 1})).unwrap();
|
|
let wb = &h.state().session.doc().unwrap().wb;
|
|
assert_eq!(shape_text(wb, 0, id), "Original sheet only");
|
|
assert_eq!(shape_text(wb, 1, other_id), "Other sheet");
|
|
|
|
h.state_mut().run("file.new", json!({})).unwrap();
|
|
let other_doc_id = h.state_mut().run("insert.textBox", json!({"at": "B2", "text": "Other workbook"})).unwrap()["shape"].as_u64().unwrap() as u32;
|
|
h.state_mut().run("window.activate", json!({"index": 0})).unwrap();
|
|
h.state_mut().run("sheet.activate", json!({"sheet": 0})).unwrap();
|
|
h.run_steps(3);
|
|
edit_box(&mut h, id);
|
|
replace(&mut h, "Original workbook only");
|
|
h.state_mut().run("window.activate", json!({"index": 1})).unwrap();
|
|
let docs = h.state().session.documents();
|
|
assert_eq!(shape_text(&docs[0].wb, 0, id), "Original workbook only");
|
|
assert_eq!(shape_text(&docs[1].wb, 0, other_doc_id), "Other workbook");
|
|
assert_eq!(docs[0].wb.sheet(0).unwrap().value(at("B2")), Value::text("cell stays"));
|
|
}
|
|
|
|
#[test]
|
|
fn protected_sheet_refuses_to_open_the_text_box_editor() {
|
|
let (mut h, id) = fixture();
|
|
h.state_mut().session.execute("review.protectSheet", json!({})).unwrap();
|
|
edit_box(&mut h, id);
|
|
assert!(h.state().text_box_editor.is_none(), "a protected sheet's text box must not open an editor");
|
|
assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("protected sheet")));
|
|
assert_eq!(shape(&h, id).text, ORIGINAL);
|
|
assert_cell_unchanged(&h);
|
|
}
|