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:
1 parent
07c0b43542
commit
87886c8a8d
30 files changed
+1010
-106
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -11,6 +11,7 @@ mod figures;
|
||||
mod format;
|
||||
mod formulas;
|
||||
mod grid;
|
||||
mod menu;
|
||||
mod output;
|
||||
mod page;
|
||||
mod protect;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -395,6 +395,7 @@ fn a_delete_stays_with_its_workbook() {
|
||||
|
||||
mod analysis;
|
||||
mod data;
|
||||
mod menu;
|
||||
mod names;
|
||||
mod numbers;
|
||||
mod output;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user