R5.5: ElyTable right-click menus, Excel-style; three rounds used, last fixes tested

Grid and header menus share ely_office::menu with ElyDoc. Each item's
availability is the kernel's state for the exact command it runs
(patch 0045). Patch 0046 lets a header select its row or column while
a shape is selected or its text is edited: the view shell ends the
drawing edit, keeps the typed text, leaves drawing mode, then marks.
Ely no longer sends Esc first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-14 13:02:10 -04:00
1 parent 07c0b43542
commit 87886c8a8d
30 files changed
+1010 -106

No files matched your search

+6 -1
View File
@@ -61,6 +61,8 @@ pub struct Book {
pub drag: Option<Drag>,
/// The sheet menu's request: number and place.
pub sheet_menu: Option<(u64, gpui::Point<gpui::Pixels>)>,
/// The grid or header menu.
pub menu: Option<ely_office::menu::Offer<crate::menu::What>>,
pub panel: Entity<Panel>,
pub panel_shown: bool,
pub choices: Option<crate::rules::Choices>,
@@ -95,6 +97,7 @@ impl Host for Book {
self.freeze(id, view, cx);
self.found(id, view, cx);
self.requested(id, view, cx);
self.offer_menu(id, view, cx);
self.follow_rule(id, view, cx);
self.read_links(id, view, cx);
if self.core.active == Some(id) {
@@ -190,6 +193,7 @@ impl Book {
bars: Bars::new(window, cx),
drag: None,
sheet_menu: None,
menu: None,
panel,
panel_shown: true,
choices: None,
@@ -388,7 +392,8 @@ impl Render for Book {
.on_action(cx.listener(Self::on_print))
.child(shell)
.children(self.core_dialog(cx))
.children(self.dialog_element(cx));
.children(self.dialog_element(cx))
.children(self.context_menu(cx));
let root = Self::bind_zoom(crate::cells::bind(crate::sheets::bind(root, cx), cx), cx);
let root = crate::data::bind(crate::formulas::bind(root, cx), cx);
let root = crate::page::bind(crate::protect::bind(root, cx), cx);
+1 -1
View File
@@ -144,7 +144,7 @@ impl Book {
self.run_in(Some(tab), id, &[&size.to_string()], cx);
}
fn ask_size(&mut self, column: bool, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) fn ask_size(&mut self, column: bool, window: &mut Window, cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
let text = Book::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, ely_gpui_component::primitives::Severity::Warning, cx);
+14 -6
View File
@@ -140,6 +140,12 @@ impl Book {
}
}
/// Selects a header's row or column.
pub(crate) fn select_header(&mut self, column: bool, index: u32, modifier: &str, cx: &mut Context<Self>) {
let id = if column { "select.column" } else { "select.row" };
self.run_active(id, &[&index.to_string(), modifier], cx);
}
/// One row or column header with its sizing edge.
fn header(
&self,
@@ -201,13 +207,15 @@ impl Book {
.border_color(transparent_black())
.focus_ring(cx)
.tab_index(0)
.on_mouse_down(
MouseButton::Right,
cx.listener(move |book, event: &MouseDownEvent, _, cx| {
cx.stop_propagation();
book.header_menu(column, index, event.position, cx);
}),
)
.on_click(cx.listener(move |book, event: &ClickEvent, _, cx| {
let id = if column {
"select.column"
} else {
"select.row"
};
book.run_active(id, &[&index.to_string(), &modifier(&event.modifiers())], cx);
book.select_header(column, index, &modifier(&event.modifiers()), cx);
}))
.child(label);
let cell = div()
+1
View File
@@ -11,6 +11,7 @@ mod figures;
mod format;
mod formulas;
mod grid;
mod menu;
mod output;
mod page;
mod protect;
+326
View File
@@ -0,0 +1,326 @@
//! The right-click menus, laid out as Excel's (TABLE-02).
use ely_gpui_component::{
menus::{ContextMenu, Menu, MenuItem},
primitives::Severity,
};
use ely_lok::{
LokView, Rect,
menu::{Runs, Target, runs},
sheet::Geometry,
};
use ely_office::menu::{self, Entry, Offer};
use ely_office::prelude::*;
use gpui::{App, Context, Entity, Pixels, Point, Window};
use crate::book::Book;
/// What was right-clicked.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Kind {
Cell,
Object,
/// A cell's text, being edited.
Text,
Rows,
Columns,
}
/// A menu's kind and what the kernel lets run.
pub struct What {
pub kind: Kind,
runs: Runs,
}
/// One thing a menu does.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Pick {
Cut,
Copy,
Paste,
RowsAbove,
RowsBelow,
ColumnsBefore,
ColumnsAfter,
InsertDown,
InsertRight,
DeleteRows,
DeleteColumns,
DeleteUp,
DeleteLeft,
Clear,
Merge,
Split,
Format,
RowHeight,
ColumnWidth,
HideRows,
ShowRows,
HideColumns,
ShowColumns,
Front,
Forward,
Back,
Backward,
}
const CLIPBOARD: [Pick; 3] = [Pick::Cut, Pick::Copy, Pick::Paste];
const CELL: &[Entry<Pick>] = &[
Entry::Line,
Entry::Sub(
"table.menu.insert",
&[
Pick::RowsAbove,
Pick::RowsBelow,
Pick::ColumnsBefore,
Pick::ColumnsAfter,
Pick::InsertDown,
Pick::InsertRight,
],
),
Entry::Sub(
"table.menu.delete",
&[Pick::DeleteRows, Pick::DeleteColumns, Pick::DeleteUp, Pick::DeleteLeft],
),
Entry::Row(Pick::Clear),
Entry::Line,
Entry::Row(Pick::Merge),
Entry::Row(Pick::Split),
Entry::Line,
Entry::Row(Pick::Format),
];
const OBJECT: &[Entry<Pick>] = &[
Entry::Line,
Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]),
Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]),
];
const ROWS: &[Entry<Pick>] = &[
Entry::Line,
Entry::Row(Pick::RowsAbove),
Entry::Row(Pick::DeleteRows),
Entry::Row(Pick::Clear),
Entry::Line,
Entry::Row(Pick::RowHeight),
Entry::Row(Pick::HideRows),
Entry::Row(Pick::ShowRows),
];
const COLUMNS: &[Entry<Pick>] = &[
Entry::Line,
Entry::Row(Pick::ColumnsBefore),
Entry::Row(Pick::DeleteColumns),
Entry::Row(Pick::Clear),
Entry::Line,
Entry::Row(Pick::ColumnWidth),
Entry::Row(Pick::HideColumns),
Entry::Row(Pick::ShowColumns),
];
impl menu::Pick for Pick {
fn row(self) -> (&'static str, &'static str) {
match self {
Pick::Cut => ("office.menu.cut", ".uno:Cut"),
Pick::Copy => ("office.menu.copy", ".uno:Copy"),
Pick::Paste => ("office.menu.paste", ".uno:Paste"),
Pick::RowsAbove => ("table.cells.rows_above", ".uno:InsertRowsBefore"),
Pick::RowsBelow => ("table.cells.rows_below", ".uno:InsertRowsAfter"),
Pick::ColumnsBefore => ("table.cells.columns_before", ".uno:InsertColumnsBefore"),
Pick::ColumnsAfter => ("table.cells.columns_after", ".uno:InsertColumnsAfter"),
Pick::InsertDown => ("table.cells.insert_down", ".uno:InsertCell"),
Pick::InsertRight => ("table.cells.insert_right", ".uno:InsertCell"),
Pick::DeleteRows => ("table.cells.delete_rows", ".uno:DeleteRows"),
Pick::DeleteColumns => ("table.cells.delete_columns", ".uno:DeleteColumns"),
Pick::DeleteUp => ("table.cells.delete_up", ".uno:DeleteCell"),
Pick::DeleteLeft => ("table.cells.delete_left", ".uno:DeleteCell"),
Pick::Clear => ("table.menu.clear", ".uno:ClearContents"),
Pick::Merge => ("table.cells.merge", ".uno:MergeCells"),
Pick::Split => ("table.cells.split", ".uno:SplitCell"),
Pick::Format => ("table.menu.format", ".uno:FormatCellDialog"),
Pick::RowHeight => ("table.cells.row_height", ".uno:RowHeight"),
Pick::ColumnWidth => ("table.cells.column_width", ".uno:ColumnWidth"),
Pick::HideRows => ("table.cells.hide_rows", ".uno:HideRow"),
Pick::ShowRows => ("table.cells.show_rows", ".uno:ShowRow"),
Pick::HideColumns => ("table.cells.hide_columns", ".uno:HideColumn"),
Pick::ShowColumns => ("table.cells.show_columns", ".uno:ShowColumn"),
Pick::Front => ("office.menu.front", ".uno:BringToFront"),
Pick::Forward => ("office.menu.forward", ".uno:ObjectForwardOne"),
Pick::Back => ("office.menu.back", ".uno:SendToBack"),
Pick::Backward => ("office.menu.backward", ".uno:ObjectBackOne"),
}
}
}
impl What {
/// The kernel lets the pick's own command run now.
pub(crate) fn on(&self, pick: Pick) -> bool {
self.runs.allows(menu::Pick::row(pick).1)
}
}
impl Kind {
/// Its rows under the clipboard row.
fn rows(self) -> &'static [Entry<Pick>] {
match self {
Kind::Cell => CELL,
Kind::Object => OBJECT,
Kind::Text => &[],
Kind::Rows => ROWS,
Kind::Columns => COLUMNS,
}
}
/// Every command its rows run.
fn commands(self) -> Vec<&'static str> {
let rows = self.rows().iter().flat_map(|entry| match entry {
Entry::Row(pick) => std::slice::from_ref(pick),
Entry::Sub(_, picks) => picks,
Entry::Line => &[],
});
let mut commands: Vec<_> = CLIPBOARD.iter().chain(rows).map(|pick| menu::Pick::row(*pick).1).collect();
commands.sort_unstable();
commands.dedup();
commands
}
}
/// A whole selected row or column covers the header.
pub(crate) fn held(selection: &[Rect], geometry: &Geometry, column: bool, index: u32) -> bool {
let (along, across) = if column {
(&geometry.columns, &geometry.rows)
} else {
(&geometry.rows, &geometry.columns)
};
let (from, to) = (along.start(index), along.start(index + 1));
let end = across.start(across.count());
selection.iter().any(|&(x, y, w, h)| {
let ((start, size), (side, span)) = if column { ((x, w), (y, h)) } else { ((y, h), (x, w)) };
side == 0 && span + 1 >= end && start <= from && to <= start + size + 1
})
}
impl Book {
/// Turns the kernel's context menu into Ely's.
pub(crate) fn offer_menu(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
let (offered, at) = match menu::take(view, cx) {
None => return,
Some(Ok(taken)) => taken,
Some(Err(error)) => return self.engine_failed(&error.to_string(), cx),
};
let kind = match offered.target {
Target::Cell => Kind::Cell,
Target::Object => Kind::Object,
Target::Text => Kind::Text,
Target::Table | Target::Frame => {
let error = format!("a {:?} menu in a spreadsheet", offered.target);
return self.toast(Book::text(cx, "office.error.engine", &[("error", &error)]), Severity::Danger, cx);
}
};
self.offer(id, kind, at, cx);
}
/// Selects a header, then offers its menu.
pub(crate) fn header_menu(&mut self, column: bool, index: u32, at: Point<Pixels>, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return;
};
let id = tab.id;
// A selected object keeps nothing held.
let read = tab.view.read(cx);
if read.graphic().is_some() || !held(read.selection(), &tab.extra.geometry, column, index) {
self.select_header(column, index, "0", cx);
}
log::info!("table: a header menu, column {column}");
self.menu = None;
let kind = if column { Kind::Columns } else { Kind::Rows };
match self.settled(id, cx) {
true => self.offer(id, kind, at, cx),
false => self.when_settled(id, move |book, _, cx| book.offer(id, kind, at, cx)),
}
cx.notify();
}
/// Asks what may run now, then opens the menu.
fn offer(&mut self, id: u64, kind: Kind, at: Point<Pixels>, cx: &mut Context<Self>) {
let commands = kind.commands();
self.ask_in(
id,
move |worker, doc| runs(worker, doc, &commands),
move |book, read, _, cx| match read {
Ok(runs) => {
book.menu = Some(Offer::new(book.menu.as_ref(), id, What { kind, runs }, at));
cx.notify();
}
Err(error) => book.engine_failed(&error.to_string(), cx),
},
cx,
);
}
/// The menu's host, for the active workbook.
pub(crate) fn context_menu(&self, cx: &mut Context<Self>) -> Option<ContextMenu> {
let offer = self.menu.as_ref().filter(|offer| Some(offer.tab) == self.core.active)?;
Some(offer.host("table-menu", menu_of(&cx.entity(), &offer.what, cx)))
}
/// Runs one menu row.
pub(crate) fn choose(&mut self, pick: Pick, window: &mut Window, cx: &mut Context<Self>) {
log::info!("table: the menu picks {pick:?}");
let (id, values): (&str, &[&str]) = match pick {
Pick::Cut | Pick::Copy | Pick::Paste => return self.clipboard(pick, cx),
Pick::Merge => return self.merge(self.core.active, cx),
Pick::RowHeight => return self.ask_size(false, window, cx),
Pick::ColumnWidth => return self.ask_size(true, window, cx),
Pick::Format => {
self.panel_shown = true;
return cx.notify();
}
Pick::RowsAbove => ("rows.insert_above", &[]),
Pick::RowsBelow => ("rows.insert_below", &[]),
Pick::ColumnsBefore => ("columns.insert_before", &[]),
Pick::ColumnsAfter => ("columns.insert_after", &[]),
Pick::InsertDown => ("cells.insert", &["V"]),
Pick::InsertRight => ("cells.insert", &[">"]),
Pick::DeleteRows => ("rows.delete", &[]),
Pick::DeleteColumns => ("columns.delete", &[]),
Pick::DeleteUp => ("cells.delete", &["U"]),
Pick::DeleteLeft => ("cells.delete", &["L"]),
Pick::Clear => ("cells.clear", &[]),
Pick::Split => ("cells.split", &[]),
Pick::HideRows => ("rows.hide", &[]),
Pick::ShowRows => ("rows.show", &[]),
Pick::HideColumns => ("columns.hide", &[]),
Pick::ShowColumns => ("columns.show", &[]),
Pick::Front => ("image.front", &[]),
Pick::Forward => ("image.forward", &[]),
Pick::Back => ("image.back", &[]),
Pick::Backward => ("image.backward", &[]),
};
self.run_active(id, values, cx);
}
fn clipboard(&mut self, pick: Pick, cx: &mut Context<Self>) {
let Some(view) = self.active_tab().map(|tab| tab.view.clone()) else {
log::warn!("table: {pick:?} with no workbook");
return;
};
view.update(cx, |view, _| match pick {
Pick::Paste => view.paste(),
_ => view.copy(pick == Pick::Cut),
});
}
}
/// The clipboard row, then the target's rows.
pub(crate) fn menu_of(this: &Entity<Book>, what: &What, cx: &App) -> Menu {
let item = |pick: Pick| {
let this = this.clone();
MenuItem::new(Book::text(cx, menu::Pick::row(pick).0, &[]))
.disabled(!what.on(pick))
.on_click(move |window, cx| this.update(cx, |book, cx| book.choose(pick, window, cx)))
};
let menu = menu::clipboard(Menu::new(), CLIPBOARD, item);
menu::entries(menu, what.kind.rows(), cx, |pick| what.on(pick), item)
}
+1
View File
@@ -395,6 +395,7 @@ fn a_delete_stays_with_its_workbook() {
mod analysis;
mod data;
mod menu;
mod names;
mod numbers;
mod output;
+258
View File
@@ -0,0 +1,258 @@
use ely_gpui_component::theme::Theme;
use ely_lok::{LokView, sheet};
use gpui::{Modifiers, MouseButton, Pixels, Point, VisualTestContext};
use super::data::{cells, open, run};
use super::*;
use crate::menu::{Kind, Pick, What, held};
/// The grid with the menu's host over it.
struct Host(Entity<Book>);
impl gpui::Render for Host {
fn render(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
use gpui::{ParentElement as _, Styled as _};
let grid = self.0.update(cx, |book, cx| book.grid(window, cx));
let menu = self.0.update(cx, |book, cx| book.context_menu(cx));
gpui::div().relative().size_full().flex().flex_col().child(grid).children(menu)
}
}
fn frames(cx: &mut VisualTestContext) {
for _ in 0..3 {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
}
fn view(cx: &mut VisualTestContext, book: &Entity<Book>) -> Entity<LokView> {
cx.update(|_, app| book.read(app).active_tab().expect("a tab").view.clone())
}
/// A cell's middle or its header's, in window pixels.
fn spot(cx: &mut VisualTestContext, book: &Entity<Book>, column: u32, row: u32, header: Option<bool>) -> Point<Pixels> {
frames(cx);
let view = view(cx, book);
cx.update(|_, app| {
let geometry = &book.read(app).active_tab().expect("a tab").extra.geometry;
let x = geometry.columns.start(column) + geometry.columns.size(column) / 2;
let y = geometry.rows.start(row) + geometry.rows.size(row) / 2;
let read = view.read(app);
let at = read.origin() + read.place((x, y));
match header {
None => at,
Some(false) => gpui::point(read.origin().x - gpui::px(24.0), at.y),
Some(true) => gpui::point(at.x, read.origin().y - gpui::px(12.0)),
}
})
}
/// Right-clicks and waits until `shown` holds for the menu.
fn right_click(cx: &mut VisualTestContext, book: &Entity<Book>, at: Point<Pixels>, shown: fn(&What) -> bool) {
cx.update(|_, app| book.update(app, |book, _| book.menu = None));
cx.simulate_mouse_move(at, None, Modifiers::none());
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
for _ in 0..500 {
cx.run_until_parked();
if cx.update(|_, app| book.read(app).menu.as_ref().is_some_and(|offer| shown(&offer.what))) {
return frames(cx);
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
panic!("no menu");
}
fn on(cx: &mut VisualTestContext, book: &Entity<Book>, pick: Pick) -> bool {
cx.update(|_, app| book.read(app).menu.as_ref().expect("a menu").what.on(pick))
}
fn choose(cx: &mut VisualTestContext, book: &Entity<Book>, pick: Pick) {
cx.update(|window, app| book.update(app, |book, cx| book.choose(pick, window, cx)));
}
/// A column range's cells, empty ones kept.
fn column(cx: &mut TestAppContext, book: &Entity<Book>, range: &str) -> Vec<String> {
cells(cx, book, range).lines().map(|line| line.trim().to_string()).collect()
}
fn cell(what: &What) -> bool {
what.kind == Kind::Cell
}
fn rows(what: &What) -> bool {
what.kind == Kind::Rows
}
fn columns(what: &What) -> bool {
what.kind == Kind::Columns
}
/// A new, selected rectangle over A1.
fn corner_rectangle(cx: &mut TestAppContext, book: &Entity<Book>) {
run(cx, book, "draw.shape", &["rectangle", "true"]);
run(cx, book, "object.x", &["0"]);
run(cx, book, "object.y", &["0"]);
let selected = cx.update(|app| book.read(app).active_tab().expect("a tab").view.read(app).graphic());
assert!(selected.is_some_and(|(x, y, ..)| x < 100 && y < 100), "selected at A1: {selected:?}");
}
/// Protects the sheet, allowing selection and `more`.
fn protect(cx: &mut TestAppContext, book: &Entity<Book>, more: &[&str]) {
let allow: Vec<String> = ["selectLocked", "selectUnlocked"].iter().chain(more).map(|key| key.to_string()).collect();
run(cx, book, "sheet.protect", &[&sheet::protect_json("pw", &allow)]);
}
#[test]
fn right_clicks_open_excel_rows_on_cells_and_headers() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = super::book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
cx.update(|cx| {
ely_gpui_component::init_for_tests(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
});
open(&mut cx, &book);
settle(&cx, |app| book.read(app).active_tab().is_some_and(|tab| tab.extra.geometry.rows.count() > 0));
let window = cx.add_window(|_, _| Host(book.clone()));
let mut shown = VisualTestContext::from_window(window.into(), &cx);
let a2 = spot(&mut shown, &book, 0, 1, None);
right_click(&mut shown, &book, a2, cell);
let cut = shown.debug_bounds("menu-button-0-0").expect("the clipboard row");
let off = cut.origin - a2;
assert!(off.x >= gpui::px(0.0) && off.x < gpui::px(40.0) && off.y < gpui::px(40.0), "at the click: {off:?}");
assert!(on(&mut shown, &book, Pick::RowsBelow) && on(&mut shown, &book, Pick::Clear));
assert!(!on(&mut shown, &book, Pick::Merge), "one cell does not merge");
choose(&mut shown, &book, Pick::RowsBelow);
assert_eq!(column(&mut cx, &book, "A2:A4"), ["a", "", "b"], "a row below A2");
// The read left A2:A4 selected; a click outside it moves.
run(&mut cx, &book, "cell.goto", &["D9"]);
let a2 = spot(&mut shown, &book, 0, 1, None);
right_click(&mut shown, &book, a2, cell);
choose(&mut shown, &book, Pick::Clear);
assert_eq!(column(&mut cx, &book, "A1:A4"), ["Name", "", "", "b"], "cleared");
assert_eq!(cells(&mut cx, &book, "B2"), "3", "one cell only");
let row5 = spot(&mut shown, &book, 0, 4, Some(false));
right_click(&mut shown, &book, row5, rows);
assert!(on(&mut shown, &book, Pick::HideRows) && on(&mut shown, &book, Pick::DeleteRows));
choose(&mut shown, &book, Pick::DeleteRows);
assert_eq!(cells(&mut cx, &book, "A5"), "c", "row 5 went, not row 2");
// A selected rectangle over A1 hides no header menu.
corner_rectangle(&mut cx, &book);
let row1 = spot(&mut shown, &book, 0, 0, Some(false));
right_click(&mut shown, &book, row1, rows);
assert!(on(&mut shown, &book, Pick::RowsAbove) && on(&mut shown, &book, Pick::HideRows));
choose(&mut shown, &book, Pick::RowsAbove);
assert_eq!(cells(&mut cx, &book, "A2"), "Name", "a row above row 1");
corner_rectangle(&mut cx, &book);
let a = spot(&mut shown, &book, 0, 0, Some(true));
right_click(&mut shown, &book, a, columns);
assert!(on(&mut shown, &book, Pick::ColumnsBefore) && on(&mut shown, &book, Pick::DeleteColumns));
protect(&mut cx, &book, &[]);
let headers: [(Point<Pixels>, fn(&What) -> bool, [Pick; 4]); 2] = [
(spot(&mut shown, &book, 0, 2, Some(false)), rows, [Pick::RowsAbove, Pick::DeleteRows, Pick::HideRows, Pick::ShowRows]),
(spot(&mut shown, &book, 1, 0, Some(true)), columns, [Pick::ColumnsBefore, Pick::DeleteColumns, Pick::HideColumns, Pick::ShowColumns]),
];
for (at, shown_as, picks) in headers {
right_click(&mut shown, &book, at, shown_as);
assert!(on(&mut shown, &book, Pick::Copy), "copy stays on");
for pick in picks.into_iter().chain([Pick::Cut, Pick::Clear]) {
assert!(!on(&mut shown, &book, pick), "{pick:?} is off on a protected sheet");
}
}
// Protection that lets rows and columns in.
run(&mut cx, &book, "sheet.protect", &[&sheet::unprotect_json("pw")]);
protect(&mut cx, &book, &["insertRows", "insertColumns"]);
let b = spot(&mut shown, &book, 1, 0, Some(true));
right_click(&mut shown, &book, b, columns);
assert!(on(&mut shown, &book, Pick::ColumnsBefore), "inserting columns is allowed");
assert!(!on(&mut shown, &book, Pick::DeleteColumns), "deleting columns is not");
let row2 = spot(&mut shown, &book, 0, 1, Some(false));
right_click(&mut shown, &book, row2, rows);
assert!(on(&mut shown, &book, Pick::RowsAbove), "inserting rows is allowed");
assert!(!on(&mut shown, &book, Pick::DeleteRows), "deleting rows is not");
choose(&mut shown, &book, Pick::RowsAbove);
// Protected cells read as nothing.
run(&mut cx, &book, "sheet.protect", &[&sheet::unprotect_json("pw")]);
assert_eq!(cells(&mut cx, &book, "A3"), "Name", "a row above row 2");
}
#[test]
fn a_header_keeps_a_whole_selection_that_holds_it() {
let geometry = sheet::geometry(
r#"{"rows":{"sizes":"256:1048575","hidden":"0:1048575"},"columns":{"sizes":"1280:16383","hidden":"0:16383"}}"#,
)
.expect("parses");
// As Calc reports column B, then row 3.
let column = [(1280, 0, 1279, 268435455)];
assert!(held(&column, &geometry, true, 1));
assert!(!held(&column, &geometry, true, 0));
assert!(!held(&column, &geometry, false, 0), "a column is no row");
let row = [(0, 512, 20971519, 255)];
assert!(held(&row, &geometry, false, 2));
assert!(!held(&row, &geometry, true, 0));
let one = [(1280, 256, 1279, 255)];
assert!(!held(&one, &geometry, true, 1), "one cell is no column");
assert!(!held(&[], &geometry, false, 0));
}
/// Starts typing into the selected shape's text.
fn type_in_shape(cx: &mut TestAppContext, book: &Entity<Book>) {
cx.update(|app| {
let book = book.read(app);
let tab = book.active_tab().expect("a tab");
let mut worker = book.core.worker.lock().expect("the worker");
worker.post_key(&tab.doc, 0, 769).expect("F2");
worker.post_key(&tab.doc, 'z' as u32, 537).expect("a key");
});
fresh(cx, book);
let graphic = cx.update(|app| book.read(app).active_tab().expect("a tab").view.read(app).graphic());
assert!(graphic.is_none(), "editing the shape's text: {graphic:?}");
}
#[test]
fn a_header_ends_shape_text_editing() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = super::book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
cx.update(|cx| {
ely_gpui_component::init_for_tests(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
});
open(&mut cx, &book);
fresh(&cx, &book);
let window = cx.add_window(|_, _| Host(book.clone()));
let mut shown = VisualTestContext::from_window(window.into(), &cx);
let headers: [(bool, u32, fn(&What) -> bool, Pick); 2] =
[(false, 2, rows, Pick::RowsAbove), (true, 1, columns, Pick::ColumnsBefore)];
for (column, index, shown_as, insert) in headers {
corner_rectangle(&mut cx, &book);
type_in_shape(&mut cx, &book);
let at = if column { spot(&mut shown, &book, index, 2, Some(true)) } else { spot(&mut shown, &book, 0, index, Some(false)) };
right_click(&mut shown, &book, at, shown_as);
let picked = cx.update(|app| {
let tab = book.read(app).active_tab().expect("a tab");
held(tab.view.read(app).selection(), &tab.extra.geometry, column, index)
});
assert!(picked, "the header's line is selected, column {column}");
assert!(on(&mut shown, &book, insert), "{insert:?} is on");
choose(&mut shown, &book, insert);
}
assert_eq!(cells(&mut cx, &book, "A4"), "b", "a row above row 3");
assert_eq!(cells(&mut cx, &book, "C2"), "3", "a column before B");
let path = dir.path().join("shapes.fods");
cx.update(|app| {
let book = book.read(app);
let mut worker = book.core.worker.lock().expect("the worker");
let doc = &book.active_tab().expect("a tab").doc;
doc.save_as(&mut worker, &ely_lok::file_url(&path), "fods").expect("the save");
});
let xml = std::fs::read_to_string(&path).expect("the save");
assert_eq!(xml.matches(">z</text:p>").count(), 2, "each shape keeps its typed text");
}