Ely Office: six offline-first desktop apps in Rust and GPUI

ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 21:20:08 -04:00
commit b7fca0146d
817 files changed
+143101

No files matched your search

+202
View File
@@ -0,0 +1,202 @@
//! The Insert menu: pivot tables and charts (TABLE-10, 11).
use ely_gpui_component::forms::TextInput;
use ely_lok::sheet::{self, Chart, Layout, Pivot, Selection};
use ely_office::prelude::*;
use gpui::{
App, Context, Div, Entity, InteractiveElement as _, Menu, MenuItem, Window,
actions,
};
use crate::book::Book;
use crate::dialog::Dialog;
mod dialogs;
pub use dialogs::{chart_dialog, pivot_dialog};
actions!(table, [MakePivot, RefreshPivot, MakeChart]);
pub fn menu(cx: &App) -> Menu {
let label = |key: &str| Book::text(cx, key, &[]);
Menu::new(label("menu.insert")).items([
MenuItem::action(label("table.pivot.make"), MakePivot),
MenuItem::action(label("table.pivot.refresh"), RefreshPivot),
MenuItem::separator(),
MenuItem::action(label("table.chart.make"), MakeChart),
])
}
pub fn bind(root: Div, cx: &mut Context<Book>) -> Div {
root.on_action(cx.listener(|book, _: &MakePivot, _, cx| book.ask(Asked::Pivot, cx)))
.on_action(cx.listener(|book, _: &RefreshPivot, _, cx| book.ask(Asked::Refresh, cx)))
.on_action(cx.listener(|book, _: &MakeChart, _, cx| book.ask(Asked::Chart, cx)))
}
#[derive(Clone, Copy, Debug)]
pub(crate) enum Asked {
Pivot,
Refresh,
Chart,
}
/// A chart dialog's text fields.
pub struct ChartFields {
pub range: Entity<TextInput>,
pub look: ely_office::chart::LookFields,
}
/// What the kernel holds where the user asked.
enum Found {
Pivot(Option<Pivot>),
Chart(Option<Chart>),
}
impl Book {
pub(crate) fn ask(&mut self, asked: Asked, cx: &mut Context<Self>) {
let Some(id) = self.core.active else {
return self.warn_key("command.blocked.no_document", cx);
};
self.ask_here(id, asked, cx);
}
/// Reads the pivot table or chart here, after input.
fn ask_here(&mut self, id: u64, asked: Asked, cx: &mut Context<Self>) {
if !self.settled(id, cx) {
return self.when_settled(id, move |book, _, cx| book.ask_here(id, asked, cx));
}
self.ask_in(
id,
move |worker, doc| match asked {
Asked::Chart => Ok(Found::Chart(sheet::chart(
&worker.values(doc, sheet::CHART)?,
)?)),
_ => Ok(Found::Pivot(sheet::pivot(
&worker.values(doc, sheet::PIVOT)?,
)?)),
},
move |book, read, window, cx| match (asked, read) {
(_, Err(error)) => book.engine_failed(&error.to_string(), cx),
(Asked::Refresh, Ok(Found::Pivot(Some(_)))) => {
book.run_in(Some(id), "pivot.refresh", &[], cx)
}
(Asked::Refresh, Ok(_)) => book.warn_key("table.pivot.none", cx),
(_, Ok(Found::Pivot(Some(pivot)))) => {
book.dialog = Some(Dialog::Pivot {
tab: id,
source: pivot.source,
fields: pivot.fields,
layout: pivot.layout,
editing: true,
});
cx.notify();
}
(_, Ok(Found::Chart(Some(chart)))) => {
book.chart_dialog(id, Some(chart), "", window, cx)
}
(_, Ok(_)) => book.data_of(
id,
true,
move |book, picked, _, window, cx| book.new_dialog(id, asked, picked, window, cx),
cx,
),
},
cx,
);
}
/// A create dialog over the picked data.
fn new_dialog(
&mut self,
id: u64,
asked: Asked,
picked: Selection,
window: &mut Window,
cx: &mut Context<Self>,
) {
log::info!("table: {asked:?} from {}", picked.range);
if let Asked::Chart = asked {
return self.chart_dialog(id, None, &picked.data, window, cx);
}
if picked.top.iter().any(String::is_empty) {
return self.warn_key("table.pivot.headers", cx);
}
self.dialog = Some(Dialog::Pivot {
tab: id,
source: picked.data,
fields: picked.top,
layout: Layout::default(),
editing: false,
});
cx.notify();
}
fn chart_dialog(
&mut self,
id: u64,
chart: Option<Chart>,
range: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let look = chart
.as_ref()
.map_or_else(ely_office::chart::fresh, |chart| chart.look.clone());
let range = chart.as_ref().map_or(range, |chart| &chart.range);
let fields = ChartFields {
range: ely_office::chart::field(range, "table.chart.range", window, cx),
look: ely_office::chart::LookFields::new(&look, window, cx),
};
self.dialog = Some(Dialog::Chart {
tab: id,
chart: chart.map(Box::new),
look: Box::new(look),
fields: Box::new(fields),
});
cx.notify();
}
/// Makes or changes the pivot table the dialog describes.
pub(crate) fn pivot_asked(&mut self, cx: &mut Context<Self>) {
let Some(Dialog::Pivot {
tab,
source,
layout,
editing,
..
}) = self.dialog.take()
else {
return;
};
let source = if editing { "" } else { source.as_str() };
self.run_in(Some(tab), "pivot.make", &[&layout.to_json(source, "")], cx);
}
/// Inserts or changes the chart the dialog describes.
pub(crate) fn chart_asked(&mut self, cx: &mut Context<Self>) {
let Some(Dialog::Chart {
tab,
chart,
look,
fields,
}) = self.dialog.take()
else {
return;
};
let look = fields.look.read(&look, cx).to_json();
let range = fields.range.read(cx).text().trim().to_string();
match chart {
Some(chart) => {
let headers = |on: bool| if on { "true" } else { "false" };
let values = [
chart.name.as_str(),
&range,
headers(chart.col_headers),
headers(chart.row_headers),
&look,
];
self.run_in(Some(tab), "chart.change", &values, cx)
}
None => self.run_in(Some(tab), "chart.insert", &[&range, "false", &look], cx),
}
}
}
+202
View File
@@ -0,0 +1,202 @@
//! The pivot table and chart dialogs.
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
forms::{Choice, Input, Select},
layout::ScrollArea,
overlays::Dialog as Card,
};
use ely_lok::sheet::{Chart, Layout, Look, PIVOT_FUNCTIONS};
use gpui::{App, Entity, ParentElement as _, Styled as _, Window, div};
use super::ChartFields;
use crate::book::Book;
use crate::dialog::{Dialog, edit};
/// Where a field goes; empty for nowhere.
const ROLES: [(&str, &str); 5] = [
("", "table.pivot.unused"),
("rows", "table.pivot.rows"),
("columns", "table.pivot.columns"),
("pages", "table.pivot.pages"),
("data", "table.pivot.data"),
];
fn role_of(layout: &Layout, field: &str) -> &'static str {
let has = |list: &[String]| list.iter().any(|name| name == field);
if has(&layout.rows) {
"rows"
} else if has(&layout.columns) {
"columns"
} else if has(&layout.pages) {
"pages"
} else if layout.data.iter().any(|(name, _)| name == field) {
"data"
} else {
""
}
}
/// Moves `field` to the end of `role`'s list.
fn place(layout: &mut Layout, field: &str, role: &str) {
for list in [&mut layout.rows, &mut layout.columns, &mut layout.pages] {
list.retain(|name| name != field);
}
layout.data.retain(|(name, _)| name != field);
let field = field.to_string();
match role {
"rows" => layout.rows.push(field),
"columns" => layout.columns.push(field),
"pages" => layout.pages.push(field),
"data" => layout.data.push((field, "sum".into())),
_ => {}
}
}
fn layout_of(dialog: &mut Dialog) -> &mut Layout {
match dialog {
Dialog::Pivot { layout, .. } => layout,
_ => unreachable!("a pivot dialog"),
}
}
/// One field's area and, for values, its function.
fn field_row(this: &Entity<Book>, at: usize, field: &str, layout: &Layout, cx: &App) -> gpui::Div {
let label = |key: &str| Book::text(cx, key, &[]);
let role = role_of(layout, field);
let (picking, name) = (this.clone(), field.to_string());
let roles = Select::new(
("table-pivot-role", at),
ROLES
.iter()
.map(|(value, key)| Choice::new(*value, label(key))),
)
.label(field.to_string())
.selected(role.to_string())
.on_change(move |value, _, cx| {
let value = value.to_string();
edit(&picking, cx, |dialog, _, _| {
place(layout_of(dialog), &name, &value)
})
});
let mut row = div().flex().gap_2().child(div().w_full().max_w_32().child(roles));
if let Some((_, function)) = layout.data.iter().find(|(name, _)| name == field) {
let mut functions: Vec<&str> = PIVOT_FUNCTIONS.to_vec();
// The kernel's own function stays until changed.
if !functions.contains(&function.as_str()) {
functions.push("other");
}
let (picking, name) = (this.clone(), field.to_string());
let shown = if PIVOT_FUNCTIONS.contains(&function.as_str()) {
function.clone()
} else {
"other".into()
};
let choices = functions
.into_iter()
.map(|value| Choice::new(value, label(&format!("table.pivot.{value}"))));
row = row.child(
div().w_full().max_w_32().child(
Select::new(("table-pivot-function", at), choices)
.label(Book::text(
cx,
"table.pivot.function_of",
&[("field", field)],
))
.selected(shown)
.on_change(move |value, _, cx| {
let value = value.to_string();
edit(&picking, cx, |dialog, _, _| {
let data = &mut layout_of(dialog).data;
if let Some(entry) = data.iter_mut().find(|(field, _)| *field == name) {
entry.1 = value;
}
})
}),
),
);
}
row
}
pub fn pivot_dialog(
this: Entity<Book>,
(source, fields, layout, editing): (&str, &[String], &Layout, bool),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let label = |key: &str| Book::text(cx, key, &[]);
let rows = fields
.iter()
.enumerate()
.fold(DescriptionList::new(), |rows, (at, field)| {
rows.item(field.clone(), field_row(&this, at, field, layout, cx))
});
let go = label(if editing {
"table.pivot.apply"
} else {
"table.pivot.go"
});
let off = layout.data.is_empty() || layout.data.iter().any(|(_, f)| f == "other");
Card::new("table-pivot", label("table.pivot.title"), dismiss)
.detail(Book::text(cx, "table.pivot.source", &[("range", source)]))
.child(
div().max_h_96().child(
ScrollArea::new("table-pivot-scroll")
.size_full()
.child(rows),
),
)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("table-pivot-go", go)
.variant(ButtonVariant::Primary)
.disabled(off)
.on_click(move |_, _, cx| this.update(cx, |book, cx| book.pivot_asked(cx)))
})
}
fn look_of(dialog: &mut Dialog) -> &mut Look {
match dialog {
Dialog::Chart { look, .. } => look,
_ => unreachable!("a chart dialog"),
}
}
pub fn chart_dialog(
this: Entity<Book>,
(chart, look, fields): (Option<&Chart>, &Look, &ChartFields),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let label = |key: &str| Book::text(cx, key, &[]);
let (picking, toggling) = (this.clone(), this.clone());
let range = DescriptionList::new().item(
label("table.chart.range"),
div().w_full().max_w_48().child(Input::new(&fields.range)),
);
let (rows, legend) = ely_office::chart::look_rows(
"table-chart",
range,
(look, &fields.look),
move |kind, cx| edit(&picking, cx, |dialog, _, _| look_of(dialog).kind = kind),
move |on, cx| edit(&toggling, cx, |dialog, _, _| look_of(dialog).legend = on),
cx,
);
let (title, go) = if chart.is_some() {
("office.chart.edit_title", "office.chart.apply")
} else {
("office.chart.insert_title", "office.chart.go")
};
let go = label(go);
Card::new("table-chart", label(title), dismiss)
.child(rows)
.child(legend)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("table-chart-go", go)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| this.update(cx, |book, cx| book.chart_asked(cx)))
})
}
+148
View File
@@ -0,0 +1,148 @@
//! The name box and the formula bar (TABLE-03).
use ely_gpui_component::{
forms::{Input, InputEvent, TextInput},
theme::{ActiveTheme as _, ControlSize},
};
use ely_lok::LokView;
use ely_office::prelude::*;
use gpui::{
AnyElement, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
KeyDownEvent, ParentElement as _, Styled as _, Window, div,
};
use crate::book::Book;
pub struct Bars {
pub name: Entity<TextInput>,
pub formula: Entity<TextInput>,
/// Tab, address and content the bars last took.
shown: Option<(u64, String, String)>,
/// A bar the user is typing in keeps its text.
editing: Option<Bar>,
}
#[derive(Clone, Copy, PartialEq)]
enum Bar {
Name,
Formula,
}
impl Bars {
pub fn new(window: &mut Window, cx: &mut Context<Book>) -> Bars {
let (name, formula) = (
Book::text(cx, "table.name_box", &[]),
Book::text(cx, "table.formula", &[]),
);
let name = cx.new(|cx| TextInput::new(window, cx).label(name));
let formula = cx.new(|cx| TextInput::new(window, cx).label(formula));
for (input, bar) in [(&name, Bar::Name), (&formula, Bar::Formula)] {
cx.subscribe_in(
input,
window,
move |book, input, event, window, cx| match event {
InputEvent::Focus => book.bars.editing = Some(bar),
InputEvent::Blur => {
book.bars.editing = None;
book.bars.shown = None;
book.refill(cx);
}
InputEvent::Submit => {
let text = input.read(cx).text().to_string();
book.submit(bar, text, window, cx);
}
InputEvent::Changed => {}
},
)
.detach();
}
Bars {
name,
formula,
shown: None,
editing: None,
}
}
pub fn render(&self, cx: &mut Context<Book>) -> AnyElement {
div()
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.border_b_1()
.border_color(cx.theme().colors.border)
.on_key_down(cx.listener(|book, event: &KeyDownEvent, window, cx| {
if event.keystroke.key == "escape" {
book.leave_bars(window, cx);
}
}))
.child(
div()
.id("table-name-box")
.w_32()
.child(Input::new(&self.name).size(ControlSize::Sm)),
)
.child(
div()
.id("table-formula")
.flex_1()
.child(Input::new(&self.formula).size(ControlSize::Sm)),
)
.into_any_element()
}
}
impl Book {
/// Shows the active cell, except in a bar being typed.
pub fn sync_bars(&mut self, view: &Entity<LokView>, cx: &mut Context<Self>) {
let Some(id) = self.core.active else {
return;
};
let cell = view.read(cx).cell();
let key = (id, cell.address.clone(), cell.formula.clone());
if self.bars.shown.as_ref() == Some(&key) {
return;
}
let editing = self.bars.editing;
if editing != Some(Bar::Name) {
self.bars
.name
.update(cx, |input, cx| input.set_text(key.1.clone(), cx));
}
if editing != Some(Bar::Formula) {
self.bars
.formula
.update(cx, |input, cx| input.set_text(key.2.clone(), cx));
}
self.bars.shown = Some(key);
}
/// Shows the active tab's cell again.
pub(crate) fn refill(&mut self, cx: &mut Context<Self>) {
if let Some(view) = self.active_tab().map(|tab| tab.view.clone()) {
self.sync_bars(&view, cx);
}
}
/// Enter goes to the address or enters the content.
fn submit(&mut self, bar: Bar, text: String, window: &mut Window, cx: &mut Context<Self>) {
match bar {
Bar::Name => self.run_active("cell.goto", &[&text], cx),
Bar::Formula => self.run_active("cell.enter", &[&text], cx),
}
self.leave_bars(window, cx);
}
/// Escape or Enter hands the keys back to the grid.
fn leave_bars(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.bars.editing = None;
self.bars.shown = None;
self.refill(cx);
if let Some(tab) = self.active_tab() {
let focus = tab.view.read(cx).focus_handle().clone();
window.focus(&focus, cx);
}
}
}
+419
View File
@@ -0,0 +1,419 @@
//! ElyTable's window: workbooks over one LOK worker (§9.1).
use std::{
path::PathBuf,
sync::{Arc, Mutex},
};
use ely_gpui_component::{
feedback::{Banner, EmptyState},
primitives::{IconName, Severity},
shell::{StatusBar, StatusBarItem},
};
use ely_lok::{LokView, Sheets, Worker, sheet::Geometry};
use ely_office::{Core, Kind, prelude::*};
use ely_platform::AppId;
use ely_ui::{Root, Shell, commands};
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, AnyView, App, AppContext as _, Context, Entity, InteractiveElement as _,
IntoElement, KeyBinding, ParentElement as _, Render, SharedString, Styled as _, Window, div,
};
use crate::bars::Bars;
use crate::format::{FormatPanel, Panel};
use crate::grid::Drag;
/// A cell: sheet count, address, edit count.
pub type Spot = (u64, String, u64);
/// What ElyTable keeps per workbook.
#[derive(Default)]
pub struct Extra {
pub sheets: Sheets,
/// The document as the last read saw it.
pub read_at: Option<ely_lok::Doc>,
pub geometry: Geometry,
/// The view's sheet and header counts at the last read.
pub read: Option<(u64, u64)>,
/// A sheet read is on its way.
pub reading: bool,
/// The kernel requests already taken.
pub requests: u64,
/// The cell the rule belongs to, and the rule.
pub rule: Option<(Spot, ely_lok::sheet::Validation)>,
/// The cell whose rule was last asked for.
pub ruled: Option<Spot>,
/// A rule read is on its way.
pub ruling: bool,
/// Linked sources shown as saved.
pub links: Vec<String>,
/// The edit count the links were last asked at.
pub linked: Option<u64>,
/// A links read is on its way.
pub linking: bool,
}
pub struct Book {
pub core: Core<Book>,
pub dialog: Option<crate::dialog::Dialog>,
pub bars: Bars,
pub drag: Option<Drag>,
/// The sheet menu's request: number and place.
pub sheet_menu: Option<(u64, gpui::Point<gpui::Pixels>)>,
pub panel: Entity<Panel>,
pub panel_shown: bool,
pub choices: Option<crate::rules::Choices>,
/// Read text files waiting for the import dialog.
pub imports: std::collections::VecDeque<crate::text::Pending>,
}
impl Host for Book {
type Extra = Extra;
type Engine = ely_office::Lok;
const KIND: Kind = Kind {
app: AppId::Table,
kept: &["xlsx", "ods", "xls", "fods"],
save_as: &["xlsx", "ods"],
macros: Some(&["xlsm", "xltm"]),
};
fn core(&self) -> &Core<Self> {
&self.core
}
fn core_mut(&mut self) -> &mut Core<Self> {
&mut self.core
}
fn read_at(&self, tab: u64, _: &gpui::App) -> Option<ely_lok::Doc> {
self.tab(tab).and_then(|tab| tab.extra.read_at.clone())
}
fn observed(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
self.reread(id, view, cx);
self.freeze(id, view, cx);
self.found(id, view, cx);
self.requested(id, view, cx);
self.follow_rule(id, view, cx);
self.read_links(id, view, cx);
if self.core.active == Some(id) {
self.sync_bars(view, cx);
}
}
}
/// The window root with a blank workbook.
pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
ely_lok::init(cx);
let (worker, work) = match ely_office::start(app, cx) {
Ok(started) => started,
Err(error) => ely_platform::startup::fail(app, &format!("{error}")),
};
let book = cx.new(|cx| {
let mut host = Book::new(worker, work, window, cx);
// Recovery points, crash return, autosave (§6.2, §6.3).
ely_office::Recovering::start_recovery(&mut host, cx);
<Book as ely_office::Autosaving>::start_autosave(cx);
host
});
cx.bind_keys([
KeyBinding::new(
"secondary-f",
ely_office::FindReplace,
Some(commands::CONTEXT),
),
KeyBinding::new(
"secondary-shift-v",
crate::data::PasteValues,
Some(commands::CONTEXT),
),
]);
ely_office::bind_zoom_keys(cx);
let menus = vec![
gpui::Menu::new(ely_ui::i18n::text(cx, "menu.view", &[])).items(ely_office::zoom_items(false, cx)),
crate::sheets::menu(cx),
crate::cells::menu(cx),
crate::analysis::menu(cx),
crate::format::menu(cx),
crate::formulas::menu(cx),
crate::data::menu(cx),
crate::page::menu(cx),
];
let root = ely_ui::document_root(app, book.clone().into(), &ely_ui::commands::FILE, menus, window, cx);
book.update(cx, |book, cx| {
book.core.root = root.downgrade();
book.core.window = Some(window.window_handle());
window.focus(&book.core.focus, cx);
});
let launched = ely_ui::launch_files(cx);
book.update(cx, |book, cx| match launched.is_empty() {
true => book.new_blank(cx),
false => launched.into_iter().for_each(|path| book.open_asked(path, cx)),
});
let opening = book.clone();
ely_ui::on_open(cx, move |path, cx| {
opening.update(cx, |book, cx| book.open_asked(path, cx))
});
let closing = book.clone();
window.on_window_should_close(cx, move |window, cx| {
closing.update(cx, |book, cx| book.should_close(window, cx))
});
root
}
impl Book {
pub fn new(
worker: Arc<Mutex<Worker>>,
work: PathBuf,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let book = cx.weak_entity();
let panel = cx.new(|cx| Panel::new(book, Arc::clone(&worker), window, cx));
// A closed dialog lets the next import ask.
cx.observe_self(|book, cx| book.next_import(cx)).detach();
Book {
core: Core::new(worker, work, cx),
dialog: None,
bars: Bars::new(window, cx),
drag: None,
sheet_menu: None,
panel,
panel_shown: true,
choices: None,
imports: Default::default(),
}
}
pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString {
ely_ui::i18n::text(cx, key, args)
}
/// A new untitled workbook (TABLE-01).
pub fn new_blank(&mut self, cx: &mut Context<Self>) {
self.new_from_text("blank.fods", &crate::template::blank(cx), cx);
}
/// Rereads sheets and geometry when the kernel moves them.
fn reread(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
let cell = view.read(cx).cell();
let seen = (cell.sheets, cell.headers);
let Some(tab) = self.tab(id) else {
return;
};
// Numbered commands need the list read at the latest revision.
let behind = tab.extra.read_at.as_ref().is_some_and(|at| view.read(cx).read_at().newer_than(at));
if tab.extra.reading || tab.extra.read == Some(seen) && !behind {
return;
}
let doc = tab.doc.clone();
// Marked read now: a failure waits for the next change.
let extra = &mut self.tab_mut(id).expect("the tab is open").extra;
(extra.reading, extra.read) = (true, Some(seen));
ely_lok::read_off(
&self.core.worker,
&doc,
|worker, doc| {
let at = worker.now(doc)?;
let sheets = doc.sheets(worker)?;
let json = worker.values(doc, ely_lok::sheet::GEOMETRY)?;
Ok((sheets, ely_lok::sheet::geometry(&json)?, at))
},
move |book: &mut Self, read, cx| {
let Some(tab) = book.tab_mut(id) else {
return;
};
tab.extra.reading = false;
let view = tab.view.clone();
match read {
Ok((sheets, geometry, at)) => {
log::info!("table: tab {id} has {} sheets", sheets.list.len());
view.update(cx, |view, _| view.set_part(sheets.current));
let extra = &mut tab.extra;
extra.sheets = sheets;
extra.geometry = geometry;
extra.read_at = Some(at);
}
Err(error) => book.engine_failed(&error.to_string(), cx),
}
cx.notify();
// The kernel may have moved again meanwhile.
book.reread(id, &view, cx);
},
cx,
);
}
/// Rereads the linked sources after each edit.
fn read_links(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
let edited = view.read(cx).edited();
let Some(tab) = self.tab_mut(id) else {
return;
};
if tab.extra.linking || tab.extra.linked == Some(edited) {
return;
}
(tab.extra.linking, tab.extra.linked) = (true, Some(edited));
let doc = tab.doc.clone();
ely_lok::read_off(
&self.core.worker,
&doc,
|worker, doc| ely_lok::sheet::links(&worker.values(doc, ely_lok::sheet::LINKS)?),
move |book: &mut Self, read, cx| {
let Some(tab) = book.tab_mut(id) else {
return;
};
tab.extra.linking = false;
let view = tab.view.clone();
match read {
Ok(links) => {
log::info!("table: tab {id} links {} sources", links.len());
tab.extra.links = links;
}
Err(error) => book.engine_failed(&error.to_string(), cx),
}
cx.notify();
// An edit may have landed meanwhile.
book.read_links(id, &view, cx);
},
cx,
);
}
/// Linked values stay as saved; the banner says so (§9.3).
fn links_banner(&self, cx: &mut Context<Self>) -> Option<Banner> {
let links = &self.active_tab()?.extra.links;
if links.is_empty() {
return None;
}
let sources = links.join(", ");
let text = Self::text(cx, "table.links.stale", &[("sources", &sources)]);
Some(Banner::new("table-links", Severity::Info, text))
}
/// Holds the kernel's frozen rows and columns in place.
fn freeze(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
let Some(tab) = self.tab(id) else {
return;
};
let read = view.read(cx);
let index = |uno: &str| frozen_index(read.state(uno));
let geometry = &tab.extra.geometry;
let frozen = (
index(".uno:FreezePanesColumn").map_or(0, |at| geometry.columns.start(at)),
index(".uno:FreezePanesRow").map_or(0, |at| geometry.rows.start(at)),
);
view.update(cx, |view, cx| view.set_frozen(frozen, cx));
}
/// The save state, and the kernel's statistics of the selection.
fn status(&self, cx: &mut Context<Self>) -> StatusBar {
let state = self.save_state(cx);
let mut status = StatusBar::new().left(StatusBarItem::new("table-state").label(state));
if self.automatic(cx) == Some(false) {
let manual = Self::text(cx, "table.calc.manual", &[]);
status = status.left(StatusBarItem::new("table-manual").label(manual));
}
let figures = self
.active_tab()
.and_then(|tab| tab.view.read(cx).state(".uno:StateTableCell"))
.filter(|figures| !figures.is_empty())
.map(|figures| crate::figures::label(figures, cx));
let status = match figures {
Some(figures) => status.right(StatusBarItem::new("table-figures").label(figures)),
None => status,
};
status.when_some(self.zoom_control(cx), |status, zoom| status.right(zoom))
}
fn body(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
if self.active_tab().is_none() {
let title = match self.core.loading.first() {
Some((_, name)) => Self::text(cx, "office.loading", &[("name", name)]),
None => Self::text(cx, "command.blocked.no_document", &[]),
};
return EmptyState::new("table-empty", IconName::Table, title).into_any_element();
}
let risk = self.stopped_banner(cx).or_else(|| self.risk_banner(cx));
div()
.size_full()
.flex()
.flex_col()
.children(risk)
.children(self.links_banner(cx))
.child(self.bars.render(cx))
.child(self.grid(window, cx))
.child(self.sheet_strip(cx))
.into_any_element()
}
}
impl Render for Book {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// Another workbook may have become active.
self.refill(cx);
let title = self
.active_tab()
.map_or_else(|| AppId::Table.name().into(), |tab| tab.name.clone());
let shell = Shell::new(title, &self.core.focus)
.tabs(self.tab_strip(cx))
.right(self.side_view(cx))
.status(self.status(cx))
.child(self.body(window, cx));
let root = div()
.size_full()
.key_context(commands::CONTEXT)
.track_focus(&self.core.focus)
.on_action(cx.listener(|book, _: &commands::New, _, cx| book.new_blank(cx)))
.on_action(cx.listener(Self::on_open_file))
.on_action(cx.listener(Self::on_save))
.on_action(cx.listener(Self::on_save_as))
.on_action(cx.listener(Self::on_quit))
.on_action(cx.listener(Self::on_close_tab))
.on_action(cx.listener(Self::on_export))
.on_action(cx.listener(Self::on_print))
.child(shell)
.children(self.core_dialog(cx))
.children(self.dialog_element(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);
crate::analysis::bind(crate::rules::bind(root, cx), cx)
.on_action(cx.listener(Self::on_find))
.on_action(cx.listener(|book, _: &FormatPanel, _, cx| {
book.panel_shown = !book.panel_shown;
cx.notify();
}))
}
}
impl Book {
/// The format panel for the active workbook, while shown.
fn side_view(&mut self, cx: &mut Context<Self>) -> Option<AnyView> {
let shown = self
.active_tab()
.map(|tab| (tab.view.clone(), tab.doc.clone()));
let tab = shown.as_ref().map(|(view, doc)| (view, doc));
self.panel.update(cx, |panel, cx| panel.show(tab, cx));
(shown.is_some() && self.panel_shown).then(|| self.panel.clone().into())
}
}
/// The first unfrozen index; None for none.
fn frozen_index(state: Option<&str>) -> Option<u32> {
let index: u32 = state?.split('/').next()?.trim().parse().ok()?;
(index > 0).then_some(index)
}
#[cfg(test)]
mod tests {
#[test]
fn freeze_states_read_their_index() {
assert_eq!(super::frozen_index(Some("2 / 0")), Some(2));
assert_eq!(super::frozen_index(Some("0 / 1")), None);
assert_eq!(super::frozen_index(None), None);
}
}
impl Zooming for Book {}
+255
View File
@@ -0,0 +1,255 @@
//! The Cells menu: rows, columns, merges, sizes, freeze (TABLE-02).
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
forms::TextInput,
overlays::{Dialog as Card, PromptDialog},
};
use ely_office::prelude::*;
use gpui::{
App, AppContext as _, Context, Div, Entity, InteractiveElement as _, Menu, MenuItem, Window,
actions,
};
use crate::book::Book;
use crate::dialog::Dialog;
actions!(
table,
[
RowsAbove,
RowsBelow,
ColumnsBefore,
ColumnsAfter,
InsertDown,
InsertRight,
DeleteRows,
DeleteColumns,
DeleteUp,
DeleteLeft,
Merge,
Split,
RowHeight,
ColumnWidth,
FitRows,
FitColumns,
HideRows,
ShowRows,
HideColumns,
ShowColumns,
Freeze
]
);
pub fn menu(cx: &App) -> Menu {
let label = |key: &str| Book::text(cx, key, &[]);
Menu::new(label("menu.cells")).items([
MenuItem::action(label("table.cells.rows_above"), RowsAbove),
MenuItem::action(label("table.cells.rows_below"), RowsBelow),
MenuItem::action(label("table.cells.columns_before"), ColumnsBefore),
MenuItem::action(label("table.cells.columns_after"), ColumnsAfter),
MenuItem::action(label("table.cells.insert_down"), InsertDown),
MenuItem::action(label("table.cells.insert_right"), InsertRight),
MenuItem::separator(),
MenuItem::action(label("table.cells.delete_rows"), DeleteRows),
MenuItem::action(label("table.cells.delete_columns"), DeleteColumns),
MenuItem::action(label("table.cells.delete_up"), DeleteUp),
MenuItem::action(label("table.cells.delete_left"), DeleteLeft),
MenuItem::separator(),
MenuItem::action(label("table.cells.merge"), Merge),
MenuItem::action(label("table.cells.split"), Split),
MenuItem::separator(),
MenuItem::action(label("table.cells.row_height"), RowHeight),
MenuItem::action(label("table.cells.column_width"), ColumnWidth),
MenuItem::action(label("table.cells.fit_rows"), FitRows),
MenuItem::action(label("table.cells.fit_columns"), FitColumns),
MenuItem::separator(),
MenuItem::action(label("table.cells.hide_rows"), HideRows),
MenuItem::action(label("table.cells.show_rows"), ShowRows),
MenuItem::action(label("table.cells.hide_columns"), HideColumns),
MenuItem::action(label("table.cells.show_columns"), ShowColumns),
MenuItem::separator(),
MenuItem::action(label("table.cells.freeze"), Freeze),
])
}
/// Binds an action to a command with fixed values.
macro_rules! run {
($root:expr, $cx:expr, $action:ty, $id:literal $(, $value:literal)*) => {
$root.on_action($cx.listener(|book, _: &$action, _, cx| {
book.run_active($id, &[$($value),*], cx)
}))
};
}
pub fn bind(root: Div, cx: &mut Context<Book>) -> Div {
let root = run!(root, cx, RowsAbove, "rows.insert_above");
let root = run!(root, cx, RowsBelow, "rows.insert_below");
let root = run!(root, cx, ColumnsBefore, "columns.insert_before");
let root = run!(root, cx, ColumnsAfter, "columns.insert_after");
let root = run!(root, cx, InsertDown, "cells.insert", "V");
let root = run!(root, cx, InsertRight, "cells.insert", ">");
let root = run!(root, cx, DeleteRows, "rows.delete");
let root = run!(root, cx, DeleteColumns, "columns.delete");
let root = run!(root, cx, DeleteUp, "cells.delete", "U");
let root = run!(root, cx, DeleteLeft, "cells.delete", "L");
let root = run!(root, cx, Split, "cells.split");
let root = run!(root, cx, FitRows, "rows.optimal", "0");
let root = run!(root, cx, FitColumns, "columns.optimal", "0");
let root = run!(root, cx, HideRows, "rows.hide");
let root = run!(root, cx, ShowRows, "rows.show");
let root = run!(root, cx, HideColumns, "columns.hide");
let root = run!(root, cx, ShowColumns, "columns.show");
let root = run!(root, cx, Freeze, "view.freeze");
root.on_action(cx.listener(|book, _: &Merge, _, cx| book.merge(book.core.active, cx)))
.on_action(cx.listener(|book, _: &RowHeight, window, cx| book.ask_size(false, window, cx)))
.on_action(cx.listener(|book, _: &ColumnWidth, window, cx| book.ask_size(true, window, cx)))
}
impl Book {
/// Merges; asks first when more than one cell holds data.
pub(crate) fn merge(&mut self, id: Option<u64>, cx: &mut Context<Self>) {
let Some(tab) = id.and_then(|id| self.tab(id)) else {
return self.run_in(id, "cells.merge", &["false"], cx);
};
if !self.settled(tab.id, cx) {
return self.when_settled(tab.id, move |book, _, cx| book.merge(id, cx));
}
let tab = tab.id;
self.ask_in(
tab,
|worker, doc| doc.selection_text(worker),
move |book, text, _, cx| match text {
Ok(text) if filled(&text) > 1 => {
book.dialog = Some(Dialog::Merge { tab });
cx.notify();
}
Ok(_) => book.run_in(id, "cells.merge", &["false"], cx),
Err(error) => book.engine_failed(&error.to_string(), cx),
},
cx,
);
}
/// Sets the asked size, in 1/100 mm, in its workbook.
pub(crate) fn size_asked(&mut self, size: i32, cx: &mut Context<Self>) {
let Some(Dialog::Size { tab, column, .. }) = self.dialog.take() else {
return;
};
let id = if column {
"columns.width_selected"
} else {
"rows.height_selected"
};
self.run_in(Some(tab), id, &[&size.to_string()], cx);
}
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);
};
let field = cx.new(|cx| TextInput::new(window, cx));
self.dialog = Some(Dialog::Size { tab, column, field });
cx.notify();
}
}
/// How many cells of a tab-joined selection hold something.
fn filled(text: &str) -> usize {
text.lines()
.flat_map(|line| line.split('\t'))
.filter(|cell| !cell.is_empty())
.count()
}
/// Centimeters as 1/100 mm, within what the kernel takes.
fn hundredths(text: &str) -> Option<i32> {
let centimeters: f64 = text.trim().replace(',', ".").parse().ok()?;
let hundredths = (centimeters * 1000.0).round();
(1.0..=65535.0)
.contains(&hundredths)
.then_some(hundredths as i32)
}
pub fn size_dialog(
this: Entity<Book>,
(column, field): (bool, &Entity<TextInput>),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> PromptDialog {
let title = Book::text(
cx,
if column {
"table.size.column"
} else {
"table.size.row"
},
&[],
);
let bad = Book::text(cx, "table.size.bad", &[]);
PromptDialog::new("table-size", title, field, dismiss)
.label(Book::text(cx, "table.size.label", &[]))
.submit(Book::text(cx, "table.size.apply", &[]))
.check(move |text| hundredths(text).map(|_| ()).ok_or_else(|| bad.clone()))
.on_submit(move |text, _, cx| {
let size = hundredths(text).expect("checked");
this.update(cx, |book, cx| book.size_asked(size, cx))
})
}
pub fn merge_dialog(
this: Entity<Book>,
tab: u64,
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let choice = |id: &'static str, key: &str, moves: &'static str, variant| {
let (this, label) = (this.clone(), Book::text(cx, key, &[]));
move |_| {
Button::new(id, label)
.variant(variant)
.on_click(move |_, _, cx| {
this.update(cx, |book, cx| {
book.dialog = None;
book.run_in(Some(tab), "cells.merge", &[moves], cx);
})
})
}
};
Card::new(
"table-merge",
Book::text(cx, "table.merge.title", &[]),
dismiss,
)
.detail(Book::text(cx, "table.merge.detail", &[]))
.action(ely_office::cancel(cx))
.action(choice(
"table-merge-keep",
"table.merge.keep",
"false",
ButtonVariant::Secondary,
))
.action(choice(
"table-merge-move",
"table.merge.move",
"true",
ButtonVariant::Primary,
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filled_cells_and_sizes() {
assert_eq!(filled("1\tb\n\t\n"), 2);
assert_eq!(filled("\t\n"), 0);
assert_eq!(hundredths("2.54"), Some(2540));
assert_eq!(hundredths("1,5"), Some(1500));
assert_eq!(hundredths("0"), None);
assert_eq!(hundredths("70"), None, "past 65.535 cm");
assert_eq!(hundredths("x"), None);
}
}
+487
View File
@@ -0,0 +1,487 @@
//! The Data menu: sort, filter, duplicates, columns, pastes (TABLE-08).
use ely_gpui_component::{
forms::TextInput,
primitives::Severity,
tables::{FilterRule, SortKey, Test},
};
use ely_lok::{
Request,
sheet::{self, Entry, Selection},
};
use ely_office::prelude::*;
use ely_platform::clipboard::{self, Clip};
use gpui::{
App, AppContext as _, Context, Div, InteractiveElement as _, Menu, MenuItem, SharedString,
Window, actions,
};
use crate::book::Book;
use crate::dialog::Dialog;
/// The state a selection was read in, and its sheet.
pub(crate) type ReadState = (ely_lok::Doc, usize);
mod dialogs;
pub use dialogs::{column_dialog, columns_dialog, dedupe_dialog, filter_dialog, sort_dialog};
actions!(
table,
[
Sort,
AutoFilter,
StandardFilter,
ClearFilter,
RemoveDuplicates,
TextToColumns,
PasteValues,
PasteFormulas
]
);
pub fn menu(cx: &App) -> Menu {
let label = |key: &str| Book::text(cx, key, &[]);
Menu::new(label("menu.data")).items([
MenuItem::action(label("table.data.sort"), Sort),
MenuItem::separator(),
MenuItem::action(label("table.data.autofilter"), AutoFilter),
MenuItem::action(label("table.data.filter"), StandardFilter),
MenuItem::action(label("table.data.unfilter"), ClearFilter),
MenuItem::separator(),
MenuItem::action(label("table.data.dedupe"), RemoveDuplicates),
MenuItem::action(label("table.data.columns"), TextToColumns),
MenuItem::separator(),
MenuItem::action(label("office.find.open"), ely_office::FindReplace),
MenuItem::action(label("table.data.paste_values"), PasteValues),
MenuItem::action(label("table.data.paste_formulas"), PasteFormulas),
MenuItem::separator(),
MenuItem::action(label("table.rules.validation"), crate::rules::Validate),
MenuItem::action(label("table.rules.unvalidate"), crate::rules::Unvalidate),
])
}
pub fn bind(root: Div, cx: &mut Context<Book>) -> Div {
root.on_action(cx.listener(|book, _: &Sort, _, cx| book.ask_data(Asked::Sort, cx)))
.on_action(cx.listener(|book, _: &StandardFilter, _, cx| book.ask_data(Asked::Filter, cx)))
.on_action(
cx.listener(|book, _: &RemoveDuplicates, _, cx| book.ask_data(Asked::Dedupe, cx)),
)
.on_action(cx.listener(|book, _: &TextToColumns, _, cx| book.ask_columns(cx)))
.on_action(
cx.listener(|book, _: &AutoFilter, _, cx| book.run_active("data.autofilter", &[], cx)),
)
.on_action(
cx.listener(|book, _: &ClearFilter, _, cx| book.run_active("data.unfilter", &[], cx)),
)
.on_action(cx.listener(|book, _: &PasteValues, _, cx| book.paste_only("paste.values", cx)))
.on_action(
cx.listener(|book, _: &PasteFormulas, _, cx| book.paste_only("paste.formulas", cx)),
)
}
/// Which data dialog to open.
#[derive(Clone, Copy, Debug)]
pub enum Asked {
Sort,
Filter,
Dedupe,
}
/// The kernel's operator for a builder test.
fn op(test: Test) -> &'static str {
match test {
Test::Contains => "contains",
Test::Equals => "equals",
Test::NotEquals => "notequals",
Test::Above => "greater",
Test::Below => "less",
Test::Empty => "empty",
}
}
/// The `data.filter` query for builder rules.
pub fn query(rules: &[FilterRule], any: bool) -> String {
let conditions: Vec<_> = rules
.iter()
.enumerate()
.map(|(at, rule)| {
serde_json::json!({
"field": rule.column.parse::<u32>().expect("columns are indexes"),
"op": op(rule.test),
"value": rule.value.as_ref(),
"or": any && at > 0,
})
})
.collect();
serde_json::json!({ "conditions": conditions }).to_string()
}
/// The `data.filter` query keeping `picked` of a column.
pub fn column_query(column: u32, entries: &[Entry], picked: &[SharedString]) -> String {
if entries
.iter()
.all(|entry| picked.iter().any(|p| *p == entry.text))
{
return serde_json::json!({ "column": column }).to_string();
}
let values: Vec<&str> = picked.iter().map(SharedString::as_ref).collect();
serde_json::json!({
"column": column,
"conditions": [{ "op": "values", "values": values }],
})
.to_string()
}
/// The `data.sort` values for up to three keys.
pub fn sort_values(header: bool, keys: &[SortKey]) -> Vec<String> {
let mut values = vec![header.to_string()];
for at in 0..3 {
match keys.get(at) {
Some(key) => {
let column: u32 = key.column.parse().expect("columns are indexes");
values.push((column + 1).to_string());
values.push(key.rising.to_string());
}
None => values.extend(["0".to_string(), "true".to_string()]),
}
}
values
}
/// A column's title: its header text, else its letters.
pub fn title(picked: &Selection, column: u32, header: bool) -> SharedString {
let (first, ..) = picked.area.expect("a data area");
let text = picked.top[(column - first) as usize].trim();
if header && !text.is_empty() {
text.to_string().into()
} else {
sheet::column_name(column).into()
}
}
impl Book {
pub(crate) fn warn_key(&self, key: &str, cx: &mut Context<Self>) {
let text = Book::text(cx, key, &[]);
self.toast(text, Severity::Warning, cx);
}
/// Reads tab `id`'s selection; a lone cell grows to data.
pub(crate) fn data_of(
&mut self,
id: u64,
grow: bool,
then: impl FnOnce(&mut Self, Selection, ReadState, &mut Window, &mut Context<Self>) + 'static,
cx: &mut Context<Self>,
) {
self.ask_in(
id,
move |worker, doc| {
let read = |worker: &mut ely_lok::Worker| {
sheet::selected_range(&worker.values(doc, sheet::SELECTED)?)
};
let picked = match read(worker) {
// The kernel's data area around the cell.
Ok(Some(lone)) if grow && !lone.range.contains(':') => {
worker.run(doc, "select.data", &[])?;
let grown = read(worker)?;
// No data: unmark, or Enter stays in the cell.
if grown.as_ref().is_some_and(|picked| picked.area.is_none()) {
worker.run(doc, "cell.goto", &[&lone.range])?;
}
Ok(grown)
}
other => other,
}?;
// Numbers in the picked range hold in this state.
Ok((picked, (worker.now(doc)?, doc.sheets(worker)?.current)))
},
move |book, read, window, cx| match read {
Ok((Some(picked), state)) if picked.area.is_some() => then(book, picked, state, window, cx),
Ok((Some(_), _)) => book.warn_key("table.data.empty", cx),
Ok((None, _)) => book.warn_key("table.data.one_range", cx),
Err(error) => book.engine_failed(&error.to_string(), cx),
},
cx,
);
}
pub(crate) fn ask_data(&mut self, asked: Asked, cx: &mut Context<Self>) {
let Some(id) = self.core.active else {
return self.warn_key("command.blocked.no_document", cx);
};
self.data_in(id, asked, cx);
}
/// The data dialog for tab `id`, once its input lands.
fn data_in(&mut self, id: u64, asked: Asked, cx: &mut Context<Self>) {
if !self.settled(id, cx) {
return self.when_settled(id, move |book, _, cx| book.data_in(id, asked, cx));
}
self.data_of(
id,
true,
move |book, picked, state, _, cx| book.data_dialog(id, asked, picked, state, cx),
cx,
);
}
fn data_dialog(&mut self, id: u64, asked: Asked, picked: Selection, state: ReadState, cx: &mut Context<Self>) {
log::info!("table: {asked:?} on {}", picked.range);
let header = picked.header;
self.dialog = Some(match asked {
Asked::Sort => {
let (first, ..) = picked.area.expect("a data area");
Dialog::Sort {
tab: id,
keys: vec![SortKey {
column: first.to_string().into(),
rising: true,
}],
header,
picked,
}
}
Asked::Filter => Dialog::Filter {
tab: id,
rules: Vec::new(),
any: false,
picked,
},
Asked::Dedupe => Dialog::Dedupe {
tab: id,
header,
picked,
state,
},
});
cx.notify();
}
/// Sorts, filters or removes duplicates as asked.
pub(crate) fn data_asked(&mut self, cx: &mut Context<Self>) {
let Some(dialog) = self.dialog.take() else {
return;
};
match dialog {
Dialog::Sort {
tab, keys, header, ..
} => {
let values = sort_values(header, &keys);
let values: Vec<&str> = values.iter().map(String::as_str).collect();
self.run_in(Some(tab), "data.sort", &values, cx);
}
Dialog::Filter {
tab, rules, any, ..
} => self.run_in(Some(tab), "data.filter", &[&query(&rules, any)], cx),
Dialog::Dedupe {
tab,
header,
picked,
state: (at, sheet),
} => {
let (c1, r1, c2, r2) = picked.area.expect("a data area");
let sheet = sheet.to_string();
let corners = [c1, r1, c2, r2].map(|n| n.to_string());
let header = header.to_string();
let mut values = vec!["true", header.as_str(), "true"];
values.extend(corners.iter().map(String::as_str));
values.push(&sheet);
self.run_on(tab, &at, "data.dedupe", &values, cx);
}
other => self.dialog = Some(other),
}
}
/// Splits one column of text by the chosen separators.
pub(crate) fn ask_columns(&mut self, cx: &mut Context<Self>) {
let Some(id) = self.core.active else {
return self.warn_key("command.blocked.no_document", cx);
};
self.columns_in(id, cx);
}
/// The columns dialog for tab `id`, once its input lands.
pub(crate) fn columns_in(&mut self, id: u64, cx: &mut Context<Self>) {
if !self.settled(id, cx) {
return self.when_settled(id, move |book, _, cx| book.columns_in(id, cx));
}
self.data_of(
id,
false,
move |book, picked, _, window, cx| {
let (c1, _, c2, _) = picked.area.expect("a data area");
if c1 != c2 {
return book.warn_key("table.columns.one", cx);
}
let other = cx.new(|cx| TextInput::new(window, cx));
book.dialog = Some(Dialog::Columns {
tab: id,
separators: vec!["9".into()],
other,
});
cx.notify();
},
cx,
);
}
/// Runs the split the columns dialog describes.
pub(crate) fn columns_asked(&mut self, cx: &mut Context<Self>) {
let Some(Dialog::Columns {
tab,
separators,
other,
}) = self.dialog.take()
else {
return;
};
let mut codes: Vec<String> = separators.iter().map(ToString::to_string).collect();
if let Some(c) = other.read(cx).text().chars().next() {
codes.push(u32::from(c).to_string());
}
// Separators, quote, UTF-8, first row.
let options = format!("{},34,76,1", codes.join("/"));
self.run_in(Some(tab), "data.columns", &[&options], cx);
}
fn paste_only(&mut self, id: &'static str, cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
return self.warn_key("command.blocked.no_document", cx);
};
self.paste_only_in(tab, id, cx);
}
/// Values or formulas alone, from cells this workbook copied.
pub(crate) fn paste_only_in(&mut self, tab: u64, id: &'static str, cx: &mut Context<Self>) {
if !self.settled(tab, cx) {
return self.when_settled(tab, move |book, _, cx| book.paste_only_in(tab, id, cx));
}
let Some(view) = self.tab(tab).map(|tab| tab.view.clone()) else {
return;
};
let html = match clipboard::read() {
Ok(Some(Clip::Html { html, .. })) => Some(html),
Ok(_) => None,
Err(error) => {
let text = Book::text(cx, "table.data.clipboard", &[("error", &error.to_string())]);
return self.toast(text, Severity::Danger, cx);
}
};
// The kernel keeps only its own cells' values and formulas.
if !html.is_some_and(|html| view.read(cx).holds_copy(&html)) {
return self.warn_key("table.data.paste_foreign", cx);
}
log::info!("table: {id}");
view.update(cx, |view, _| view.paste_only(id));
}
/// The filter menu a column button asks for.
pub(crate) fn ask_column(&mut self, id: u64, request: &Request, cx: &mut Context<Self>) {
let Request::Filter { column, row } = *request else {
return;
};
self.ask_in(
id,
move |worker, doc| {
let json = worker.values(doc, &sheet::cell_query(sheet::FILTER, column, row))?;
sheet::filter_values(&json)
},
move |book, read, _, cx| match read {
Ok(filter) => {
let picked = filter
.entries
.iter()
.filter(|entry| entry.selected)
.map(|entry| SharedString::from(entry.text.clone()))
.collect();
book.dialog = Some(Dialog::Column {
tab: id,
column,
entries: filter.entries,
picked,
});
cx.notify();
}
Err(error) => book.engine_failed(&error.to_string(), cx),
},
cx,
);
}
/// Keeps the picked entries, or sorts by the column.
pub(crate) fn column_asked(&mut self, sort: Option<bool>, cx: &mut Context<Self>) {
let Some(Dialog::Column {
tab,
column,
entries,
picked,
}) = self.dialog.take()
else {
return;
};
match sort {
Some(rising) => {
let key = SortKey {
column: column.to_string().into(),
rising,
};
let values = sort_values(true, &[key]);
let values: Vec<&str> = values.iter().map(String::as_str).collect();
self.run_in(Some(tab), "data.sort", &values, cx);
}
None => {
let query = column_query(column, &entries, &picked);
self.run_in(Some(tab), "data.filter", &[&query], cx);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rules_and_keys_become_kernel_values() {
let rules = [
FilterRule {
column: "2".into(),
test: Test::Above,
value: "5".into(),
},
FilterRule {
column: "0".into(),
test: Test::Empty,
value: "".into(),
},
];
let json: serde_json::Value = serde_json::from_str(&query(&rules, true)).expect("json");
assert_eq!(json["conditions"][0]["op"], "greater");
assert_eq!(json["conditions"][1]["or"], true);
let keys = [SortKey {
column: "1".into(),
rising: false,
}];
assert_eq!(
sort_values(true, &keys),
["true", "2", "false", "0", "true", "0", "true"]
);
let entries = [
Entry {
text: "a".into(),
hidden: false,
selected: true,
},
Entry {
text: "b".into(),
hidden: false,
selected: true,
},
];
assert_eq!(
column_query(3, &entries, &["a".into(), "b".into()]),
r#"{"column":3}"#
);
assert!(column_query(3, &entries, &["a".into()]).contains(r#""values":["a"]"#));
}
}
+311
View File
@@ -0,0 +1,311 @@
//! The data dialogs: sort, filter, duplicates, columns.
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
forms::{Checkbox, CheckboxGroup, Choice, Input, Switch, TextInput},
layout::ScrollArea,
overlays::{Close, Dialog as Card},
primitives::Severity,
tables::{FilterBuilder, FilterRule, SortBuilder, SortKey},
theme::ControlSize,
};
use ely_lok::sheet::{self, Entry, Selection};
use ely_office::prelude::*;
use gpui::{App, Entity, ParentElement as _, SharedString, Styled as _, Window, div};
use super::title;
use crate::book::Book;
use crate::dialog::{Dialog, edit};
/// The kernel sorts by up to three keys.
const KEYS: usize = 3;
/// The primary button that runs what the dialog asks.
fn go(
this: Entity<Book>,
(id, label): (&'static str, SharedString),
off: bool,
run: fn(&mut Book, &mut gpui::Context<Book>),
) -> impl FnOnce(Close) -> Button + 'static {
move |_| {
Button::new(id, label)
.variant(ButtonVariant::Primary)
.disabled(off)
.on_click(move |_, _, cx| this.update(cx, run))
}
}
fn header_switch(this: &Entity<Book>, id: &'static str, on: bool, cx: &App) -> Switch {
let this = this.clone();
Switch::new(id, on)
.label(Book::text(cx, "table.data.header", &[]))
.on_change(move |on, _, cx| {
edit(&this, cx, |dialog, _, _| match dialog {
Dialog::Sort { header, .. } | Dialog::Dedupe { header, .. } => *header = on,
_ => {}
})
})
}
pub fn sort_dialog(
this: Entity<Book>,
(picked, keys, header): (&Selection, &[SortKey], bool),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let (first, _, last, _) = picked.area.expect("a data area");
let columns: Vec<_> = (first..=last)
.map(|column| (column.to_string(), title(picked, column, header)))
.collect();
let keyed = this.clone();
let builder = SortBuilder::new("table-sort-keys", columns)
.keys(keys.to_vec())
.on_change(move |keys, _, cx| {
let mut keys = keys.to_vec();
let over = keys.len() > KEYS;
keys.truncate(KEYS);
edit(&keyed, cx, |dialog, book, cx| {
if let Dialog::Sort { keys: held, .. } = dialog {
*held = keys;
}
if over {
let text = Book::text(cx, "table.data.three", &[]);
book.toast(text, Severity::Info, cx);
}
})
});
let label = Book::text(cx, "table.data.sort_go", &[]);
Card::new(
"table-sort",
Book::text(cx, "table.data.sort_title", &[]),
dismiss,
)
.child(
div()
.flex()
.flex_col()
.gap_3()
.child(header_switch(&this, "table-sort-header", header, cx))
.child(builder),
)
.action(ely_office::cancel(cx))
.action(go(
this,
("table-sort-go", label),
keys.is_empty(),
Book::data_asked,
))
}
pub fn filter_dialog(
this: Entity<Book>,
(picked, rules, any): (&Selection, &[FilterRule], bool),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let (first, _, last, _) = picked.area.expect("a data area");
let columns: Vec<_> = (first..=last)
.map(|column| (column.to_string(), title(picked, column, true)))
.collect();
let ruled = this.clone();
let builder = FilterBuilder::new("table-filter-rules", columns)
.rules(rules.to_vec(), any)
.on_change(move |rules, any, _, cx| {
let rules = rules.to_vec();
edit(&ruled, cx, |dialog, _, _| {
if let Dialog::Filter {
rules: held,
any: joined,
..
} = dialog
{
*held = rules;
*joined = any;
}
})
});
let label = Book::text(cx, "table.data.filter_go", &[]);
Card::new(
"table-filter",
Book::text(cx, "table.data.filter_title", &[]),
dismiss,
)
.child(builder)
.action(ely_office::cancel(cx))
.action(go(
this,
("table-filter-go", label),
rules.is_empty(),
Book::data_asked,
))
}
pub fn dedupe_dialog(
this: Entity<Book>,
(picked, header): (&Selection, bool),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let detail = Book::text(cx, "table.data.dedupe_detail", &[("range", &picked.range)]);
let label = Book::text(cx, "table.data.dedupe_go", &[]);
let removing = this.clone();
Card::new(
"table-dedupe",
Book::text(cx, "table.data.dedupe_title", &[]),
dismiss,
)
.detail(detail)
.child(header_switch(&this, "table-dedupe-header", header, cx))
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("table-dedupe-go", label)
.variant(ButtonVariant::Danger)
.on_click(move |_, _, cx| removing.update(cx, |book, cx| book.data_asked(cx)))
})
}
/// Separators as kernel character codes.
const SEPARATORS: [(&str, &str); 4] = [
("9", "table.columns.tab"),
("44", "table.columns.comma"),
("59", "table.columns.semicolon"),
("32", "table.columns.space"),
];
pub fn columns_dialog(
this: Entity<Book>,
(separators, other): (&[SharedString], &Entity<TextInput>),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let choices = SEPARATORS
.iter()
.map(|(code, key)| Choice::new(*code, Book::text(cx, key, &[])));
let picked = this.clone();
let group = CheckboxGroup::new("table-columns-separators", choices)
.selected(separators.to_vec())
.on_change(move |values, _, cx| {
let values = values.to_vec();
edit(&picked, cx, |dialog, _, _| {
if let Dialog::Columns { separators, .. } = dialog {
*separators = values;
}
})
});
let other_row = div()
.flex()
.items_center()
.gap_2()
.child(Book::text(cx, "table.columns.other", &[]))
.child(div().w_16().child(Input::new(other).size(ControlSize::Sm)));
let none = separators.is_empty() && other.read(cx).text().is_empty();
let label = Book::text(cx, "table.columns.go", &[]);
Card::new(
"table-columns",
Book::text(cx, "table.columns.title", &[]),
dismiss,
)
.child(
div()
.flex()
.flex_col()
.gap_3()
.child(group)
.child(other_row),
)
.action(ely_office::cancel(cx))
.action(go(
this,
("table-columns-go", label),
none,
Book::columns_asked,
))
}
pub fn column_dialog(
this: Entity<Book>,
(column, entries, picked): (u32, &[Entry], &[SharedString]),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let empty = Book::text(cx, "table.filter.empty", &[]);
let choices = entries.iter().map(|entry| {
let label = match entry.text.is_empty() {
true => empty.clone(),
false => entry.text.clone().into(),
};
Choice::new(entry.text.clone(), label)
});
let checked = this.clone();
let group = CheckboxGroup::new("table-column-entries", choices)
.selected(picked.to_vec())
.on_change(move |values, _, cx| {
let values = values.to_vec();
edit(&checked, cx, |dialog, _, _| {
if let Dialog::Column { picked, .. } = dialog {
*picked = values;
}
})
});
let every = this.clone();
let all: Vec<SharedString> = entries
.iter()
.map(|entry| entry.text.clone().into())
.collect();
let whole = Checkbox::new("table-column-all", picked.len() == entries.len())
.label(Book::text(cx, "table.filter.all", &[]))
.on_change(move |on, _, cx| {
let values = if on { all.clone() } else { Vec::new() };
edit(&every, cx, |dialog, _, _| {
if let Dialog::Column { picked, .. } = dialog {
*picked = values;
}
})
});
let sort = |id: &'static str, key: &str, rising: bool| {
let this = this.clone();
Button::new(id, Book::text(cx, key, &[]))
.size(ControlSize::Sm)
.on_click(move |_, _, cx| {
this.update(cx, |book, cx| book.column_asked(Some(rising), cx))
})
};
let title = Book::text(
cx,
"table.filter.title",
&[("column", &sheet::column_name(column))],
);
let label = Book::text(cx, "table.filter.go", &[]);
let (applying, none) = (this.clone(), picked.is_empty());
Card::new("table-column", title, dismiss)
.child(
div()
.flex()
.flex_col()
.gap_3()
.child(
div()
.flex()
.gap_2()
.child(sort("table-column-up", "table.filter.up", true))
.child(sort("table-column-down", "table.filter.down", false)),
)
.child(whole)
.child(
div().h_64().child(
ScrollArea::new("table-column-scroll")
.size_full()
.child(group),
),
),
)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("table-column-go", label)
.variant(ButtonVariant::Primary)
.disabled(none)
.on_click(move |_, _, cx| {
applying.update(cx, |book, cx| book.column_asked(None, cx))
})
})
}
+296
View File
@@ -0,0 +1,296 @@
//! ElyTable's one open dialog.
use ely_gpui_component::{
forms::TextInput,
tables::{FilterRule, SortKey},
};
use ely_lok::sheet::{Entry, Format, References, Selection, Validation};
use gpui::{AnyElement, App, Context, Entity, IntoElement, SharedString, Window};
use crate::book::Book;
use crate::{analysis, data, rules};
/// Changes the open dialog's state.
pub(crate) fn edit(
this: &Entity<Book>,
cx: &mut App,
change: impl FnOnce(&mut Dialog, &mut Book, &mut Context<Book>),
) {
this.update(cx, |book, cx| {
if let Some(mut dialog) = book.dialog.take() {
change(&mut dialog, book, cx);
book.dialog = Some(dialog);
}
cx.notify();
});
}
/// What the open dialog asks, for which workbook.
pub enum Dialog {
Rename {
tab: u64,
/// The state the sheet number was read in.
at: ely_lok::Doc,
index: usize,
field: Entity<TextInput>,
},
Show {
tab: u64,
hidden: Vec<String>,
chosen: String,
},
Delete {
tab: u64,
/// The state the sheet number was read in.
at: ely_lok::Doc,
index: usize,
name: String,
references: References,
},
Size {
tab: u64,
column: bool,
field: Entity<TextInput>,
},
Merge {
tab: u64,
},
Define {
tab: u64,
name: Entity<TextInput>,
range: Entity<TextInput>,
scope: String,
},
Names {
tab: u64,
names: Vec<ely_lok::sheet::Name>,
chosen: usize,
range: Entity<TextInput>,
},
Sort {
tab: u64,
picked: Selection,
keys: Vec<SortKey>,
header: bool,
},
Filter {
tab: u64,
picked: Selection,
rules: Vec<FilterRule>,
any: bool,
},
Dedupe {
tab: u64,
picked: Selection,
header: bool,
state: crate::data::ReadState,
},
Columns {
tab: u64,
separators: Vec<SharedString>,
other: Entity<TextInput>,
},
/// A column button's filter menu.
Column {
tab: u64,
column: u32,
entries: Vec<Entry>,
picked: Vec<SharedString>,
},
Validation {
tab: u64,
rule: Box<Validation>,
fields: Box<crate::rules::Fields>,
},
Conditional {
tab: u64,
kind: &'static str,
operator: &'static str,
style: &'static str,
preset: usize,
value1: Entity<TextInput>,
value2: Entity<TextInput>,
},
Formats {
tab: u64,
formats: Vec<Format>,
chosen: usize,
},
Pivot {
tab: u64,
source: String,
fields: Vec<String>,
layout: ely_lok::sheet::Layout,
/// Changes the pivot table at the cursor.
editing: bool,
},
Chart {
tab: u64,
/// The selected chart; none inserts one.
chart: Option<Box<ely_lok::sheet::Chart>>,
look: Box<ely_lok::sheet::Look>,
fields: Box<crate::analysis::ChartFields>,
},
/// A text file before it opens.
Import(Box<crate::text::Pending>),
Export {
tab: u64,
format: crate::output::Format,
},
Page {
tab: u64,
layout: Box<ely_lok::sheet::PrintLayout>,
landscape: bool,
scale: ely_lok::sheet::Scale,
fields: Box<crate::page::PageFields>,
},
Protect {
tab: u64,
protecting: Box<crate::protect::Protecting>,
},
}
impl Book {
/// The define dialog's sheets: its own workbook's.
pub(crate) fn define_scopes(&self) -> Option<Vec<String>> {
let Some(Dialog::Define { tab, .. }) = &self.dialog else {
return None;
};
self.sheet_in(*tab).map(|(_, _, names)| names)
}
/// ElyTable's open dialog, if any.
pub fn dialog_element(&mut self, cx: &mut Context<Self>) -> Option<AnyElement> {
let this = cx.entity();
let dismiss = {
let this = this.clone();
move |_: &mut Window, cx: &mut App| {
this.update(cx, |book, cx| {
book.dialog = None;
cx.notify();
})
}
};
let element = match self.dialog.as_ref()? {
Dialog::Rename { tab, index, field, .. } => {
let names = self.sheet_in(*tab)?.2;
crate::sheets::dialogs::rename(this, (*index, field, names), dismiss, cx)
.into_any_element()
}
Dialog::Show {
tab,
hidden,
chosen,
} => crate::sheets::dialogs::show(this, (*tab, hidden, chosen), dismiss, cx)
.into_any_element(),
Dialog::Delete {
name, references, ..
} => crate::sheets::dialogs::delete(this, (name, references), dismiss, cx)
.into_any_element(),
Dialog::Size { column, field, .. } => {
crate::cells::size_dialog(this, (*column, field), dismiss, cx).into_any_element()
}
Dialog::Merge { tab } => {
crate::cells::merge_dialog(this, *tab, dismiss, cx).into_any_element()
}
Dialog::Define {
name, range, scope, ..
} => crate::formulas::define_dialog(
this,
(name, range, scope, self.define_scopes()?),
dismiss,
cx,
)
.into_any_element(),
Dialog::Names {
names,
chosen,
range,
..
} => crate::formulas::names_dialog(this, (names, *chosen, range), dismiss, cx)
.into_any_element(),
Dialog::Sort {
picked,
keys,
header,
..
} => data::sort_dialog(this, (picked, keys, *header), dismiss, cx).into_any_element(),
Dialog::Filter {
picked, rules, any, ..
} => data::filter_dialog(this, (picked, rules, *any), dismiss, cx).into_any_element(),
Dialog::Dedupe { picked, header, .. } => {
data::dedupe_dialog(this, (picked, *header), dismiss, cx).into_any_element()
}
Dialog::Columns {
separators, other, ..
} => data::columns_dialog(this, (separators, other), dismiss, cx).into_any_element(),
Dialog::Column {
column,
entries,
picked,
..
} => data::column_dialog(this, (*column, entries, picked), dismiss, cx)
.into_any_element(),
Dialog::Validation { rule, fields, .. } => {
rules::validation_dialog(this, (rule, fields), dismiss, cx).into_any_element()
}
Dialog::Conditional {
kind,
operator,
style,
preset,
value1,
value2,
..
} => {
let shown = rules::Shown {
kind,
operator,
style,
preset: *preset,
value1,
value2,
};
rules::conditional_dialog(this, shown, dismiss, cx).into_any_element()
}
Dialog::Formats {
formats, chosen, ..
} => rules::formats_dialog(this, (formats, *chosen), dismiss, cx).into_any_element(),
Dialog::Pivot {
source,
fields,
layout,
editing,
..
} => analysis::pivot_dialog(this, (source, fields, layout, *editing), dismiss, cx)
.into_any_element(),
Dialog::Chart {
chart,
look,
fields,
..
} => analysis::chart_dialog(this, (chart.as_deref(), look, fields), dismiss, cx)
.into_any_element(),
Dialog::Import(pending) => {
crate::text::import_dialog(this, pending, dismiss, cx).into_any_element()
}
Dialog::Export { tab, format } => {
let sheet = self.sheet_in(*tab)?.1;
crate::output::export_dialog(this, (*format, &sheet), dismiss, cx)
.into_any_element()
}
Dialog::Page {
layout,
landscape,
scale,
fields,
..
} => crate::page::page_dialog(this, (layout, *landscape, *scale, fields), dismiss, cx)
.into_any_element(),
Dialog::Protect { protecting, .. } => {
crate::protect::protect_dialog(this, protecting, dismiss, cx).into_any_element()
}
};
Some(element)
}
}
+97
View File
@@ -0,0 +1,97 @@
//! The kernel's status texts in the interface language (TABLE-03, 05).
use gpui::{App, SharedString};
use crate::book::Book;
/// The kernel's English names of the selection figures.
const FIGURES: [(&str, &str); 7] = [
("Sum", "table.figure.sum"),
("Selection count", "table.figure.selected"),
("Count", "table.figure.count"),
("CountA", "table.figure.counta"),
("Average", "table.figure.average"),
("Max", "table.figure.max"),
("Min", "table.figure.min"),
];
/// The kernel's explanations of a cell's error.
const ERRORS: [(&str, &str); 25] = [
("Error: Division by zero", "table.error.div_zero"),
("Error: No value", "table.error.no_value"),
("Error: Not a valid reference", "table.error.no_ref"),
("Error: Invalid name", "table.error.no_name"),
("Error: Invalid numeric value", "table.error.number"),
("Error: Value not available", "table.error.not_available"),
("Error: Add-in not found", "table.error.no_addin"),
("Error: Macro not found", "table.error.no_macro"),
("Internal syntactical error", "table.error.syntax"),
("Error: No code or intersection", "table.error.no_code"),
("Error: Invalid argument", "table.error.argument"),
("Error in parameter list", "table.error.parameters"),
("Error: Invalid character", "table.error.character"),
("Error: in bracketing", "table.error.brackets"),
("Error: Operator missing", "table.error.operator"),
("Error: Variable missing", "table.error.variable"),
("Error: Formula overflow", "table.error.formula_overflow"),
("Error: String overflow", "table.error.string_overflow"),
("Error: Internal overflow", "table.error.internal_overflow"),
("Error: Array or matrix size", "table.error.array_size"),
("Error: Circular reference", "table.error.circular"),
(
"Error: Calculation does not converge",
"table.error.converge",
),
(
"Nested arrays are not supported.",
"table.error.nested_array",
),
(
"Unsupported inline array content.",
"table.error.array_content",
),
("External content disabled.", "table.error.external"),
];
/// The kernel's cell status: its error, else the selection figures.
pub fn label(status: &str, cx: &App) -> SharedString {
if let Some((_, key)) = ERRORS.iter().find(|(known, _)| *known == status) {
return Book::text(cx, key, &[]);
}
let parts: Vec<String> = status
.split("; ")
.map(|part| {
let known = part.split_once(": ").and_then(|(name, value)| {
let (_, key) = FIGURES.iter().find(|(known, _)| *known == name)?;
Some(Book::text(cx, key, &[("value", value.trim())]).to_string())
});
known.unwrap_or_else(|| {
log::warn!("table: the kernel status {part:?} has no label");
part.to_string()
})
})
.collect();
parts.join(" ").into()
}
#[cfg(test)]
mod tests {
#[test]
fn kernel_status_takes_the_interface_language() {
let cx = gpui::TestAppContext::single();
cx.update(|app| {
ely_ui::settings::install_defaults(app);
ely_ui::i18n::install(app);
let label = super::label("Average: 21; Sum: 42; Odd: 1", app);
let expect = format!(
"{} {} Odd: 1",
super::Book::text(app, "table.figure.average", &[("value", "21")]),
super::Book::text(app, "table.figure.sum", &[("value", "42")])
);
assert_eq!(label.as_ref(), expect, "known names relabel, others stay");
for (english, key) in super::ERRORS {
assert_eq!(super::label(english, app), super::Book::text(app, key, &[]));
}
});
}
}
+466
View File
@@ -0,0 +1,466 @@
//! The format panel: numbers, alignment, borders, fill (TABLE-07).
use std::sync::{Arc, Mutex};
use ely_gpui_component::{
buttons::{Button, ToggleButton, ToggleItem},
data_display::{PropertyGrid, PropertyGroup},
forms::{Choice, ColorPalette, ColorSwatch, Input, InputEvent, Select, TextInput},
layout::ScrollArea,
overlays::Popover,
primitives::{IconName, Severity},
theme::ControlSize,
};
use ely_lok::{Doc, LokView, Reported, Worker, sheet};
use ely_office::prelude::*;
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, Hsla, IntoElement, Menu, MenuItem,
ParentElement as _, Render, SharedString, Styled as _, Subscription, WeakEntity, Window,
actions, div, rgb,
};
use crate::book::{Book, Spot};
actions!(table, [FormatPanel]);
pub fn menu(cx: &App) -> Menu {
let label = |key: &str| Book::text(cx, key, &[]);
Menu::new(label("menu.format")).items([
MenuItem::action(label("table.format.panel"), FormatPanel),
MenuItem::separator(),
MenuItem::action(label("table.rules.conditional"), crate::rules::Conditional),
MenuItem::action(
label("table.rules.conditionals"),
crate::rules::Conditionals,
),
MenuItem::separator(),
MenuItem::action(label("table.protect.lock"), crate::protect::LockCells),
MenuItem::action(label("table.protect.unlock"), crate::protect::UnlockCells),
])
}
/// Number kinds the panel sets, and their commands.
const KINDS: [(&str, &str, &str); 5] = [
("general", "number.general", "table.format.general"),
("number", "number.decimal", "table.format.decimal"),
("currency", "number.currency", "table.format.currency"),
("percent", "number.percent", "table.format.percent"),
("date", "number.date", "table.format.date"),
];
/// Fill colors, 0xRRGGBB.
const FILLS: [u32; 10] = [
0xFFFFFF, 0xD9D9D9, 0xFFC7CE, 0xFFEB9C, 0xC6EFCE, 0xBDD7EE, 0xF8CBAD, 0xE4DFEC, 0x808080,
0x000000,
];
/// The workbook the panel formats.
struct Shown {
view: Entity<LokView>,
doc: Doc,
_observe: Subscription,
}
pub struct Panel {
book: WeakEntity<Book>,
worker: Arc<Mutex<Worker>>,
shown: Option<Shown>,
code: Entity<TextInput>,
/// The code field has the user's typing.
editing: bool,
/// The cell of the last read.
read: Option<Spot>,
/// A read is on its way.
reading: bool,
/// The cell's format code and kind.
pub(crate) number: Option<(String, String)>,
_code: Subscription,
}
impl Panel {
pub fn new(
book: WeakEntity<Book>,
worker: Arc<Mutex<Worker>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let name = Book::text(cx, "table.format.code", &[]);
let code = cx.new(|cx| TextInput::new(window, cx).label(name));
let subscription = cx.subscribe(&code, |panel, code, event, cx| match event {
InputEvent::Focus => panel.editing = true,
InputEvent::Blur => {
panel.editing = false;
panel.read = None;
panel.reread(cx);
}
InputEvent::Submit => {
let text = code.read(cx).text().trim().to_string();
panel.apply_code(&text, cx);
}
InputEvent::Changed => {}
});
Panel {
book,
worker,
shown: None,
code,
editing: false,
read: None,
reading: false,
number: None,
_code: subscription,
}
}
/// Follows the active workbook.
pub fn show(&mut self, tab: Option<(&Entity<LokView>, &Doc)>, cx: &mut Context<Self>) {
let id = tab.map(|(view, _)| view.entity_id());
if self.shown.as_ref().map(|shown| shown.view.entity_id()) == id {
return;
}
self.shown = tab.map(|(view, doc)| Shown {
view: view.clone(),
doc: doc.clone(),
_observe: cx.subscribe(view, |panel, _, _: &Reported, cx| {
panel.reread(cx);
cx.notify();
}),
});
self.read = None;
self.reread(cx);
}
/// Rereads the number format after a move or edit.
fn reread(&mut self, cx: &mut Context<Self>) {
let Some(shown) = &self.shown else {
return;
};
let view = shown.view.read(cx);
let at: Spot = (
view.cell().sheets,
view.cell().address.clone(),
view.edited(),
);
if self.reading || !view.settled() || self.read.as_ref() == Some(&at) {
return;
}
(self.read, self.reading) = (Some(at), true);
let reader = shown.view.entity_id();
ely_lok::read_off(
&self.worker,
&shown.doc,
|worker, doc| sheet::number_format(&worker.values(doc, sheet::NUMBER)?),
move |panel: &mut Self, read, cx| {
panel.reading = false;
if panel.shown.as_ref().map(|shown| shown.view.entity_id()) != Some(reader) {
return panel.reread(cx);
}
match read {
Ok(number) => {
if !panel.editing {
let code = number.0.clone();
panel.code.update(cx, |input, cx| input.set_text(code, cx));
}
panel.number = Some(number);
}
Err(error) => panel.toast(&format!("{error}"), cx),
}
cx.notify();
// The cell may have moved meanwhile.
panel.reread(cx);
},
cx,
);
}
fn toast(&self, error: &str, cx: &mut Context<Self>) {
if let Some(book) = self.book.upgrade() {
book.update(cx, |book, cx| book.engine_failed(error, cx));
}
}
/// Sets a typed format code once the kernel takes it.
pub(crate) fn apply_code(&mut self, code: &str, cx: &mut Context<Self>) {
let Some(shown) = &self.shown else {
return;
};
// The workbook that asked, not the one active later.
let Some(tab) = self
.book
.upgrade()
.and_then(|book| book.read(cx).core.active)
else {
return;
};
let code = code.to_string();
let asked = code.clone();
ely_lok::read_off(
&self.worker,
&shown.doc,
move |worker, doc| {
sheet::number_valid(&worker.values(doc, &sheet::number_check_query(&asked))?)
},
move |panel: &mut Self, check, cx| match check {
Ok(true) => {
if let Some(book) = panel.book.upgrade() {
book.update(cx, |book, cx| {
book.run_in(Some(tab), "number.code", &[&code], cx)
});
}
}
Ok(false) => {
if let Some(book) = panel.book.upgrade() {
book.update(cx, |book, cx| {
let text = Book::text(cx, "table.format.invalid", &[("code", &code)]);
book.toast(text, Severity::Warning, cx);
});
}
}
Err(error) => panel.toast(&error.to_string(), cx),
},
cx,
);
}
fn state(&self, uno: &str, cx: &App) -> Option<String> {
let view = self.shown.as_ref()?.view.read(cx);
view.state(uno).map(str::to_string)
}
/// A kernel-lit toggle for `command`.
fn toggle(
&self,
command: &'static str,
icon: Option<IconName>,
key: &str,
cx: &Context<Self>,
) -> AnyElement {
let uno = ely_lok::commands::find(command)
.expect("a table command")
.uno;
let on = self.state(uno, cx).as_deref() == Some("true");
let mut item = ToggleItem::new(command).tooltip(Book::text(cx, key, &[]));
item = match icon {
Some(icon) => item.icon(icon),
None => item.label(Book::text(cx, key, &[])),
};
let book = self.book.clone();
ToggleButton::new(SharedString::from(command), item, on)
.size(ControlSize::Sm)
.on_toggle(move |_, _, cx| {
let wrap = (!on).to_string();
let values: &[&str] = if command == "cells.wrap" {
&[&wrap]
} else {
&[]
};
run(&book, command, values, cx);
})
.into_any_element()
}
fn button(&self, command: &'static str, key: &str, cx: &Context<Self>) -> AnyElement {
let book = self.book.clone();
Button::new(SharedString::from(command), Book::text(cx, key, &[]))
.size(ControlSize::Sm)
.on_click(move |_, _, cx| run(&book, command, &[], cx))
.into_any_element()
}
fn number_group(&self, cx: &Context<Self>) -> PropertyGroup {
let label = |key: &str| Book::text(cx, key, &[]);
let kind = self
.number
.as_ref()
.map_or("general", |(_, kind)| kind.as_str());
let listed = KINDS.iter().any(|(value, ..)| *value == kind);
let mut choices: Vec<Choice> = KINDS
.iter()
.map(|(value, _, key)| Choice::new(*value, label(key)))
.collect();
if !listed {
choices.push(Choice::new(kind.to_string(), label("table.format.custom")));
}
let book = self.book.clone();
let kinds = Select::new("table-format-kind", choices)
.label(label("table.format.kind"))
.selected(kind.to_string())
.size(ControlSize::Sm)
.on_change(move |value, _, cx| {
if let Some((_, command, _)) =
KINDS.iter().find(|(kind, ..)| *kind == value.as_ref())
{
run(&book, command, &[], cx);
}
});
PropertyGroup::new(label("table.format.number"))
.row(label("table.format.kind"), kinds)
.row(
label("table.format.code"),
Input::new(&self.code).size(ControlSize::Sm),
)
}
fn align_group(&self, cx: &Context<Self>) -> PropertyGroup {
let label = |key: &str| Book::text(cx, key, &[]);
let across = row([
self.toggle(
"align.left",
Some(IconName::AlignLeft),
"table.format.left",
cx,
),
self.toggle(
"align.center",
Some(IconName::AlignCenter),
"table.format.center",
cx,
),
self.toggle(
"align.right",
Some(IconName::AlignRight),
"table.format.right",
cx,
),
self.toggle(
"align.justify",
Some(IconName::AlignJustify),
"table.format.justify",
cx,
),
]);
let down = row([
self.toggle(
"align.top",
Some(IconName::AlignStartHorizontal),
"table.format.top",
cx,
),
self.toggle(
"align.middle",
Some(IconName::AlignCenterHorizontal),
"table.format.middle",
cx,
),
self.toggle(
"align.bottom",
Some(IconName::AlignEndHorizontal),
"table.format.bottom",
cx,
),
]);
PropertyGroup::new(label("table.format.alignment"))
.row(label("table.format.across"), across)
.row(label("table.format.down"), down)
.row(
label("table.format.text"),
self.toggle("cells.wrap", None, "table.format.wrap", cx),
)
}
fn cell_group(&self, cx: &Context<Self>) -> PropertyGroup {
let label = |key: &str| Book::text(cx, key, &[]);
let borders = row([
self.button("cells.borders_all", "table.format.borders_all", cx),
self.button("cells.borders_outer", "table.format.borders_outer", cx),
self.button("cells.borders_none", "table.format.borders_none", cx),
]);
PropertyGroup::new(label("table.format.cell"))
.row(label("table.format.borders"), borders)
.row(label("table.format.fill"), self.fill(cx))
}
/// The cell's fill as a swatch; it opens the palette.
fn fill(&self, cx: &Context<Self>) -> AnyElement {
let current = self
.state(".uno:BackgroundColor", cx)
.and_then(|color| color.parse::<i64>().ok())
.and_then(|color| u32::try_from(color).ok());
let (shown, tip) = match current {
Some(color) => (Hsla::from(rgb(color)), format!("#{color:06X}").into()),
None => (
gpui::transparent_black(),
Book::text(cx, "table.format.no_fill", &[]),
),
};
let (book, none) = (
self.book.clone(),
Book::text(cx, "table.format.no_fill", &[]),
);
Popover::with_opener(
"table-fill-popover",
move |toggle| {
ColorSwatch::new("table-fill-swatch", shown)
.tooltip(tip)
.on_click(move |window, cx| toggle(window, cx))
},
move |close, _, _| {
let swatches = FILLS.map(|color| (format!("#{color:06X}"), Hsla::from(rgb(color))));
let picked = book.clone();
let mut palette = ColorPalette::new("table-fill", swatches.clone())
.columns(5)
.on_change({
let close = close.clone();
move |color, window, cx| {
let at = swatches
.iter()
.position(|(_, swatch)| *swatch == color)
.expect("the palette hands back its own colors");
close(window, cx);
run(&picked, "cells.fill", &[&FILLS[at].to_string()], cx);
}
});
if let Some(color) = current.filter(|color| FILLS.contains(color)) {
palette = palette.selected(rgb(color));
}
let book = book.clone();
let clear = Button::new("table-fill-none", none.clone())
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
close(window, cx);
run(&book, "cells.fill", &["-1"], cx);
});
div()
.flex()
.flex_col()
.gap_2()
.p_2()
.child(palette)
.child(clear)
},
)
.into_any_element()
}
}
/// Runs a command on the active workbook.
fn run(book: &WeakEntity<Book>, id: &str, values: &[&str], cx: &mut App) {
if let Some(book) = book.upgrade() {
book.update(cx, |book, cx| book.run_active(id, values, cx));
}
}
/// Controls side by side, wrapping when narrow.
fn row(items: impl IntoIterator<Item = AnyElement>) -> AnyElement {
div()
.flex()
.flex_wrap()
.items_center()
.gap_1()
.children(items)
.into_any_element()
}
impl Render for Panel {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let grid = PropertyGrid::new("table-format")
.group(self.number_group(cx))
.group(self.align_group(cx))
.group(self.cell_group(cx));
div().size_full().child(
ScrollArea::new("table-format-scroll").vertical()
.size_full()
.child(grid),
)
}
}
+272
View File
@@ -0,0 +1,272 @@
//! The Formula menu: calculation, names, tracing (TABLE-03..06).
use ely_gpui_component::{forms::TextInput, primitives::Severity};
use ely_lok::sheet;
use ely_office::prelude::*;
use gpui::{App, AppContext as _, Context, Div, InteractiveElement as _, Menu, MenuItem, actions};
use crate::book::Book;
use crate::dialog::Dialog;
mod dialogs;
pub use dialogs::{define_dialog, names_dialog};
actions!(
table,
[
AutoCalculate,
Recalculate,
RecalculateAll,
DefineName,
ManageNames,
TraceError,
TracePrecedents,
TraceDependents,
RemoveArrows
]
);
pub fn menu(cx: &App) -> Menu {
let label = |key: &str| Book::text(cx, key, &[]);
Menu::new(label("menu.formula")).items([
MenuItem::action(label("table.calc.auto"), AutoCalculate),
MenuItem::action(label("table.calc.now"), Recalculate),
MenuItem::action(label("table.calc.all"), RecalculateAll),
MenuItem::separator(),
MenuItem::action(label("table.names.define"), DefineName),
MenuItem::action(label("table.names.manage"), ManageNames),
MenuItem::separator(),
MenuItem::action(label("table.trace.error"), TraceError),
MenuItem::action(label("table.trace.precedents"), TracePrecedents),
MenuItem::action(label("table.trace.dependents"), TraceDependents),
MenuItem::action(label("table.trace.clear"), RemoveArrows),
])
}
/// Binds an action to a command without values.
macro_rules! run {
($root:expr, $cx:expr, $action:ty, $id:literal) => {
$root.on_action($cx.listener(|book, _: &$action, _, cx| book.run_active($id, &[], cx)))
};
}
pub fn bind(root: Div, cx: &mut Context<Book>) -> Div {
let root = run!(root, cx, Recalculate, "calc.now");
let root = run!(root, cx, RecalculateAll, "calc.all");
let root = run!(root, cx, TraceError, "trace.error");
let root = run!(root, cx, TracePrecedents, "trace.precedents");
let root = run!(root, cx, TraceDependents, "trace.dependents");
let root = run!(root, cx, RemoveArrows, "trace.clear");
root.on_action(cx.listener(|book, _: &AutoCalculate, _, cx| book.toggle_calculation(cx)))
.on_action(cx.listener(|book, _: &DefineName, _, cx| book.ask_define(cx)))
.on_action(cx.listener(|book, _: &ManageNames, _, cx| book.ask_names(cx)))
}
impl Book {
/// Whether the active workbook calculates by itself.
pub fn automatic(&self, cx: &App) -> Option<bool> {
let tab = self.active_tab()?;
match tab.view.read(cx).state(".uno:AutomaticCalculation")? {
"true" => Some(true),
"false" => Some(false),
other => {
log::warn!("table: calculation state {other:?}");
None
}
}
}
fn toggle_calculation(&mut self, cx: &mut Context<Self>) {
let Some(automatic) = self.automatic(cx) else {
let text = Book::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
let next = (!automatic).to_string();
self.run_active("calc.auto", &[&next], cx);
}
/// Asks for a name, prefilled with the selection.
pub(crate) fn ask_define(&mut self, cx: &mut Context<Self>) {
let Some(id) = self.core.active else {
let text = Book::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
self.define_in(id, cx);
}
/// The define dialog for tab `id`, once its input lands.
fn define_in(&mut self, id: u64, cx: &mut Context<Self>) {
if !self.settled(id, cx) {
return self.when_settled(id, move |book, _, cx| book.define_in(id, cx));
}
self.ask_in(
id,
|worker, doc| sheet::selected_range(&worker.values(doc, sheet::SELECTED)?),
move |book, read, window, cx| {
let refers = match read {
Ok(Some(picked)) => picked.range,
Ok(None) => {
// Several ranges: the person types it.
let text = Book::text(cx, "table.names.unresolved", &[]);
book.toast(text, Severity::Info, cx);
String::new()
}
Err(error) => return book.engine_failed(&error.to_string(), cx),
};
let name = cx.new(|cx| TextInput::new(window, cx));
let range = cx.new(|cx| TextInput::new(window, cx));
range.update(cx, |range, cx| range.set_text(refers, cx));
book.dialog = Some(Dialog::Define {
tab: id,
name,
range,
scope: String::new(),
});
cx.notify();
},
cx,
);
}
/// Defines the asked name once the kernel takes it (TABLE-06).
pub(crate) fn define(&mut self, cx: &mut Context<Self>) {
let Some(Dialog::Define {
tab,
name,
range,
scope,
}) = &self.dialog
else {
return;
};
let (tab, scope) = (*tab, scope.clone());
let name = name.read(cx).text().trim().to_string();
let range = range.read(cx).text().trim().to_string();
let asked = name.clone();
self.read_in(
tab,
move |worker, doc| {
let names = sheet::names(&worker.values(doc, sheet::NAMES)?)?;
let check =
sheet::name_check(&worker.values(doc, &sheet::name_check_query(&asked))?)?;
Ok((names, check))
},
move |book, read, _, cx| {
let (names, check) = match read {
Ok(read) => read,
Err(error) => return book.engine_failed(&error.to_string(), cx),
};
let lower = name.to_lowercase();
let taken = names
.iter()
.any(|known| known.scope == scope && known.name.to_lowercase() == lower);
let refused = match check {
_ if taken => Some("table.names.taken"),
"valid" => None,
"address" => Some("table.names.address"),
_ => Some("table.names.characters"),
};
if let Some(key) = refused {
let text = Book::text(cx, key, &[("name", &name)]);
return book.toast(text, Severity::Warning, cx);
}
book.dialog = None;
book.run_in(Some(tab), "names.define", &[&name, &range, &scope], cx);
},
cx,
);
}
/// Lists the names to change, jump to or remove.
fn ask_names(&mut self, cx: &mut Context<Self>) {
let Some(id) = self.core.active else {
let text = Book::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
self.names_in(id, cx);
}
/// The names dialog for tab `id`, once its input lands.
pub(crate) fn names_in(&mut self, id: u64, cx: &mut Context<Self>) {
if !self.settled(id, cx) {
return self.when_settled(id, move |book, _, cx| book.names_in(id, cx));
}
self.ask_in(
id,
|worker, doc| sheet::names(&worker.values(doc, sheet::NAMES)?),
move |book, read, window, cx| {
let names = match read {
Ok(names) => names,
Err(error) => return book.engine_failed(&error.to_string(), cx),
};
let Some(first) = names.first() else {
let text = Book::text(cx, "table.names.none", &[]);
return book.toast(text, Severity::Info, cx);
};
let range = cx.new(|cx| TextInput::new(window, cx));
let formula = first.formula.clone();
range.update(cx, |range, cx| range.set_text(formula, cx));
book.dialog = Some(Dialog::Names {
tab: id,
names,
chosen: 0,
range,
});
cx.notify();
},
cx,
);
}
/// Picks name `index` in the open list.
pub(crate) fn choose_name(&mut self, index: usize, cx: &mut Context<Self>) {
if let Some(Dialog::Names {
names,
chosen,
range,
..
}) = &mut self.dialog
{
*chosen = index;
let formula = names[index].formula.clone();
range.update(cx, |range, cx| range.set_text(formula, cx));
}
cx.notify();
}
/// Applies, jumps to or removes the chosen name.
pub(crate) fn act_on_name(&mut self, act: NameAct, cx: &mut Context<Self>) {
let Some(Dialog::Names {
tab,
names,
chosen,
range,
}) = self.dialog.take()
else {
return;
};
let name = &names[chosen];
let tab = Some(tab);
match act {
NameAct::Apply => {
let range = range.read(cx).text().trim().to_string();
self.run_in(tab, "names.define", &[&name.name, &range, &name.scope], cx);
}
NameAct::Go => {
// The jump shows its own workbook.
self.core.active = tab;
self.run_in(tab, "cell.goto", &[&name.range], cx);
}
NameAct::Remove => self.run_in(tab, "names.remove", &[&name.name, &name.scope], cx),
}
cx.notify();
}
}
/// What the names list does with the chosen name.
#[derive(Clone, Copy)]
pub enum NameAct {
Apply,
Go,
Remove,
}
+147
View File
@@ -0,0 +1,147 @@
//! The name dialogs: define, and the list (TABLE-06).
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
forms::{Choice, Input, Select, TextInput},
overlays::Dialog as Card,
};
use ely_lok::sheet::Name;
use gpui::{App, Entity, ParentElement as _, SharedString, Styled as _, Window, div};
use super::NameAct;
use crate::book::Book;
use crate::dialog::Dialog;
/// What the scope list shows: the workbook, then each sheet.
fn scope_label(scope: &str, cx: &App) -> SharedString {
if scope.is_empty() {
Book::text(cx, "table.names.workbook", &[])
} else {
scope.to_string().into()
}
}
pub fn define_dialog(
this: Entity<Book>,
(name, range, scope, sheets): (&Entity<TextInput>, &Entity<TextInput>, &str, Vec<String>),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let scopes = std::iter::once(String::new())
.chain(sheets)
.map(|scope| Choice::new(scope.clone(), scope_label(&scope, cx)));
let picked = this.clone();
let scope = Select::new("table-define-scope", scopes)
.selected(scope.to_string())
.on_change(move |value, _, cx| {
picked.update(cx, |book, cx| {
if let Some(Dialog::Define { scope, .. }) = &mut book.dialog {
*scope = value.to_string();
}
cx.notify();
})
});
let fields = DescriptionList::new()
.item(
Book::text(cx, "table.names.name", &[]),
div().w_full().max_w_64().child(Input::new(name)),
)
.item(
Book::text(cx, "table.names.refers", &[]),
div().w_full().max_w_64().child(Input::new(range)),
)
.item(Book::text(cx, "table.names.scope", &[]), scope);
let empty = name.read(cx).text().trim().is_empty() || range.read(cx).text().trim().is_empty();
let label = Book::text(cx, "table.names.go_define", &[]);
Card::new(
"table-define",
Book::text(cx, "table.names.define_title", &[]),
dismiss,
)
.child(fields)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("table-define-go", label)
.variant(ButtonVariant::Primary)
.disabled(empty)
.on_click(move |_, _, cx| this.update(cx, |book, cx| book.define(cx)))
})
}
/// One name as the list shows it.
fn entry(name: &Name, cx: &App) -> SharedString {
let args = [
("name", name.name.as_str()),
("scope", &scope_label(&name.scope, cx)),
("formula", name.formula.as_str()),
];
let key = if name.broken {
"table.names.entry_broken"
} else {
"table.names.entry"
};
Book::text(cx, key, &args)
}
pub fn names_dialog(
this: Entity<Book>,
(names, chosen, range): (&[Name], usize, &Entity<TextInput>),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let choices = names
.iter()
.enumerate()
.map(|(index, name)| Choice::new(index.to_string(), entry(name, cx)));
let picked = this.clone();
let list = Select::new("table-names-pick", choices)
.selected(chosen.to_string())
.on_change(move |value, _, cx| {
let index: usize = value.parse().expect("names are listed by index");
picked.update(cx, |book, cx| book.choose_name(index, cx))
});
let fields = DescriptionList::new()
.item(Book::text(cx, "table.names.name", &[]), list)
.item(
Book::text(cx, "table.names.refers", &[]),
div().w_full().max_w_64().child(Input::new(range)),
);
let nowhere = names[chosen].broken || names[chosen].range.is_empty();
let act = |id: &'static str, key: &str, act: NameAct, variant, off: bool| {
let (this, label) = (this.clone(), Book::text(cx, key, &[]));
move |_| {
Button::new(id, label)
.variant(variant)
.disabled(off)
.on_click(move |_, _, cx| this.update(cx, |book, cx| book.act_on_name(act, cx)))
}
};
Card::new(
"table-names",
Book::text(cx, "table.names.manage_title", &[]),
dismiss,
)
.child(fields)
.action(act(
"table-names-remove",
"table.names.remove",
NameAct::Remove,
ButtonVariant::Danger,
false,
))
.action(act(
"table-names-go",
"table.names.goto",
NameAct::Go,
ButtonVariant::Secondary,
nowhere,
))
.action(act(
"table-names-apply",
"table.names.apply",
NameAct::Apply,
ButtonVariant::Primary,
false,
))
}
+361
View File
@@ -0,0 +1,361 @@
//! Headers, sizing drags and the fill handle (TABLE-02).
use ely_gpui_component::{primitives::FocusRing as _, theme::ActiveTheme as _};
use ely_lok::{
LokView, Rect,
sheet::{Axis, address, column_name},
};
use ely_office::prelude::*;
use gpui::{
AnyElement, ClickEvent, Context, CursorStyle, Entity, InteractiveElement as _, IntoElement,
Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _,
Pixels, Point, Role, StatefulInteractiveElement as _, Styled as _, Window, div, px,
transparent_black,
};
use crate::book::Book;
/// A drag over the grid.
pub enum Drag {
/// A column or row edge.
Size { column: bool, index: u32 },
/// The fill handle, to the cell under the pointer.
Fill { to: Option<(u32, u32)> },
}
/// The width that grabs a header edge.
const EDGE: f32 = 4.0;
const HANDLE: f32 = 7.0;
/// The vcl modifier bits a header click takes.
fn modifier(modifiers: &Modifiers) -> String {
let shift = if modifiers.shift { 0x1000 } else { 0 };
let add = if modifiers.secondary() { 0x2000 } else { 0 };
(shift | add).to_string()
}
/// Twips as 1/100 mm, as the size commands take them.
fn mm100(twips: i32) -> i32 {
(twips * 2540 + 720) / 1440
}
impl Book {
pub fn grid(&mut self, _: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let tab = self.active_tab().expect("the grid needs a tab");
let view = tab.view.clone();
let (rows, columns) = (
tab.extra.geometry.rows.clone(),
tab.extra.geometry.columns.clone(),
);
let theme = cx.theme();
let (border, sunken) = (theme.colors.border, theme.colors.sunken);
let column_strip = self.strip(&view, &columns, true, cx);
let row_strip = self.strip(&view, &rows, false, cx);
let corner = div()
.w_12()
.h_6()
.border_r_1()
.border_b_1()
.border_color(border);
div()
.flex_1()
.min_h_0()
.flex()
.flex_col()
.bg(sunken)
.on_mouse_move(cx.listener(Self::on_drag_move))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_drag_end))
.child(div().flex().child(corner).child(column_strip))
.child(
div().flex_1().min_h_0().flex().child(row_strip).child(
div()
.relative()
.flex_1()
.min_w_0()
.child(view.clone())
.children(self.fill_handle(&view, cx))
.children(self.cell_aids(&view, cx))
.children(self.find_box(cx)),
),
)
.into_any_element()
}
/// One header strip; `column` picks the axis.
fn strip(
&self,
view: &Entity<LokView>,
axis: &Axis,
column: bool,
cx: &mut Context<Self>,
) -> AnyElement {
let theme = cx.theme();
let border = theme.colors.border;
let read = view.read(cx);
let (width, height) = read.viewport();
let along = |twips: i32| {
let at = read.place((twips, twips));
f32::from(if column { at.x } else { at.y })
};
let length = if column { width } else { height };
let pick = |pair: (i32, i32)| if column { pair.0 } else { pair.1 };
let (frozen, scroll) = (pick(read.frozen()), pick(read.scroll()));
let mut spans = Vec::new();
let mut take = |index: u32| {
let (start, end) = (along(axis.start(index)), along(axis.start(index + 1)));
if end > start {
spans.push((index, start, end - start));
}
};
// The frozen indexes, then the scrolled ones.
let held = if frozen > 0 {
axis.at(frozen - 1) + 1
} else {
0
};
(0..held).for_each(&mut take);
let mut index = axis.at(frozen + scroll).max(held);
while index < axis.count() && along(axis.start(index)) < length {
take(index);
index += 1;
}
let cells: Vec<AnyElement> = spans
.into_iter()
.map(|(index, start, size)| self.header(index, start, size, column, cx))
.collect();
let strip = div()
.id(if column {
"table-columns"
} else {
"table-rows"
})
.relative()
.overflow_hidden()
.border_color(border)
.children(cells);
if column {
strip.flex_1().h_6().border_b_1().into_any_element()
} else {
strip.w_12().h_full().border_r_1().into_any_element()
}
}
/// One row or column header with its sizing edge.
fn header(
&self,
index: u32,
start: f32,
size: f32,
column: bool,
cx: &mut Context<Self>,
) -> AnyElement {
let theme = cx.theme();
let (border, text) = (theme.colors.border, theme.colors.fg_muted);
let label = if column {
column_name(index)
} else {
(index + 1).to_string()
};
let edge = div()
.id(("table-edge", index as usize))
.absolute()
.cursor(if column {
CursorStyle::ResizeLeftRight
} else {
CursorStyle::ResizeUpDown
})
.on_mouse_down(
MouseButton::Left,
cx.listener(move |book, _: &MouseDownEvent, _, cx| {
cx.stop_propagation();
book.drag = Some(Drag::Size { column, index });
}),
);
let edge = if column {
edge.top_0().right_0().h_full().w(px(EDGE))
} else {
edge.left_0().bottom_0().w_full().h(px(EDGE))
};
let id = (
if column { "table-column" } else { "table-row" },
index as usize,
);
let name = if column {
Book::text(cx, "table.header.column", &[("name", &label)])
} else {
Book::text(cx, "table.header.row", &[("name", &label)])
};
let button = div()
.id(id)
.role(if column {
Role::ColumnHeader
} else {
Role::RowHeader
})
.aria_label(name)
.size_full()
.flex()
.items_center()
.justify_center()
.border_1()
.border_color(transparent_black())
.focus_ring(cx)
.tab_index(0)
.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);
}))
.child(label);
let cell = div()
.absolute()
.overflow_hidden()
.text_xs()
.text_color(text)
.border_color(border)
.child(button)
.child(edge);
let cell = if column {
cell.top_0()
.left(px(start))
.w(px(size))
.h_full()
.border_r_1()
} else {
cell.left_0()
.top(px(start))
.h(px(size))
.w_full()
.border_b_1()
};
cell.into_any_element()
}
/// The square at the selection's corner; dragging it fills.
fn fill_handle(&self, view: &Entity<LokView>, cx: &mut Context<Self>) -> Option<AnyElement> {
let read = view.read(cx);
let area = span(read.selection()).or(read.cell().cursor)?;
let corner = read.place((area.0 + area.2, area.1 + area.3));
let start = read.place((area.0, area.1));
// Scrolled under the frozen part, the area shows nothing.
if corner.x <= start.x || corner.y <= start.y {
return None;
}
let theme = cx.theme();
let (fill, edge) = (theme.colors.focus, theme.colors.bg);
let preview = match &self.drag {
Some(Drag::Fill {
to: Some((column, row)),
}) => {
let geometry = &self.active_tab()?.extra.geometry;
let end = (
geometry.columns.start(column + 1),
geometry.rows.start(row + 1),
);
Some(outline(read.place((area.0, area.1)), read.place(end), fill))
}
_ => None,
};
let handle = div()
.id("table-fill")
.absolute()
.left(corner.x - px(HANDLE / 2.0))
.top(corner.y - px(HANDLE / 2.0))
.size(px(HANDLE))
.bg(fill)
.border_1()
.border_color(edge)
.cursor(CursorStyle::Crosshair)
.occlude()
.on_mouse_down(
MouseButton::Left,
cx.listener(|book, _: &MouseDownEvent, _, cx| {
cx.stop_propagation();
book.drag = Some(Drag::Fill { to: None });
cx.notify();
}),
);
let layer = div().absolute().top_0().left_0().size_full();
Some(layer.children(preview).child(handle).into_any_element())
}
fn on_drag_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
if !matches!(self.drag, Some(Drag::Fill { .. })) {
return;
}
let Some(tab) = self.active_tab() else {
return;
};
let read = tab.view.read(cx);
let (x, y) = read.twips_at(event.position - read.origin());
let geometry = &tab.extra.geometry;
let cell = (geometry.columns.at(x), geometry.rows.at(y));
self.drag = Some(Drag::Fill { to: Some(cell) });
cx.notify();
}
fn on_drag_end(&mut self, event: &MouseUpEvent, _: &mut Window, cx: &mut Context<Self>) {
let Some(drag) = self.drag.take() else {
return;
};
cx.notify();
match drag {
Drag::Size { column, index, .. } => {
let Some(tab) = self.active_tab() else {
return;
};
let read = tab.view.read(cx);
let geometry = &tab.extra.geometry;
let axis = if column {
&geometry.columns
} else {
&geometry.rows
};
let local = event.position - read.origin();
let (x, y) = read.twips_at(local);
let twips = (if column { x } else { y }) - axis.start(index);
let size = mm100(twips).clamp(1, 65535).to_string();
let id = if column {
"columns.width"
} else {
"rows.height"
};
log::info!("table: {id} {} to {size}", index + 1);
self.run_active(id, &[&size, &(index + 1).to_string()], cx);
}
Drag::Fill {
to: Some((column, row)),
} => {
self.run_active("fill.auto", &[&address(column, row)], cx);
}
Drag::Fill { to: None } => {}
}
}
}
/// The rectangle that holds all of `rects`.
fn span(rects: &[Rect]) -> Option<Rect> {
let first = *rects.first()?;
let (mut x0, mut y0, mut x1, mut y1) = (first.0, first.1, first.0 + first.2, first.1 + first.3);
for &(x, y, w, h) in &rects[1..] {
(x0, y0) = (x0.min(x), y0.min(y));
(x1, y1) = (x1.max(x + w), y1.max(y + h));
}
Some((x0, y0, x1 - x0, y1 - y0))
}
/// A frame from `from` to `to`, view-local.
fn outline(from: Point<Pixels>, to: Point<Pixels>, color: gpui::Hsla) -> AnyElement {
div()
.absolute()
.left(from.x)
.top(from.y)
.w(to.x - from.x)
.h(to.y - from.y)
.border_2()
.border_color(color)
.into_any_element()
}
+26
View File
@@ -0,0 +1,26 @@
mod analysis;
mod bars;
mod book;
mod cells;
mod data;
mod dialog;
mod figures;
mod format;
mod formulas;
mod grid;
mod output;
mod page;
mod protect;
mod rules;
mod sheets;
mod template;
mod text;
use ely_platform::AppId;
fn main() {
ely_ui::run(AppId::Table, book::open);
}
#[cfg(test)]
mod tests;
+201
View File
@@ -0,0 +1,201 @@
//! Export as PDF, CSV or TSV, and print (TABLE-13, 14).
use std::path::PathBuf;
use ely_gpui_component::{
buttons::{Button, ButtonVariant, SegmentedControl},
overlays::Dialog as Card,
primitives::Severity,
theme::ControlSize,
};
use ely_lok::Pdf;
use ely_office::{in_window, prelude::*};
use ely_ui::commands::{Export, Print};
use gpui::{App, Context, Entity, ParentElement as _, TaskExt as _, Window, div};
use crate::book::Book;
use crate::dialog::{Dialog, edit};
/// What an export writes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Format {
Pdf,
Csv,
Tsv,
}
const FORMATS: [(Format, &str, &str); 3] = [
(Format::Pdf, "pdf", "PDF"),
(Format::Csv, "csv", "CSV"),
(Format::Tsv, "tsv", "TSV"),
];
impl Format {
fn extension(self) -> &'static str {
FORMATS.iter().find(|(each, ..)| *each == self).expect("listed").1
}
}
/// Every sheet as Page Setup lays it out.
const WHOLE: Pdf = Pdf {
pages: None,
comments: false,
changes: false,
handouts: 0,
};
impl Book {
pub(crate) fn on_export(&mut self, _: &Export, _: &mut Window, cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
return self.warn_key("command.blocked.no_document", cx);
};
self.dialog = Some(Dialog::Export {
tab,
format: Format::Pdf,
});
cx.notify();
}
/// Asks where, then writes the chosen format.
pub(crate) fn export_asked(&mut self, cx: &mut Context<Self>) {
let Some(Dialog::Export { tab, format }) = self.dialog.take() else {
return;
};
cx.notify();
let Some(source) = self.tab(tab) else {
return;
};
let Some(dir) = ely_office::folder(source) else {
return self.panel_failed("no documents folder", cx);
};
let stem = source
.name
.rsplit_once('.')
.map_or(source.name.as_ref(), |(stem, _)| stem);
let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.{}", format.extension())));
cx.spawn(async move |this, cx| {
let chosen = match chosen.await {
Ok(Ok(path)) => Ok(path),
Ok(Err(error)) => Err(error.to_string()),
Err(error) => Err(error.to_string()),
};
in_window(&this, cx, |book, _, cx| match chosen {
Ok(Some(path)) => book.export_to(tab, path, format, cx),
Ok(None) => log::info!("table: the export panel was cancelled"),
Err(error) => book.panel_failed(&error, cx),
})
})
.detach_and_log_err(cx);
}
pub(crate) fn export_to(&mut self, tab: u64, path: PathBuf, format: Format, cx: &mut Context<Self>) {
if !self.settled(tab, cx) {
return self.when_settled(tab, move |book, _, cx| book.export_to(tab, path, format, cx));
}
match self.holder(&path) {
Ok(None) => {}
Ok(Some(_)) => {
let name = ely_office::files::file_name(&path);
let text = Book::text(cx, "table.export.open", &[("name", &name)]);
return self.toast(text, Severity::Danger, cx);
}
Err(error) => return self.output_failed(&error.to_string(), cx),
}
log::info!("table: exports tab {tab} as {format:?}");
let (url, name) = (ely_lok::file_url(&path), ely_office::files::file_name(&path));
let opened = path.clone();
self.read_in(
tab,
move |worker, doc| match format {
Format::Pdf => doc.export_pdf(worker, &url, &WHOLE),
Format::Csv => doc.export_text(worker, &url, ','),
Format::Tsv => doc.export_text(worker, &url, '\t'),
},
move |book, written, _, cx| match written {
Ok(()) => {
let text = Book::text(cx, "table.export.done", &[("name", &name)]);
book.toast_output(text, opened.clone(), cx);
}
Err(error) => book.output_failed(&error.to_string(), cx),
},
cx,
);
}
/// Prints a PDF of every sheet through the system panel.
pub(crate) fn on_print(&mut self, _: &Print, _: &mut Window, cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
return self.warn_key("command.blocked.no_document", cx);
};
self.print(tab, cx);
}
fn print(&mut self, tab: u64, cx: &mut Context<Self>) {
if !self.settled(tab, cx) {
return self.when_settled(tab, move |book, _, cx| book.print(tab, cx));
}
let Some(source) = self.tab(tab) else {
return;
};
let path = source.work.join("print.pdf");
let url = ely_lok::file_url(&path);
log::info!("table: prints tab {tab}");
self.read_in(
tab,
move |worker, doc| doc.export_pdf(worker, &url, &WHOLE),
move |book, written, _, cx| match written {
Ok(()) => ely_office::print_later(path, cx, |book, error, cx| {
book.output_failed(&error, cx)
}),
Err(error) => book.output_failed(&error.to_string(), cx),
},
cx,
);
}
fn output_failed(&self, error: &str, cx: &mut Context<Self>) {
let text = Book::text(cx, "table.export.failed", &[("error", error)]);
self.toast(text, Severity::Danger, cx);
}
}
/// The export dialog; text formats say what they drop.
pub fn export_dialog(
this: Entity<Book>,
(format, sheet): (Format, &str),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let picking = this.clone();
let formats = FORMATS
.iter()
.fold(
SegmentedControl::new("table-export-format", format.extension()),
|control, (_, value, label)| control.segment(*value, *label, None),
)
.size(ControlSize::Sm)
.on_change(move |value, _, cx| {
let Some((chosen, ..)) = FORMATS.iter().find(|(_, each, _)| *each == value.as_ref())
else {
return;
};
let chosen = *chosen;
edit(&picking, cx, |dialog, _, _| {
if let Dialog::Export { format, .. } = dialog {
*format = chosen;
}
})
});
let loss = (format != Format::Pdf)
.then(|| div().child(Book::text(cx, "table.export.loss", &[("sheet", sheet)])));
let go = Book::text(cx, "table.export.go", &[]);
Card::new("table-export", Book::text(cx, "table.export.title", &[]), dismiss)
.child(formats)
.children(loss)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("table-export-go", go)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| this.update(cx, |book, cx| book.export_asked(cx)))
})
}
+238
View File
@@ -0,0 +1,238 @@
//! The Page menu: print area, titles, page, breaks (TABLE-14).
use ely_gpui_component::forms::TextInput;
use ely_lok::sheet::{self, PrintLayout, Scale, Span};
use ely_office::prelude::*;
use gpui::{
App, AppContext as _, Context, Div, Entity, InteractiveElement as _, Menu, MenuItem, Window,
actions,
};
use crate::book::Book;
use crate::dialog::Dialog;
mod dialogs;
pub use dialogs::page_dialog;
actions!(
table,
[
PageSetup,
RowBreak,
ColumnBreak,
RemoveRowBreak,
RemoveColumnBreak
]
);
pub fn menu(cx: &App) -> Menu {
let label = |key: &str| Book::text(cx, key, &[]);
Menu::new(label("menu.page")).items([
MenuItem::action(label("table.page.setup"), PageSetup),
MenuItem::separator(),
MenuItem::action(label("table.page.row_break"), RowBreak),
MenuItem::action(label("table.page.column_break"), ColumnBreak),
MenuItem::action(label("table.page.remove_row_break"), RemoveRowBreak),
MenuItem::action(label("table.page.remove_column_break"), RemoveColumnBreak),
])
}
pub fn bind(root: Div, cx: &mut Context<Book>) -> Div {
root.on_action(cx.listener(|book, _: &PageSetup, _, cx| book.ask_page(cx)))
.on_action(cx.listener(|book, _: &RowBreak, _, cx| book.run_active("break.row", &[], cx)))
.on_action(
cx.listener(|book, _: &ColumnBreak, _, cx| book.run_active("break.column", &[], cx)),
)
.on_action(cx.listener(|book, _: &RemoveRowBreak, _, cx| book.remove_break(true, cx)))
.on_action(
cx.listener(|book, _: &RemoveColumnBreak, _, cx| book.remove_break(false, cx)),
)
}
/// The page setup dialog's text fields.
pub struct PageFields {
pub area: Entity<TextInput>,
pub rows: Entity<TextInput>,
pub columns: Entity<TextInput>,
}
/// A column's number from its letters, A is 1.
fn column_number(letters: &str) -> Option<u32> {
(1..=3).contains(&letters.len()).then_some(())?;
let number = letters.bytes().try_fold(0u32, |number, byte| {
byte.is_ascii_uppercase()
.then(|| number * 26 + u32::from(byte - b'A') + 1)
})?;
(number <= 16384).then_some(number)
}
fn row_number(digits: &str) -> Option<u32> {
let number: u32 = digits.parse().ok()?;
(1..=1_048_576).contains(&number).then_some(number)
}
/// One A1 cell such as `$B$2`.
fn cell_ok(cell: &str) -> bool {
let cell = cell.strip_prefix('$').unwrap_or(cell);
let split = cell.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(cell.len());
let (letters, rest) = cell.split_at(split);
let digits = rest.strip_prefix('$').unwrap_or(rest);
column_number(&letters.to_ascii_uppercase()).is_some()
&& digits.bytes().all(|byte| byte.is_ascii_digit())
&& row_number(digits).is_some()
}
/// Areas as the kernel takes them, or None.
fn areas(text: &str) -> Option<String> {
let tokens: Vec<&str> = text
.split([',', ';'])
.map(str::trim)
.filter(|token| !token.is_empty())
.collect();
let ok = |token: &&str| match token.split_once(':') {
Some((from, to)) => cell_ok(from) && cell_ok(to),
None => cell_ok(token),
};
tokens.iter().all(ok).then(|| tokens.join(","))
}
/// A span such as `1:2` or `$A`; empty is none.
fn span(text: &str, number: fn(&str) -> Option<u32>) -> Option<Option<Span>> {
let text = text.trim();
if text.is_empty() {
return Some(None);
}
let end = |end: &str| number(&end.trim().trim_start_matches('$').to_ascii_uppercase());
let (first, last) = match text.split_once(':') {
Some((first, last)) => (end(first)?, end(last)?),
None => (end(text)?, end(text)?),
};
(first <= last).then_some(Some((first, last)))
}
impl Book {
pub(crate) fn ask_page(&mut self, cx: &mut Context<Self>) {
let Some(id) = self.core.active else {
return self.warn_key("command.blocked.no_document", cx);
};
if !self.settled(id, cx) {
return self.when_settled(id, |book, _, cx| book.ask_page(cx));
}
self.ask_in(
id,
|worker, doc| sheet::print_layout(&worker.values(doc, sheet::LAYOUT)?),
move |book, read, window, cx| match read {
Ok(layout) => book.page_dialog(id, layout, window, cx),
Err(error) => book.engine_failed(&error.to_string(), cx),
},
cx,
);
}
fn page_dialog(&mut self, id: u64, layout: PrintLayout, window: &mut Window, cx: &mut Context<Self>) {
let mut field = |text: String, hint: &str, cx: &mut Context<Self>| {
let hint = Book::text(cx, hint, &[]);
cx.new(|cx| {
let mut input = TextInput::new(window, cx).placeholder(hint);
input.set_text(text, cx);
input
})
};
let fields = PageFields {
area: field(layout.areas.join(","), "table.page.area_hint", cx),
rows: field(sheet::rows_ref(layout.rows), "table.page.rows_hint", cx),
columns: field(sheet::columns_ref(layout.columns), "table.page.columns_hint", cx),
};
self.dialog = Some(Dialog::Page {
tab: id,
landscape: layout.landscape,
scale: layout.scale,
layout: Box::new(layout),
fields: Box::new(fields),
});
cx.notify();
}
/// Applies the page setup dialog's changes.
pub(crate) fn page_asked(&mut self, cx: &mut Context<Self>) {
let Some(Dialog::Page {
tab,
layout,
landscape,
scale,
fields,
}) = &self.dialog
else {
return;
};
let text = |field: &Entity<TextInput>| field.read(cx).text().to_string();
let read = (
areas(&text(&fields.area)),
span(&text(&fields.rows), row_number),
span(&text(&fields.columns), column_number),
);
let (Some(area), Some(rows), Some(columns)) = read else {
return self.warn_key("table.page.bad_reference", cx);
};
let (tab, landscape, scale) = (*tab, *landscape, *scale);
if scale == Scale::Fit(0, 0) {
return self.warn_key("table.page.bad_fit", cx);
}
let printed = area != layout.areas.join(",") || rows != layout.rows || columns != layout.columns;
let paged = landscape != layout.landscape || scale != layout.scale;
self.dialog = None;
cx.notify();
if printed {
let (rows, columns) = (sheet::rows_ref(rows), sheet::columns_ref(columns));
self.run_in(Some(tab), "print.area", &[&area, &rows, &columns], cx);
}
if paged {
self.run_in(Some(tab), "page.setup", &[&sheet::page_json(landscape, scale)], cx);
}
}
/// Removes the cursor's break; none says so.
fn remove_break(&mut self, row: bool, cx: &mut Context<Self>) {
let Some(id) = self.core.active else {
return self.warn_key("command.blocked.no_document", cx);
};
if !self.settled(id, cx) {
return self.when_settled(id, move |book, _, cx| book.remove_break(row, cx));
}
self.ask_in(
id,
|worker, doc| sheet::print_layout(&worker.values(doc, sheet::LAYOUT)?),
move |book, read, _, cx| match read {
Ok(layout) if row && layout.row_break => {
book.run_in(Some(id), "break.row_remove", &[], cx)
}
Ok(layout) if !row && layout.column_break => {
book.run_in(Some(id), "break.column_remove", &[], cx)
}
Ok(_) => book.warn_key("table.page.no_break", cx),
Err(error) => book.engine_failed(&error.to_string(), cx),
},
cx,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn references_parse_like_excel() {
assert_eq!(areas(" A1:B2; $d$4 ").as_deref(), Some("A1:B2,$d$4"));
assert_eq!(areas("").as_deref(), Some(""));
assert_eq!(areas("A1:B"), None);
assert_eq!(areas("XFE1"), None);
assert_eq!(areas("S!A1"), None);
assert_eq!(span("$1:$3", row_number), Some(Some((1, 3))));
assert_eq!(span("2", row_number), Some(Some((2, 2))));
assert_eq!(span("3:1", row_number), None);
assert_eq!(span("a:ab", column_number), Some(Some((1, 28))));
assert_eq!(span(" ", column_number), Some(None));
assert_eq!(span("A1", column_number), None);
}
}
+141
View File
@@ -0,0 +1,141 @@
//! The page setup dialog.
use ely_gpui_component::{
buttons::{Button, ButtonVariant, SegmentedControl},
data_display::DescriptionList,
forms::{Input, NumberInput},
overlays::Dialog as Card,
theme::ControlSize,
};
use ely_lok::sheet::{PrintLayout, Scale};
use gpui::{App, Entity, ParentElement as _, Styled as _, Window, div};
use super::PageFields;
use crate::book::Book;
use crate::dialog::{Dialog, edit};
/// Changes the dialog's orientation or scale.
fn set(this: &Entity<Book>, cx: &mut App, change: impl FnOnce(&mut bool, &mut Scale)) {
edit(this, cx, |dialog, _, _| {
if let Dialog::Page {
landscape, scale, ..
} = dialog
{
change(landscape, scale);
}
})
}
fn number(id: &'static str, this: &Entity<Book>, value: u16, range: (u16, u16), apply: fn(&mut Scale, u16)) -> NumberInput {
let this = this.clone();
NumberInput::new(id, f64::from(value))
.range(f64::from(range.0), f64::from(range.1))
.on_commit(move |value, _, cx| {
let value = value.round() as u16;
set(&this, cx, |_, scale| apply(scale, value))
})
}
/// The scale's mode, then its numbers under the mode's name.
fn scale_rows(
rows: DescriptionList,
this: &Entity<Book>,
scale: Scale,
cx: &App,
) -> DescriptionList {
let label = |key: &str| Book::text(cx, key, &[]);
let mode = match scale {
Scale::Percent(_) => "percent",
Scale::Fit(..) => "fit",
Scale::Pages(_) => "pages",
};
let picking = this.clone();
let modes = SegmentedControl::new("table-page-scale", mode)
.size(ControlSize::Sm)
.segment("percent", label("table.page.percent"), None)
.segment("fit", label("table.page.fit"), None)
.segment("pages", label("table.page.pages"), None)
.on_change(move |value, _, cx| {
let chosen = match value.as_ref() {
"fit" => Scale::Fit(1, 1),
"pages" => Scale::Pages(1),
_ => Scale::Percent(100),
};
set(&picking, cx, |_, scale| *scale = chosen)
});
let field = |input: NumberInput| div().w_full().max_w_24().child(input);
let numbers = match scale {
Scale::Percent(percent) => div().child(field(number(
"table-page-percent",
this,
percent,
(10, 400),
|scale, value| *scale = Scale::Percent(value),
))),
Scale::Fit(wide, tall) => div()
.flex()
.items_center()
.gap_1()
.child(field(number("table-page-wide", this, wide, (0, 1000), |scale, value| {
if let Scale::Fit(wide, _) = scale {
*wide = value;
}
})))
.child("×")
.child(field(number("table-page-tall", this, tall, (0, 1000), |scale, value| {
if let Scale::Fit(_, tall) = scale {
*tall = value;
}
}))),
Scale::Pages(pages) => div().child(field(number(
"table-page-count",
this,
pages,
(1, 1000),
|scale, value| *scale = Scale::Pages(value),
))),
};
let named = label(&format!("table.page.{mode}"));
rows.item(label("table.page.scale"), modes)
.item(named, numbers)
}
pub fn page_dialog(
this: Entity<Book>,
(layout, landscape, scale, fields): (&PrintLayout, bool, Scale, &PageFields),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let label = |key: &str| Book::text(cx, key, &[]);
let picking = this.clone();
let orientation = SegmentedControl::new(
"table-page-orientation",
if landscape { "landscape" } else { "portrait" },
)
.size(ControlSize::Sm)
.segment("portrait", label("table.page.portrait"), None)
.segment("landscape", label("table.page.landscape"), None)
.on_change(move |value, _, cx| {
let on = value.as_ref() == "landscape";
set(&picking, cx, |landscape, _| *landscape = on)
});
let input = |field| div().w_full().max_w_48().child(Input::new(field));
let rows = DescriptionList::new()
.item(label("table.page.area"), input(&fields.area))
.item(label("table.page.rows"), input(&fields.rows))
.item(label("table.page.columns"), input(&fields.columns))
.item(label("table.page.orientation"), orientation);
let rows = scale_rows(rows, &this, scale, cx);
let mut card = Card::new("table-page", label("table.page.title"), dismiss);
// The page style may govern other sheets too.
if layout.sheets.len() > 1 {
let sheets = layout.sheets.join(", ");
card = card.detail(Book::text(cx, "table.page.shared", &[("sheets", &sheets)]));
}
let go = label("table.page.apply");
card.child(rows).action(ely_office::cancel(cx)).action(move |_| {
Button::new("table-page-go", go)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| this.update(cx, |book, cx| book.page_asked(cx)))
})
}
+166
View File
@@ -0,0 +1,166 @@
//! Sheet protection and locked cells (TABLE-15).
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
forms::{Input, Switch, TextInput},
overlays::Dialog as Card,
primitives::Severity,
};
use ely_lok::sheet::{self, PROTECT_OPTIONS, Protection};
use ely_office::prelude::*;
use gpui::{
App, AppContext as _, Context, Div, Entity, InteractiveElement as _, ParentElement as _,
Styled as _, Window, actions, div,
};
use crate::book::Book;
use crate::dialog::{Dialog, edit};
actions!(table, [ProtectSheet, LockCells, UnlockCells]);
pub fn bind(root: Div, cx: &mut Context<Book>) -> Div {
root.on_action(cx.listener(|book, _: &ProtectSheet, _, cx| book.ask_protect(cx)))
.on_action(cx.listener(|book, _: &LockCells, _, cx| book.run_active("cells.lock", &["true"], cx)))
.on_action(
cx.listener(|book, _: &UnlockCells, _, cx| book.run_active("cells.lock", &["false"], cx)),
)
}
/// The protection dialog's state.
pub struct Protecting {
pub now: Protection,
pub password: Entity<TextInput>,
pub confirm: Entity<TextInput>,
pub allow: Vec<String>,
}
impl Book {
pub(crate) fn ask_protect(&mut self, cx: &mut Context<Self>) {
let Some(id) = self.core.active else {
return self.warn_key("command.blocked.no_document", cx);
};
if !self.settled(id, cx) {
return self.when_settled(id, |book, _, cx| book.ask_protect(cx));
}
self.ask_in(
id,
|worker, doc| sheet::protection(&worker.values(doc, sheet::PROTECTION)?),
move |book, read, window, cx| match read {
Ok(now) => {
let mut masked = |key: &str, cx: &mut Context<Self>| {
let name = Book::text(cx, key, &[]);
cx.new(|cx| TextInput::new(window, cx).masked().label(name))
};
let protecting = Protecting {
allow: now.allow.clone(),
password: masked("table.protect.password", cx),
confirm: masked("table.protect.confirm", cx),
now,
};
book.dialog = Some(Dialog::Protect {
tab: id,
protecting: Box::new(protecting),
});
cx.notify();
}
Err(error) => book.engine_failed(&error.to_string(), cx),
},
cx,
);
}
/// Protects or unprotects as the dialog says.
pub(crate) fn protect_asked(&mut self, cx: &mut Context<Self>) {
let Some(Dialog::Protect { tab, protecting }) = &self.dialog else {
return;
};
let (tab, password) = (*tab, protecting.password.read(cx).text().to_string());
if !protecting.now.protected {
if password != protecting.confirm.read(cx).text() {
return self.warn_key("table.protect.mismatch", cx);
}
let json = sheet::protect_json(&password, &protecting.allow);
log::info!("table: protects tab {tab}, allows {:?}", protecting.allow);
self.dialog = None;
cx.notify();
return self.run_in(Some(tab), "sheet.protect", &[&json], cx);
}
self.dialog = None;
cx.notify();
log::info!("table: unprotects tab {tab}");
let json = sheet::unprotect_json(&password);
self.read_in(
tab,
move |worker, doc| match worker.run(doc, "sheet.protect", &[&json]) {
Ok(()) => Ok(true),
// Still protected: the password was wrong.
Err(error) => match sheet::protection(&worker.values(doc, sheet::PROTECTION)?)? {
Protection { protected: true, .. } => Ok(false),
_ => Err(error),
},
},
|book, done, _, cx| match done {
Ok(true) => {}
Ok(false) => {
let text = Book::text(cx, "table.protect.wrong", &[]);
book.toast(text, Severity::Danger, cx);
}
Err(error) => book.engine_failed(&error.to_string(), cx),
},
cx,
);
}
}
/// Protects with options, or asks the password back.
pub fn protect_dialog(
this: Entity<Book>,
protecting: &Protecting,
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let label = |key: &str| Book::text(cx, key, &[]);
let input = |field| div().w_full().max_w_48().child(Input::new(field));
let protected = protecting.now.protected;
let (title, go) = match protected {
true => ("table.protect.off_title", "table.protect.off"),
false => ("table.protect.on_title", "table.protect.on"),
};
let mut card = Card::new("table-protect", label(title), dismiss);
if !protected {
let switches = PROTECT_OPTIONS.iter().enumerate().map(|(at, option)| {
let toggling = this.clone();
let on = protecting.allow.iter().any(|allowed| allowed == option);
Switch::new(("table-protect-allow", at), on)
.label(label(&format!("table.protect.{option}")))
.on_change(move |on, _, cx| {
edit(&toggling, cx, |dialog, _, _| {
if let Dialog::Protect { protecting, .. } = dialog {
protecting.allow.retain(|allowed| allowed != option);
if on {
protecting.allow.push(option.to_string());
}
}
})
})
});
card = card
.child(
DescriptionList::new()
.item(label("table.protect.password"), input(&protecting.password))
.item(label("table.protect.confirm"), input(&protecting.confirm)),
)
.child(div().flex().flex_col().gap_2().children(switches));
} else if protecting.now.password {
card = card.child(
DescriptionList::new().item(label("table.protect.password"), input(&protecting.password)),
);
}
let go = label(go);
card.action(ely_office::cancel(cx)).action(move |_| {
Button::new("table-protect-go", go)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| this.update(cx, |book, cx| book.protect_asked(cx)))
})
}
+379
View File
@@ -0,0 +1,379 @@
//! Rules: validation, choices, input help, conditional formats (TABLE-09).
use ely_gpui_component::{forms::TextInput, primitives::Severity};
use ely_lok::{
LokView, Request,
sheet::{self, Validation},
};
use ely_office::prelude::*;
use gpui::{AppContext as _, Context, Div, Entity, InteractiveElement as _, Window, actions};
use crate::book::{Book, Spot};
use crate::dialog::Dialog;
mod aids;
mod dialogs;
pub use aids::Choices;
pub use dialogs::{Shown, conditional_dialog, formats_dialog, validation_dialog};
actions!(table, [Validate, Unvalidate, Conditional, Conditionals]);
pub fn bind(root: Div, cx: &mut Context<Book>) -> Div {
root.on_action(cx.listener(|book, _: &Validate, _, cx| book.ask_rule(cx)))
.on_action(cx.listener(|book, _: &Unvalidate, _, cx| book.clear_rule(cx)))
.on_action(
cx.listener(|book, _: &Conditional, window, cx| book.ask_conditional(window, cx)),
)
.on_action(cx.listener(|book, _: &Conditionals, _, cx| book.ask_formats(cx)))
}
/// The text fields of the validation dialog.
pub struct Fields {
pub value1: Entity<TextInput>,
pub value2: Entity<TextInput>,
pub list: Entity<TextInput>,
pub input_title: Entity<TextInput>,
pub input_message: Entity<TextInput>,
pub error_title: Entity<TextInput>,
pub error_message: Entity<TextInput>,
}
/// List entries, one per line; a range starts "=".
fn list_text(value1: &str) -> String {
if !value1.starts_with('"') {
return format!("={value1}");
}
let mut entries = Vec::new();
let mut chars = value1.chars().peekable();
while let Some(c) = chars.next() {
if c != '"' {
continue;
}
let mut entry = String::new();
while let Some(c) = chars.next() {
match (c, chars.peek()) {
('"', Some('"')) => {
entry.push('"');
chars.next();
}
('"', _) => break,
(c, _) => entry.push(c),
}
}
entries.push(entry);
}
entries.join("\n")
}
/// No rule on the cell: the dialog's starting point.
fn unrestricted() -> Validation {
Validation {
kind: "any".into(),
operator: "between".into(),
dropdown: true,
ignore_blank: true,
show_error: true,
error_style: "stop".into(),
..Validation::default()
}
}
impl Book {
/// Opens the validation of the current cell.
pub(crate) fn ask_rule(&mut self, cx: &mut Context<Self>) {
let Some(id) = self.core.active else {
let text = Book::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
self.rule_in(id, cx);
}
/// The validation dialog for tab `id`, once its input lands.
pub(crate) fn rule_in(&mut self, id: u64, cx: &mut Context<Self>) {
if !self.settled(id, cx) {
return self.when_settled(id, move |book, _, cx| book.rule_in(id, cx));
}
self.ask_in(
id,
|worker, doc| sheet::validation(&worker.values(doc, sheet::VALIDATION)?),
move |book, read, window, cx| match read {
Ok(rule) => book.rule_dialog(id, rule, window, cx),
Err(error) => book.engine_failed(&error.to_string(), cx),
},
cx,
);
}
fn rule_dialog(
&mut self,
id: u64,
rule: Validation,
window: &mut Window,
cx: &mut Context<Self>,
) {
let mut field = |text: &str, rows: Option<(usize, usize)>, cx: &mut Context<Self>| {
let text = text.to_string();
cx.new(|cx| {
let input = TextInput::new(window, cx);
let mut input = match rows {
Some((min, max)) => input.multi_line(min, max),
None => input,
};
input.set_text(text, cx);
input
})
};
let list = if rule.kind == "list" {
list_text(&rule.value1)
} else {
String::new()
};
let fields = Fields {
value1: field(&rule.value1, None, cx),
value2: field(&rule.value2, None, cx),
list: field(&list, Some((3, 8)), cx),
input_title: field(&rule.input_title, None, cx),
input_message: field(&rule.input_message, Some((2, 4)), cx),
error_title: field(&rule.error_title, None, cx),
error_message: field(&rule.error_message, Some((2, 4)), cx),
};
// A cell without a rule starts from the defaults.
let rule = if rule.operator.is_empty() {
unrestricted()
} else {
rule
};
self.dialog = Some(Dialog::Validation {
tab: id,
rule: Box::new(rule),
fields: Box::new(fields),
});
cx.notify();
}
/// Removes the selection's validation.
pub(crate) fn clear_rule(&mut self, cx: &mut Context<Self>) {
let rule = unrestricted().to_json();
self.run_active("rules.validation", &[&rule], cx);
}
/// Sets the validation the dialog describes.
pub(crate) fn rule_asked(&mut self, cx: &mut Context<Self>) {
let Some(Dialog::Validation { tab, rule, fields }) = self.dialog.take() else {
return;
};
let text = |field: &Entity<TextInput>| field.read(cx).text().to_string();
let mut rule = Validation {
value1: text(&fields.value1).trim().to_string(),
value2: text(&fields.value2).trim().to_string(),
input_title: text(&fields.input_title),
input_message: text(&fields.input_message),
error_title: text(&fields.error_title),
error_message: text(&fields.error_message),
..*rule
};
if rule.kind == "list" {
let list = text(&fields.list);
match list.trim().strip_prefix('=') {
Some(source) => rule.value1 = source.trim().to_string(),
None => {
rule.value1.clear();
rule.list = list
.lines()
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(str::to_string)
.collect();
}
}
}
self.run_in(Some(tab), "rules.validation", &[&rule.to_json()], cx);
}
/// Takes the kernel's new request on tab `id`.
pub(crate) fn requested(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
let cell = view.read(cx).cell();
let (count, request) = (cell.requests, cell.request.clone());
let Some(tab) = self.tab_mut(id) else {
return;
};
if tab.extra.requests == count {
return;
}
tab.extra.requests = count;
match request {
Some(request @ Request::Filter { .. }) => self.ask_column(id, &request, cx),
Some(Request::Choices { column, row }) => self.offer_choices(id, (column, row), cx),
Some(Request::Invalid {
title,
message,
style,
accepted,
}) => {
let fate = if accepted { "noted" } else { "refused" };
log::info!("table: a {style} entry was {fate}");
let severity = match style.as_str() {
"info" => Severity::Info,
"warning" => Severity::Warning,
_ => Severity::Danger,
};
// The document's own words: shown, never logged.
if let Some(root) = self.core.root.upgrade() {
let text: gpui::SharedString = format!("{title}: {message}").into();
root.update(cx, |root, cx| root.toast(text, severity, cx));
}
}
None => {}
}
}
/// Rereads the current cell's rule after the cursor moves.
pub(crate) fn follow_rule(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
let read = view.read(cx);
let spot: Spot = (
read.cell().sheets,
read.cell().address.clone(),
read.edited(),
);
let Some(tab) = self.tab(id) else {
return;
};
let asked = tab.extra.ruled.as_ref() == Some(&spot);
if spot.1.is_empty() || tab.extra.ruling || asked {
return;
}
let doc = tab.doc.clone();
// Asked now: a failure waits for the next change.
let extra = &mut self.tab_mut(id).expect("the tab is open").extra;
(extra.ruling, extra.ruled) = (true, Some(spot.clone()));
ely_lok::read_off(
&self.core.worker,
&doc,
|worker, doc| sheet::validation(&worker.values(doc, sheet::VALIDATION)?),
move |book: &mut Self, read, cx| {
let Some(tab) = book.tab_mut(id) else {
return;
};
tab.extra.ruling = false;
let view = tab.view.clone();
match read {
Ok(rule) => tab.extra.rule = Some((spot, rule)),
Err(error) => book.engine_failed(&error.to_string(), cx),
}
cx.notify();
// The cursor may have moved meanwhile.
book.follow_rule(id, &view, cx);
},
cx,
);
}
pub(crate) fn ask_conditional(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(id) = self.core.active else {
let text = Book::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
let value1 = cx.new(|cx| TextInput::new(window, cx));
let value2 = cx.new(|cx| TextInput::new(window, cx));
self.dialog = Some(Dialog::Conditional {
tab: id,
kind: "cell",
operator: "greater",
style: "Good",
preset: 0,
value1,
value2,
});
cx.notify();
}
/// Adds the conditional format the dialog describes.
pub(crate) fn conditional_asked(&mut self, cx: &mut Context<Self>) {
let Some(Dialog::Conditional {
tab,
kind,
operator,
style,
preset,
value1,
value2,
}) = self.dialog.take()
else {
return;
};
let rule = match kind {
"scale" => serde_json::json!({ "kind": "scale", "colors": dialogs::SCALES[preset].1 }),
"databar" => serde_json::json!({ "kind": "databar", "color": dialogs::BARS[preset].1 }),
_ => serde_json::json!({
"kind": "cell",
"operator": operator,
"style": style,
"value1": value1.read(cx).text().trim(),
"value2": value2.read(cx).text().trim(),
}),
};
self.run_in(Some(tab), "rules.conditional", &[&rule.to_string()], cx);
}
/// Lists the shown sheet's conditional formats.
pub(crate) fn ask_formats(&mut self, cx: &mut Context<Self>) {
let Some(id) = self.core.active else {
let text = Book::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
self.formats_in(id, cx);
}
/// The format list of tab `id`, once its input lands.
pub(crate) fn formats_in(&mut self, id: u64, cx: &mut Context<Self>) {
if !self.settled(id, cx) {
return self.when_settled(id, move |book, _, cx| book.formats_in(id, cx));
}
self.ask_in(
id,
|worker, doc| sheet::formats(&worker.values(doc, sheet::FORMATS)?),
move |book, read, _, cx| match read {
Ok(formats) if formats.is_empty() => {
let text = Book::text(cx, "table.rules.no_formats", &[]);
book.toast(text, Severity::Info, cx);
}
Ok(formats) => {
book.dialog = Some(Dialog::Formats {
tab: id,
formats,
chosen: 0,
});
cx.notify();
}
Err(error) => book.engine_failed(&error.to_string(), cx),
},
cx,
);
}
/// Removes the chosen conditional format.
pub(crate) fn format_removed(&mut self, cx: &mut Context<Self>) {
let Some(Dialog::Formats {
tab,
formats,
chosen,
}) = self.dialog.take()
else {
return;
};
let remove = serde_json::json!({ "remove": formats[chosen].key }).to_string();
self.run_in(Some(tab), "rules.conditional", &[&remove], cx);
}
}
#[cfg(test)]
mod tests {
#[test]
fn list_sources_read_as_lines() {
assert_eq!(
super::list_text(r#""Red","Green ""G""""#),
"Red\nGreen \"G\""
);
assert_eq!(super::list_text("$A$1:$A$3"), "=$A$1:$A$3");
}
}
+154
View File
@@ -0,0 +1,154 @@
//! The cell's aids: the choices menu, input help, list button.
use ely_gpui_component::{
buttons::{ButtonVariant, IconButton},
menus::{ContextMenu, Menu as Popup, MenuItem as PopupItem},
primitives::{IconName, raise},
theme::{ActiveTheme as _, ControlSize, Elevation, Radius, TextSize},
};
use ely_lok::{LokView, sheet};
use ely_office::prelude::*;
use gpui::{
AnyElement, Context, Entity, IntoElement, ParentElement as _, Pixels, Point, Styled as _, div,
px,
};
use crate::book::Book;
/// The open choices menu: tab, values, request.
pub struct Choices {
tab: u64,
pub(crate) values: Vec<String>,
request: (u64, Point<Pixels>),
}
impl Book {
/// A cell's validation list, as a menu below.
pub(crate) fn offer_choices(
&mut self,
id: u64,
(column, row): (u32, u32),
cx: &mut Context<Self>,
) {
self.ask_in(
id,
move |worker, doc| {
let json = worker.values(doc, &sheet::cell_query(sheet::LIST, column, row))?;
sheet::list_values(&json)
},
move |book, read, _, cx| {
let values = match read {
Ok(values) => values,
Err(error) => return book.engine_failed(&error.to_string(), cx),
};
let Some(tab) = book.tab(id) else {
return;
};
let geometry = &tab.extra.geometry;
let bottom = (geometry.columns.start(column), geometry.rows.start(row + 1));
let at = tab.view.read(cx).window_point(bottom);
let number = book
.choices
.as_ref()
.map_or(0, |choices| choices.request.0 + 1);
book.choices = Some(Choices {
tab: id,
values,
request: (number, at),
});
cx.notify();
},
cx,
);
}
/// The choices menu and the help of the current cell.
pub(crate) fn cell_aids(
&self,
view: &Entity<LokView>,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
let Some(tab) = self.active_tab() else {
return Vec::new();
};
let mut aids = Vec::new();
if let Some(choices) = self
.choices
.as_ref()
.filter(|choices| choices.tab == tab.id)
{
let this = cx.entity();
let menu = choices.values.iter().fold(Popup::new(), |menu, value| {
let (this, value) = (this.clone(), value.clone());
menu.item(PopupItem::new(value.clone()).on_click(move |_, cx| {
this.update(cx, |book, cx| book.run_active("cell.enter", &[&value], cx))
}))
});
aids.push(
ContextMenu::new("table-choices", menu)
.manual(Some(choices.request))
.into_any_element(),
);
}
let read = view.read(cx);
let (Some((at, rule)), Some((x, y, w, h))) = (&tab.extra.rule, read.cell().cursor) else {
return aids;
};
if at.1 != read.cell().address {
return aids;
}
if rule.kind == "list" && rule.dropdown {
let corner = read.place((x + w, y));
let (column, row) = (
tab.extra.geometry.columns.at(x),
tab.extra.geometry.rows.at(y),
);
let (id, size) = (
tab.id,
f32::from(read.place((x, y + h)).y - read.place((x, y)).y),
);
aids.push(
div()
.absolute()
.left(corner.x)
.top(corner.y)
.child(
IconButton::new("table-choices-open", IconName::ChevronDown)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(Book::text(cx, "table.rules.choose", &[]))
.on_click(cx.listener(move |book, _, _, cx| {
book.offer_choices(id, (column, row), cx)
})),
)
.h(px(size.max(16.0)))
.into_any_element(),
);
}
if rule.show_input && !rule.input_message.is_empty() {
let below = read.place((x, y + h));
let theme = cx.theme();
let title = (!rule.input_title.is_empty()).then(|| {
div()
.font_weight(gpui::FontWeight::SEMIBOLD)
.child(rule.input_title.clone())
});
let note = div()
.absolute()
.left(below.x)
.top(below.y)
.max_w(theme.tooltip_max_width())
.px_2()
.py_1()
.rounded(theme.radius(Radius::Md))
.bg(theme.colors.tooltip_bg)
.text_color(theme.colors.tooltip_fg)
.text_size(theme.text_size(TextSize::Sm))
.shadow(theme.elevation(Elevation::Floating))
.children(title)
.child(rule.input_message.clone());
aids.push(raise("table-input-help", note).into_any_element());
}
aids
}
}
+448
View File
@@ -0,0 +1,448 @@
//! The rule dialogs: validation, conditional format, the format list.
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
forms::{Choice, Input, Select, Switch, TextInput},
overlays::Dialog as Card,
};
use ely_lok::sheet::{Format, Validation};
use gpui::{App, Entity, ParentElement as _, SharedString, Styled as _, Window, div};
use super::Fields;
use crate::book::Book;
use crate::dialog::{Dialog, edit};
/// What a cell may hold.
const KINDS: [(&str, &str); 8] = [
("any", "table.rules.any"),
("whole", "table.rules.whole"),
("decimal", "table.rules.decimal"),
("date", "table.rules.date"),
("time", "table.rules.time"),
("textlength", "table.rules.textlength"),
("list", "table.rules.list"),
("custom", "table.rules.custom"),
];
const OPERATORS: [(&str, &str); 8] = [
("between", "table.rules.between"),
("notbetween", "table.rules.notbetween"),
("equal", "table.rules.equal"),
("notequal", "table.rules.notequal"),
("greater", "table.rules.greater"),
("less", "table.rules.less"),
("greaterequal", "table.rules.greaterequal"),
("lessequal", "table.rules.lessequal"),
];
const ALERTS: [(&str, &str); 3] = [
("stop", "table.rules.stop"),
("warning", "table.rules.warning"),
("info", "table.rules.info"),
];
/// Conditions a cell rule tests; more than validation has.
const CONDITIONS: [(&str, &str); 18] = [
("greater", "table.rules.greater"),
("less", "table.rules.less"),
("between", "table.rules.between"),
("notbetween", "table.rules.notbetween"),
("equal", "table.rules.equal"),
("notequal", "table.rules.notequal"),
("greaterequal", "table.rules.greaterequal"),
("lessequal", "table.rules.lessequal"),
("duplicate", "table.rules.duplicate"),
("unique", "table.rules.unique"),
("contains", "table.rules.contains"),
("beginswith", "table.rules.beginswith"),
("endswith", "table.rules.endswith"),
("top10", "table.rules.top"),
("bottom10", "table.rules.bottom"),
("aboveaverage", "table.rules.above_average"),
("belowaverage", "table.rules.below_average"),
("error", "table.rules.error"),
];
/// The kernel's cell styles a rule can apply.
const STYLES: [(&str, &str); 10] = [
("Good", "table.style.good"),
("Neutral", "table.style.neutral"),
("Bad", "table.style.bad"),
("Warning", "table.style.warning"),
("Error", "table.style.error"),
("Accent", "table.style.accent"),
("Accent 1", "table.style.accent1"),
("Accent 2", "table.style.accent2"),
("Accent 3", "table.style.accent3"),
("Note", "table.style.note"),
];
const FORMATS: [(&str, &str); 3] = [
("cell", "table.rules.cell"),
("scale", "table.rules.scale"),
("databar", "table.rules.databar"),
];
/// Color scales: label, low to high.
pub(super) const SCALES: [(&str, &[&str]); 4] = [
("table.rules.scale_rgy", &["#F8696B", "#FFEB84", "#63BE7B"]),
("table.rules.scale_gyr", &["#63BE7B", "#FFEB84", "#F8696B"]),
("table.rules.scale_wr", &["#FFFFFF", "#F8696B"]),
("table.rules.scale_wg", &["#FFFFFF", "#63BE7B"]),
];
/// Data bar colors.
pub(super) const BARS: [(&str, &str); 4] = [
("table.rules.bar_blue", "#638EC6"),
("table.rules.bar_green", "#63C384"),
("table.rules.bar_red", "#FF555A"),
("table.rules.bar_orange", "#FFB628"),
];
/// The table's value for a picked choice.
fn find(table: &[(&'static str, &'static str)], value: &str) -> &'static str {
table
.iter()
.find(|(key, _)| *key == value)
.map(|(key, _)| *key)
.expect("a listed choice")
}
/// A select over `table`; `set` takes the pick.
fn pick(
this: &Entity<Book>,
id: &'static str,
(table, selected): (&[(&'static str, &'static str)], &str),
set: fn(&mut Dialog, &'static str),
cx: &App,
) -> Select {
let choices = table
.iter()
.map(|(value, key)| Choice::new(*value, Book::text(cx, key, &[])));
let (this, table) = (this.clone(), table.to_vec());
Select::new(id, choices)
.selected(selected.to_string())
.on_change(move |value, _, cx| {
let value = find(&table, value);
edit(&this, cx, |dialog, _, _| set(dialog, value))
})
}
/// A switch; `set` takes its state.
fn switch(
this: &Entity<Book>,
id: &'static str,
on: bool,
set: fn(&mut Validation, bool),
) -> Switch {
let this = this.clone();
Switch::new(id, on).on_change(move |on, _, cx| {
edit(&this, cx, |dialog, _, _| {
if let Dialog::Validation { rule, .. } = dialog {
set(rule, on);
}
})
})
}
fn field(input: &Entity<TextInput>) -> gpui::Div {
div().w_full().max_w_64().child(Input::new(input))
}
fn rule_of(dialog: &mut Dialog) -> &mut Validation {
match dialog {
Dialog::Validation { rule, .. } => rule,
_ => unreachable!("a validation dialog"),
}
}
pub fn validation_dialog(
this: Entity<Book>,
(rule, fields): (&Validation, &Fields),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let label = |key: &str| Book::text(cx, key, &[]);
let mut rows = DescriptionList::new().item(
label("table.rules.allow"),
pick(
&this,
"table-rule-kind",
(&KINDS, &rule.kind),
|dialog, kind| rule_of(dialog).kind = kind.into(),
cx,
),
);
match rule.kind.as_str() {
"whole" | "decimal" | "date" | "time" | "textlength" => {
rows = rows
.item(
label("table.rules.data"),
pick(
&this,
"table-rule-operator",
(&OPERATORS, &rule.operator),
|dialog, op| rule_of(dialog).operator = op.into(),
cx,
),
)
.item(label("table.rules.value"), field(&fields.value1));
if matches!(rule.operator.as_str(), "between" | "notbetween") {
rows = rows.item(label("table.rules.and"), field(&fields.value2));
}
}
"list" => {
rows = rows
.item(label("table.rules.entries"), field(&fields.list))
.item(
label("table.rules.dropdown"),
switch(&this, "table-rule-dropdown", rule.dropdown, |rule, on| {
rule.dropdown = on
}),
);
}
"custom" => rows = rows.item(label("table.rules.formula"), field(&fields.value1)),
_ => {}
}
if rule.kind != "any" {
rows = rows.item(
label("table.rules.blank"),
switch(&this, "table-rule-blank", rule.ignore_blank, |rule, on| {
rule.ignore_blank = on
}),
);
}
rows = rows.item(
label("table.rules.help"),
switch(&this, "table-rule-help", rule.show_input, |rule, on| {
rule.show_input = on
}),
);
if rule.show_input {
rows = rows
.item(label("table.rules.title"), field(&fields.input_title))
.item(label("table.rules.message"), field(&fields.input_message));
}
if rule.kind != "any" {
rows = rows.item(
label("table.rules.alert"),
switch(&this, "table-rule-alert", rule.show_error, |rule, on| {
rule.show_error = on
}),
);
if rule.show_error {
rows = rows
.item(
label("table.rules.style"),
pick(
&this,
"table-rule-style",
(&ALERTS, &rule.error_style),
|dialog, style| rule_of(dialog).error_style = style.into(),
cx,
),
)
.item(label("table.rules.title"), field(&fields.error_title))
.item(label("table.rules.message"), field(&fields.error_message));
}
}
let go = label("table.rules.apply");
Card::new("table-rule", label("table.rules.validation_title"), dismiss)
.child(rows)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("table-rule-go", go)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| this.update(cx, |book, cx| book.rule_asked(cx)))
})
}
/// The open conditional dialog's parts.
pub struct Shown<'a> {
pub kind: &'a str,
pub operator: &'a str,
pub style: &'a str,
pub preset: usize,
pub value1: &'a Entity<TextInput>,
pub value2: &'a Entity<TextInput>,
}
fn set_conditional(
dialog: &mut Dialog,
change: impl FnOnce(&mut &'static str, &mut &'static str, &mut &'static str, &mut usize),
) {
if let Dialog::Conditional {
kind,
operator,
style,
preset,
..
} = dialog
{
change(kind, operator, style, preset);
}
}
pub fn conditional_dialog(
this: Entity<Book>,
shown: Shown,
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let label = |key: &str| Book::text(cx, key, &[]);
let mut rows = DescriptionList::new().item(
label("table.rules.kind"),
pick(
&this,
"table-format-kind",
(&FORMATS, shown.kind),
|dialog, value| {
set_conditional(dialog, |kind, _, _, preset| {
*kind = value;
*preset = 0;
})
},
cx,
),
);
match shown.kind {
"scale" | "databar" => {
let table: Vec<(SharedString, SharedString)> = if shown.kind == "scale" {
SCALES
.iter()
.enumerate()
.map(|(at, (key, _))| (at.to_string().into(), label(key)))
.collect()
} else {
BARS.iter()
.enumerate()
.map(|(at, (key, _))| (at.to_string().into(), label(key)))
.collect()
};
let picked = this.clone();
let presets = Select::new(
"table-format-preset",
table
.into_iter()
.map(|(value, text)| Choice::new(value, text)),
)
.selected(shown.preset.to_string())
.on_change(move |value, _, cx| {
let at: usize = value.parse().expect("presets are indexes");
edit(&picked, cx, |dialog, _, _| {
set_conditional(dialog, |_, _, _, preset| *preset = at)
})
});
rows = rows.item(label("table.rules.colors"), presets);
}
_ => {
rows = rows.item(
label("table.rules.condition"),
pick(
&this,
"table-format-operator",
(&CONDITIONS, shown.operator),
|dialog, value| set_conditional(dialog, |_, operator, _, _| *operator = value),
cx,
),
);
match shown.operator {
"duplicate" | "unique" | "aboveaverage" | "belowaverage" | "error" => {}
"between" | "notbetween" => {
rows = rows
.item(label("table.rules.value"), field(shown.value1))
.item(label("table.rules.and"), field(shown.value2));
}
_ => rows = rows.item(label("table.rules.value"), field(shown.value1)),
}
rows = rows.item(
label("table.rules.cell_style"),
pick(
&this,
"table-format-style",
(&STYLES, shown.style),
|dialog, value| set_conditional(dialog, |_, _, style, _| *style = value),
cx,
),
);
}
}
let go = label("table.rules.add");
Card::new(
"table-format",
label("table.rules.conditional_title"),
dismiss,
)
.child(rows)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("table-format-go", go)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| this.update(cx, |book, cx| book.conditional_asked(cx)))
})
}
/// One format as the list shows it.
fn entry(format: &Format, cx: &App) -> SharedString {
let kind = FORMATS
.iter()
.find(|(value, _)| *value == format.kind)
.map_or_else(
|| format.kind.clone().into(),
|(_, key)| Book::text(cx, key, &[]),
);
let style = STYLES
.iter()
.find(|(value, _)| *value == format.style)
.map_or_else(
|| format.style.clone().into(),
|(_, key)| Book::text(cx, key, &[]),
);
let args = [
("range", format.range.as_str()),
("kind", kind.as_ref()),
("style", style.as_ref()),
("value", format.value.as_str()),
];
match format.kind.as_str() {
"cell" => Book::text(cx, "table.rules.entry_cell", &args),
_ => Book::text(cx, "table.rules.entry", &args),
}
}
pub fn formats_dialog(
this: Entity<Book>,
(formats, chosen): (&[Format], usize),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let choices = formats
.iter()
.enumerate()
.map(|(at, format)| Choice::new(at.to_string(), entry(format, cx)));
let picked = this.clone();
let list = Select::new("table-formats-pick", choices)
.selected(chosen.to_string())
.on_change(move |value, _, cx| {
let at: usize = value.parse().expect("formats are listed by index");
edit(&picked, cx, |dialog, _, _| {
if let Dialog::Formats { chosen, .. } = dialog {
*chosen = at;
}
})
});
let go = Book::text(cx, "table.rules.remove", &[]);
Card::new(
"table-formats",
Book::text(cx, "table.rules.formats_title", &[]),
dismiss,
)
.child(list)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("table-formats-remove", go)
.variant(ButtonVariant::Danger)
.on_click(move |_, _, cx| this.update(cx, |book, cx| book.format_removed(cx)))
})
}
+358
View File
@@ -0,0 +1,358 @@
//! Sheet tabs and the workbook's sheet commands (TABLE-01).
use std::io;
use ely_gpui_component::{
buttons::IconButton,
forms::{Choice, TextInput},
menus::{ContextMenu, Menu as Popup, MenuItem as PopupItem},
navigation::{TabPlacement, Tabs as SheetTabs},
primitives::{IconName, Severity},
};
use ely_office::prelude::*;
use gpui::{
Action, AnyElement, App, AppContext as _, Context, Div, InteractiveElement as _, IntoElement,
Menu, MenuItem, ParentElement as _, Styled as _, Window, actions, div,
};
use crate::book::Book;
use crate::dialog::Dialog;
actions!(
table,
[
InsertSheet,
RenameSheet,
DuplicateSheet,
MoveSheetLeft,
MoveSheetRight,
HideSheet,
ShowSheet,
DeleteSheet
]
);
pub fn menu(cx: &App) -> Menu {
let label = |key: &str| Book::text(cx, key, &[]);
Menu::new(label("menu.sheet")).items([
MenuItem::action(label("table.sheet.insert"), InsertSheet),
MenuItem::action(label("table.sheet.rename"), RenameSheet),
MenuItem::action(label("table.sheet.duplicate"), DuplicateSheet),
MenuItem::separator(),
MenuItem::action(label("table.sheet.left"), MoveSheetLeft),
MenuItem::action(label("table.sheet.right"), MoveSheetRight),
MenuItem::separator(),
MenuItem::action(label("table.sheet.hide"), HideSheet),
MenuItem::action(label("table.sheet.show"), ShowSheet),
MenuItem::separator(),
MenuItem::action(label("table.protect.menu"), crate::protect::ProtectSheet),
MenuItem::separator(),
MenuItem::action(label("table.sheet.delete"), DeleteSheet),
])
}
/// The same commands on a right click.
fn popup(cx: &App) -> Popup {
let item = |key: &str, action: Box<dyn Action>| {
PopupItem::new(Book::text(cx, key, &[]))
.on_click(move |window, cx| window.dispatch_action(action.boxed_clone(), cx))
};
Popup::new()
.item(item("table.sheet.insert", Box::new(InsertSheet)))
.item(item("table.sheet.rename", Box::new(RenameSheet)))
.item(item("table.sheet.duplicate", Box::new(DuplicateSheet)))
.separator()
.item(item("table.sheet.left", Box::new(MoveSheetLeft)))
.item(item("table.sheet.right", Box::new(MoveSheetRight)))
.separator()
.item(item("table.sheet.hide", Box::new(HideSheet)))
.item(item("table.sheet.show", Box::new(ShowSheet)))
.separator()
.item(item("table.sheet.delete", Box::new(DeleteSheet)))
}
pub(crate) mod dialogs;
pub fn bind(root: Div, cx: &mut Context<Book>) -> Div {
root.on_action(cx.listener(|book, _: &InsertSheet, _, cx| book.insert_sheet(cx)))
.on_action(cx.listener(|book, _: &RenameSheet, window, cx| book.ask_rename(window, cx)))
.on_action(cx.listener(|book, _: &DuplicateSheet, _, cx| book.duplicate_sheet(cx)))
.on_action(cx.listener(|book, _: &MoveSheetLeft, _, cx| book.move_sheet(false, cx)))
.on_action(cx.listener(|book, _: &MoveSheetRight, _, cx| book.move_sheet(true, cx)))
.on_action(cx.listener(|book, _: &HideSheet, _, cx| book.hide_sheet(cx)))
.on_action(cx.listener(|book, _: &ShowSheet, _, cx| book.ask_show(cx)))
.on_action(
cx.listener(|book, _: &DeleteSheet, _, cx| book.ask_delete(book.core.active, cx)),
)
}
impl Book {
/// The shown sheet, its name, and every name.
fn current_sheet(&self) -> Option<(usize, String, Vec<String>)> {
self.sheet_in(self.core.active?)
}
/// Tab `id`: shown sheet, its name, every name.
pub(crate) fn sheet_in(&self, id: u64) -> Option<(usize, String, Vec<String>)> {
let sheets = &self.tab(id)?.extra.sheets;
let names: Vec<String> = sheets.list.iter().map(|sheet| sheet.name.clone()).collect();
Some((sheets.current, names.get(sheets.current)?.clone(), names))
}
fn warn(&self, key: &str, cx: &mut Context<Self>) {
let text = Book::text(cx, key, &[]);
self.toast(text, Severity::Warning, cx);
}
/// The visible sheets as tabs, with an add button.
pub fn sheet_strip(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(tab) = self.active_tab() else {
return div().into_any_element();
};
let Some(choices) = visible(&tab.extra.sheets) else {
return div().into_any_element();
};
let this = cx.entity();
let picked = this.clone();
let tabs = SheetTabs::new(
"table-sheets",
choices,
tab.extra.sheets.current.to_string(),
)
.placement(TabPlacement::Bottom)
.on_change(move |value, _, cx| {
let index: usize = value.parse().expect("sheet tabs are indexes");
picked.update(cx, |book, cx| book.show_sheet(index, cx));
})
.on_context(move |value, at, _, cx| {
let index: usize = value.parse().expect("sheet tabs are indexes");
this.update(cx, |book, cx| {
book.show_sheet(index, cx);
book.sheet_menu = Some((book.sheet_menu.map_or(0, |(n, _)| n + 1), at));
cx.notify();
});
});
let add = IconButton::new("table-sheet-add", IconName::Plus)
.tooltip(Book::text(cx, "table.sheet.add", &[]))
.on_click(cx.listener(|book, _, _, cx| book.insert_sheet(cx)));
ContextMenu::new("table-sheet-menu", popup(cx))
.manual(self.sheet_menu)
.child(div().flex().items_center().px_2().child(tabs).child(add))
.into_any_element()
}
/// Shows sheet `index` in order with the queued input.
pub(crate) fn show_sheet(&mut self, index: usize, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return;
};
let (id, view) = (tab.id, tab.view.clone());
view.update(cx, |view, _| view.show_part(index));
self.tab_mut(id)
.expect("the tab is open")
.extra
.sheets
.current = index;
cx.notify();
}
fn insert_sheet(&mut self, cx: &mut Context<Self>) {
let Some((current, _, names)) = self.current_sheet() else {
return self.warn("command.blocked.no_document", cx);
};
let name = (1..)
.map(|n| Book::text(cx, "table.sheet.default", &[("n", &n.to_string())]))
.find(|name| !names.iter().any(|taken| taken == name.as_ref()))
.expect("a free name");
let at = (current + 2).to_string();
self.run_active("sheet.insert", &[&name, &at], cx);
}
fn duplicate_sheet(&mut self, cx: &mut Context<Self>) {
let Some((current, ..)) = self.current_sheet() else {
return self.warn("command.blocked.no_document", cx);
};
self.run_active(
"sheet.move",
&[&(current + 2).to_string(), "true", "true"],
cx,
);
}
/// Moves the shown sheet; Index is its final place.
fn move_sheet(&mut self, right: bool, cx: &mut Context<Self>) {
let Some((current, _, names)) = self.current_sheet() else {
return self.warn("command.blocked.no_document", cx);
};
if (right && current + 1 == names.len()) || (!right && current == 0) {
return self.warn("table.sheet.edge", cx);
}
let at = if right { current + 2 } else { current };
self.run_active("sheet.move", &[&at.to_string(), "false", "true"], cx);
}
fn visible_count(&self, id: u64) -> usize {
self.tab(id).map_or(0, |tab| {
tab.extra
.sheets
.list
.iter()
.filter(|sheet| sheet.visible)
.count()
})
}
fn hide_sheet(&mut self, cx: &mut Context<Self>) {
let Some((current, ..)) = self.current_sheet() else {
return self.warn("command.blocked.no_document", cx);
};
if self.core.active.is_none_or(|id| self.visible_count(id) < 2) {
return self.warn("table.sheet.last", cx);
}
self.run_active("sheet.hide", &[&current.to_string()], cx);
}
fn ask_show(&mut self, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return self.warn("command.blocked.no_document", cx);
};
let hidden: Vec<String> = tab
.extra
.sheets
.list
.iter()
.filter(|sheet| !sheet.visible)
.map(|sheet| sheet.name.clone())
.collect();
let Some(chosen) = hidden.first().cloned() else {
return self.warn("table.show.none", cx);
};
let tab = tab.id;
self.dialog = Some(Dialog::Show {
tab,
hidden,
chosen,
});
cx.notify();
}
pub(crate) fn ask_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let (Some(tab), Some((index, name, _))) = (self.core.active, self.current_sheet()) else {
return self.warn("command.blocked.no_document", cx);
};
// The list shown was read in this state.
let Some(at) = self.read_at(tab, cx) else {
return self.warn("command.blocked.no_document", cx);
};
let field = cx.new(|cx| TextInput::new(window, cx));
field.update(cx, |field, cx| field.set_text(name, cx));
self.dialog = Some(Dialog::Rename { tab, at, index, field });
cx.notify();
}
/// Asks before a delete, naming what refers (UX-07).
pub(crate) fn ask_delete(&mut self, id: Option<u64>, cx: &mut Context<Self>) {
let Some(id) = id else {
return self.warn("command.blocked.no_document", cx);
};
if !self.settled(id, cx) {
return self.when_settled(id, move |book, _, cx| book.ask_delete(Some(id), cx));
}
let Some((index, name, _)) = self.sheet_in(id) else {
return self.warn("command.blocked.no_document", cx);
};
if self.visible_count(id) < 2 {
return self.warn("table.sheet.last", cx);
}
self.ask_in(
id,
move |worker, doc| {
// The sheet is checked and deleted in one state.
let at = worker.now(doc)?;
if doc.sheets(worker)?.list.get(index).map(|sheet| &sheet.name) != Some(&name) {
return Err(io::Error::other(format!("{}sheet {index} moved", ely_lok::STALE)));
}
let json = worker.values(doc, &ely_lok::sheet::references_query(index))?;
Ok((ely_lok::sheet::references(&json)?, at, name))
},
move |book, read, _, cx| match read {
Ok((references, at, name)) => {
book.dialog = Some(Dialog::Delete {
tab: id,
at,
index,
name,
references,
});
cx.notify();
}
Err(error) if ely_lok::is_stale(&error) => book.warn("office.error.changed", cx),
Err(error) => book.engine_failed(&error.to_string(), cx),
},
cx,
);
}
/// Renames the sheet the open dialog asked about.
pub(crate) fn rename_asked(&mut self, name: &str, cx: &mut Context<Self>) {
let Some(Dialog::Rename { tab, at, index, .. }) = self.dialog.take() else {
return;
};
self.run_on(tab, &at, "sheet.rename", &[name, &(index + 1).to_string()], cx);
}
/// Deletes the sheet the open dialog asked about.
pub(crate) fn delete_asked(&mut self, cx: &mut Context<Self>) {
let Some(Dialog::Delete { tab, at, index, .. }) = self.dialog.take() else {
return;
};
self.run_on(tab, &at, "sheet.delete", &[&(index + 1).to_string()], cx);
}
}
/// Visible sheets as tabs, once the shown one is known.
fn visible(sheets: &ely_lok::Sheets) -> Option<Vec<Choice>> {
let shown = sheets.list.get(sheets.current)?;
if !shown.visible {
return None;
}
Some(
sheets
.list
.iter()
.enumerate()
.filter(|(_, sheet)| sheet.visible)
.map(|(index, sheet)| Choice::new(index.to_string(), sheet.name.clone()))
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use ely_lok::{Sheet, Sheets};
#[test]
fn tabs_wait_for_a_known_shown_sheet() {
assert!(visible(&Sheets::default()).is_none(), "no list yet");
let sheet = |name: &str, visible| Sheet {
name: name.into(),
visible,
};
let hidden = Sheets {
current: 1,
list: vec![sheet("A", true), sheet("B", false)],
};
assert!(visible(&hidden).is_none(), "a hidden current waits");
let shown = Sheets {
current: 0,
list: vec![sheet("A", true), sheet("B", false), sheet("C", true)],
};
let values: Vec<_> = visible(&shown)
.expect("tabs")
.into_iter()
.map(|choice| choice.value)
.collect();
assert_eq!(values, ["0", "2"]);
}
}
+142
View File
@@ -0,0 +1,142 @@
//! The sheet dialogs: rename, show, delete (TABLE-01).
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
forms::{Choice, Select, TextInput},
overlays::{Dialog as Card, PromptDialog},
};
use ely_lok::sheet::References;
use ely_office::prelude::*;
use gpui::{App, Entity, ParentElement as _, SharedString, Window};
use super::Dialog;
use crate::book::Book;
/// Why `name` cannot name a sheet, as a message key.
fn refused(name: &str, names: &[String]) -> Option<&'static str> {
if name.is_empty() {
return Some("table.rename.empty");
}
let bad = name.contains([':', '\\', '/', '?', '*', '[', ']'])
|| name.starts_with('\'')
|| name.ends_with('\'');
if bad {
return Some("table.rename.invalid");
}
names
.iter()
.any(|taken| taken.to_lowercase() == name.to_lowercase())
.then_some("table.rename.taken")
}
pub(crate) fn rename(
this: Entity<Book>,
(index, field, names): (usize, &Entity<TextInput>, Vec<String>),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> PromptDialog {
let current = names.get(index).cloned().unwrap_or_default();
let others: Vec<String> = names.into_iter().filter(|name| *name != current).collect();
let title = Book::text(cx, "table.rename.title", &[("name", &current)]);
let messages: Vec<(&str, SharedString)> = [
"table.rename.empty",
"table.rename.invalid",
"table.rename.taken",
]
.into_iter()
.map(|key| (key, Book::text(cx, key, &[])))
.collect();
PromptDialog::new("table-rename", title, field, dismiss)
.label(Book::text(cx, "table.rename.label", &[]))
.submit(Book::text(cx, "table.size.apply", &[]))
.check(move |text| match refused(text, &others) {
Some(key) => Err(messages
.iter()
.find(|(known, _)| *known == key)
.map(|(_, message)| message.clone())
.expect("every refusal has a message")),
None => Ok(()),
})
.on_submit(move |text, _, cx| {
let name = text.to_string();
this.update(cx, |book, cx| book.rename_asked(&name, cx))
})
}
pub(crate) fn show(
this: Entity<Book>,
(tab, hidden, chosen): (u64, &[String], &str),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let choices = hidden
.iter()
.map(|name| Choice::new(name.clone(), name.clone()));
let picked = this.clone();
let select = Select::new("table-show-pick", choices)
.selected(chosen.to_string())
.on_change(move |value, _, cx| {
picked.update(cx, |book, cx| {
if let Some(Dialog::Show { chosen, .. }) = &mut book.dialog {
*chosen = value.to_string();
}
cx.notify();
})
});
let label = Book::text(cx, "table.show.go", &[]);
Card::new(
"table-show",
Book::text(cx, "table.show.title", &[]),
dismiss,
)
.child(select)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("table-show-go", label)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| {
this.update(cx, |book, cx| {
let Some(Dialog::Show { chosen, .. }) = book.dialog.take() else {
return;
};
book.run_in(Some(tab), "sheet.show", &[&chosen], cx);
})
})
})
}
pub(crate) fn delete(
this: Entity<Book>,
(name, references): (&str, &References),
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let mut detail = if references.formulas + references.names == 0 {
Book::text(cx, "table.delete.alone", &[])
} else {
Book::text(
cx,
"table.delete.refs",
&[
("formulas", &references.formulas.to_string()),
("first", &references.first),
("names", &references.names.to_string()),
],
)
};
if references.dynamic > 0 {
let count = references.dynamic.to_string();
let dynamic = Book::text(cx, "table.delete.dynamic", &[("count", &count)]);
detail = format!("{detail} {dynamic}").into();
}
let label = Book::text(cx, "table.delete.go", &[]);
let title = Book::text(cx, "table.delete.title", &[("name", name)]);
Card::new("table-delete", title, dismiss)
.detail(detail)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("table-delete-go", label)
.variant(ButtonVariant::Danger)
.on_click(move |_, _, cx| this.update(cx, |book, cx| book.delete_asked(cx)))
})
}
+15
View File
@@ -0,0 +1,15 @@
//! The blank workbook a new window starts from (TABLE-01).
use gpui::App;
/// A one-sheet flat ODF workbook with a local name.
pub fn blank(cx: &App) -> String {
let name = ely_ui::i18n::text(cx, "table.sheet.default", &[("n", "1")]);
let name = name
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('"', "&quot;");
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\" office:version=\"1.3\" office:mimetype=\"application/vnd.oasis.opendocument.spreadsheet\"><office:body><office:spreadsheet><table:table table:name=\"{name}\"/></office:spreadsheet></office:body></office:document>\n"
)
}
+401
View File
@@ -0,0 +1,401 @@
use std::{
path::Path,
sync::{Arc, Mutex},
};
use ely_office::prelude::*;
use gpui::{AppContext as _, Entity, TestAppContext, WindowHandle};
use crate::book::Book;
use crate::dialog::Dialog;
struct Blank;
impl gpui::Render for Blank {
fn render(
&mut self,
_: &mut gpui::Window,
_: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
gpui::Empty
}
}
/// An unrendered Book over a fresh worker, if LOK exists.
fn book(cx: &mut TestAppContext, dir: &Path) -> Option<(WindowHandle<Blank>, Entity<Book>)> {
let install = ely_lok::install_dir().ok()?;
let worker = ely_lok::Worker::spawn(&install, &dir.join("profile")).expect("the worker");
let work = dir.join("work");
std::fs::create_dir_all(&work).expect("the work dir");
cx.update(|cx| {
ely_ui::settings::install_defaults(cx);
ely_ui::i18n::install(cx);
});
let worker = Arc::new(Mutex::new(worker));
let window = cx.add_window(|_, _| Blank);
let book = cx
.update_window(window.into(), |_, window, app| {
let book = app.new(|cx| Book::new(worker, work, window, cx));
book.update(app, |book, _| {
book.core.window = Some(window.window_handle())
});
book
})
.expect("the window lives");
Some((window, book))
}
/// Steps the scheduler until `done` or ten seconds pass.
#[track_caller]
fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while std::time::Instant::now() < deadline {
if !cx.executor().tick() {
cx.executor()
.advance_clock(std::time::Duration::from_millis(20));
}
if cx.update(|app| done(app)) {
return;
}
}
panic!("the state never settled");
}
/// Waits until every tab's input landed and was read.
#[track_caller]
fn fresh(cx: &TestAppContext, book: &Entity<Book>) {
settle(cx, |app| {
let book = book.read(app);
let mut worker = book.core.worker.lock().expect("the worker lock");
book.core.tabs.iter().all(|tab| {
tab.view.read(app).settled()
&& tab.extra.read_at.is_some() && tab.extra.read_at.as_ref() == worker.now(&tab.doc).ok().as_ref()
})
});
}
fn update(
cx: &mut TestAppContext,
book: &Entity<Book>,
f: impl FnOnce(&mut Book, &mut gpui::Context<Book>),
) {
cx.update(|app| book.update(app, f));
}
fn sheet_names(app: &gpui::App, book: &Entity<Book>) -> Vec<String> {
book.read(app).active_tab().map_or_else(Vec::new, |tab| {
tab.extra
.sheets
.list
.iter()
.map(|sheet| sheet.name.clone())
.collect()
})
}
/// A blank workbook, loaded and read.
fn blank(cx: &mut TestAppContext, book: &Entity<Book>) {
update(cx, book, |book, cx| book.new_blank(cx));
settle(cx, |app| sheet_names(app, book).len() == 1);
}
#[test]
fn a_new_workbook_saves_xlsx_and_keeps_its_sheets() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &book);
update(&mut cx, &book, |book, cx| book.run_active("cell.enter", &["42"], cx));
fresh(&cx, &book);
update(&mut cx, &book, |book, cx| book.run_active("sheet.insert", &["Plan", "2"], cx));
let first = cx.update(|app| sheet_names(app, &book)[0].clone());
settle(&cx, |app| {
sheet_names(app, &book) == [first.as_str(), "Plan"]
});
let path = dir.path().join("book.xlsx");
let id = cx.update(|app| book.read(app).core.tabs[0].id);
cx.update_window(window.into(), |_, window, app| {
book.update(app, |book, cx| {
book.write_as(id, path.clone(), None, window, cx)
})
})
.expect("the window lives");
settle(&cx, |app| book.read(app).core.tabs[0].name == "book.xlsx");
update(&mut cx, &book, |book, cx| {
book.close_tab(id, cx);
book.open_file(path.clone(), cx);
});
settle(&cx, |app| {
sheet_names(app, &book) == [first.as_str(), "Plan"]
});
let bytes = std::fs::read(&path).expect("the file");
assert!(bytes.starts_with(b"PK"), "an XLSX zip");
}
#[test]
fn deleting_names_what_refers_and_merging_asks_when_data_would_hide() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &book);
let first = cx.update(|app| sheet_names(app, &book)[0].clone());
let formula = format!("='{first}'!A1*2");
update(&mut cx, &book, |book, cx| {
book.run_active("cell.enter", &["1"], cx);
book.run_active("cell.goto", &["B1"], cx);
book.run_active("cell.enter", &["b"], cx);
});
fresh(&cx, &book);
update(&mut cx, &book, |book, cx| {
book.run_active("sheet.insert", &["Uses", "2"], cx);
book.run_active("cell.enter", &[&formula], cx);
});
settle(&cx, |app| sheet_names(app, &book).len() == 2);
update(&mut cx, &book, |book, cx| {
let tab = book.active_tab().expect("a tab");
let mut worker = book.core.worker.lock().expect("the worker lock");
tab.doc.show_sheet(&mut worker, 0).expect("Sheet1 shows");
drop(worker);
cx.notify();
});
settle(&cx, |app| {
book.read(app)
.active_tab()
.is_some_and(|tab| tab.extra.sheets.current == 0)
});
update(&mut cx, &book, |book, cx| {
book.ask_delete(book.core.active, cx)
});
settle(&cx, |app| {
matches!(book.read(app).dialog, Some(Dialog::Delete { .. }))
});
let referred = cx.update(|app| match &book.read(app).dialog {
Some(Dialog::Delete { references, .. }) => references.formulas,
_ => 0,
});
assert_eq!(referred, 1, "the formula on Uses is named");
update(&mut cx, &book, |book, cx| {
book.dialog = None;
book.run_active("cell.goto", &["A1:B1"], cx);
book.merge(book.core.active, cx);
});
settle(&cx, |app| {
matches!(book.read(app).dialog, Some(Dialog::Merge { .. }))
});
update(&mut cx, &book, |book, cx| {
book.dialog = None;
book.run_active("cell.goto", &["C1:D1"], cx);
book.merge(book.core.active, cx);
});
settle(&cx, |app| {
let book = book.read(app);
book.dialog.is_none()
&& book.active_tab().is_some_and(|tab| {
tab.view.read(app).settled() && tab.view.read(app).cell().address == "C1:D1"
})
});
}
#[test]
fn a_queued_entry_lands_before_the_sheet_switches() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &book);
update(&mut cx, &book, |book, cx| {
book.run_active("sheet.insert", &["Two", "2"], cx);
});
settle(&cx, |app| sheet_names(app, &book).len() == 2);
update(&mut cx, &book, |book, cx| book.show_sheet(0, cx));
settle(&cx, |app| {
book.read(app)
.active_tab()
.is_some_and(|tab| tab.extra.sheets.current == 0 && tab.view.read(app).settled())
});
update(&mut cx, &book, |book, cx| {
book.run_active("cell.enter", &["42"], cx);
book.show_sheet(1, cx);
book.run_active("cell.enter", &["99"], cx);
});
settle(&cx, |app| {
book.read(app)
.active_tab()
.is_some_and(|tab| tab.extra.sheets.current == 1 && tab.view.read(app).settled())
});
let path = dir.path().join("two.fods");
cx.update(|app| {
let book = book.read(app);
let mut worker = book.core.worker.lock().expect("the worker lock");
let doc = &book.core.tabs[0].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");
let split = xml.find("table:name=\"Two\"").expect("the second sheet");
let (first, second) = xml.split_at(split);
assert!(
first.contains("office:value=\"42\""),
"42 stays on the first sheet"
);
assert!(
second.contains("office:value=\"99\""),
"99 goes to the second"
);
}
#[test]
fn a_switch_stays_with_its_workbook() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let current = |app: &gpui::App, at: usize| book.read(app).core.tabs[at].extra.sheets.current;
for _ in 0..2 {
update(&mut cx, &book, |book, cx| book.new_blank(cx));
}
settle(&cx, |app| {
let tabs = &book.read(app).core.tabs;
tabs.len() == 2 && tabs.iter().all(|tab| tab.extra.sheets.list.len() == 1)
});
for at in 0..2 {
update(&mut cx, &book, |book, cx| {
book.core.active = Some(book.core.tabs[at].id);
book.run_active("sheet.insert", &["Two", "2"], cx);
book.show_sheet(0, cx);
});
}
settle(&cx, |app| {
book.read(app)
.core
.tabs
.iter()
.all(|tab| tab.extra.sheets.list.len() == 2 && tab.view.read(app).settled())
});
update(&mut cx, &book, |book, cx| {
book.core.active = Some(book.core.tabs[0].id);
book.run_active("cell.enter", &["42"], cx);
book.show_sheet(1, cx);
book.core.active = Some(book.core.tabs[1].id);
});
settle(&cx, |app| {
book.read(app)
.core
.tabs
.iter()
.all(|tab| tab.view.read(app).settled())
});
settle(&cx, |app| current(app, 0) == 1);
assert_eq!(
cx.update(|app| current(app, 1)),
0,
"the other workbook stays"
);
}
#[test]
fn the_bars_follow_the_active_workbook() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
for _ in 0..2 {
update(&mut cx, &book, |book, cx| book.new_blank(cx));
}
settle(&cx, |app| book.read(app).core.tabs.len() == 2);
update(&mut cx, &book, |book, cx| {
book.core.active = Some(book.core.tabs[1].id);
book.run_active("cell.goto", &["C3"], cx);
});
let address = |app: &gpui::App, at: usize| {
book.read(app).core.tabs[at]
.view
.read(app)
.cell()
.address
.clone()
};
settle(&cx, |app| {
address(app, 0) == "A1" && address(app, 1) == "C3"
});
let name = |cx: &mut TestAppContext, at: usize| {
update(cx, &book, |book, cx| {
book.core.active = Some(book.core.tabs[at].id);
book.refill(cx);
});
cx.update(|app| book.read(app).bars.name.read(app).text().to_string())
};
assert_eq!(name(&mut cx, 0), "A1", "the first workbook's cell");
assert_eq!(name(&mut cx, 1), "C3", "then the second's");
assert_eq!(name(&mut cx, 0), "A1", "and back");
}
#[test]
fn a_delete_stays_with_its_workbook() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let count = |app: &gpui::App, at: usize| book.read(app).core.tabs[at].extra.sheets.list.len();
for _ in 0..2 {
update(&mut cx, &book, |book, cx| book.new_blank(cx));
}
settle(&cx, |app| book.read(app).core.tabs.len() == 2);
for (at, name) in [(0, "OriginTwo"), (1, "OtherTwo")] {
fresh(&cx, &book);
update(&mut cx, &book, |book, cx| {
book.core.active = Some(book.core.tabs[at].id);
book.run_active("sheet.insert", &[name, "2"], cx);
book.show_sheet(1, cx);
});
}
settle(&cx, |app| count(app, 0) == 2 && count(app, 1) == 2);
update(&mut cx, &book, |book, cx| {
book.core.active = Some(book.core.tabs[0].id);
book.run_active("cell.enter", &["42"], cx);
book.ask_delete(book.core.active, cx);
});
settle(&cx, |app| {
matches!(book.read(app).dialog, Some(Dialog::Delete { .. }))
});
// The user moves on while the dialog is open.
update(&mut cx, &book, |book, _| {
book.core.active = Some(book.core.tabs[1].id)
});
let asked = cx.update(|app| match &book.read(app).dialog {
Some(Dialog::Delete { name, .. }) => name.clone(),
_ => String::new(),
});
assert_eq!(
asked, "OriginTwo",
"the dialog names the first workbook's sheet"
);
update(&mut cx, &book, |book, cx| book.delete_asked(cx));
settle(&cx, |app| count(app, 0) == 1);
assert_eq!(
cx.update(|app| count(app, 1)),
2,
"the other workbook keeps both"
);
}
mod analysis;
mod data;
mod names;
mod numbers;
mod output;
mod rules;
+184
View File
@@ -0,0 +1,184 @@
use ely_lok::sheet::{self, Layout};
use super::data::{cells, open, quiet, run};
use super::*;
use crate::analysis::Asked;
/// Reads through the active tab's worker.
fn read<T>(cx: &mut TestAppContext, book: &Entity<Book>, what: &str, parse: fn(&str) -> T) -> T {
quiet(cx, 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 lock");
parse(&worker.values(&tab.doc, what).expect("the read"))
})
}
fn ask(cx: &mut TestAppContext, book: &Entity<Book>, asked: Asked, is: fn(&Dialog) -> bool) {
update(cx, book, |book, cx| {
book.dialog = None;
book.ask(asked, cx)
});
settle(cx, |app| book.read(app).dialog.as_ref().is_some_and(is));
}
#[test]
fn a_pivot_table_is_made_then_changed_in_place() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open(&mut cx, &book);
run(&mut cx, &book, "cell.goto", &["B3"]);
ask(&mut cx, &book, Asked::Pivot, |dialog| {
matches!(dialog, Dialog::Pivot { editing: false, .. })
});
update(&mut cx, &book, |book, cx| {
let Some(Dialog::Pivot {
source,
fields,
layout,
..
}) = &mut book.dialog
else {
unreachable!("a pivot dialog")
};
assert!(source.ends_with("$A$1:$C$5"), "the data area: {source}");
assert_eq!(fields, &["Name", "Qty", "City"], "the header row");
layout.rows.push("City".into());
layout.data.push(("Qty".into(), "sum".into()));
book.pivot_asked(cx)
});
settle(&cx, |app| sheet_names(app, &book).len() == 2);
let made = read(&mut cx, &book, sheet::PIVOT, |json| {
sheet::pivot(json).expect("parses")
})
.expect("the cursor is on the new table");
assert_eq!(made.layout.rows, ["City"]);
let shown = cells(&mut cx, &book, "A1:B4");
assert!(
shown.contains("x\t6") && shown.contains("y\t3"),
"{shown:?}"
);
run(&mut cx, &book, "cell.goto", &["A2"]);
ask(&mut cx, &book, Asked::Pivot, |dialog| {
matches!(dialog, Dialog::Pivot { editing: true, .. })
});
let changed = Layout {
rows: vec!["Name".into()],
data: vec![("Qty".into(), "max".into())],
..Layout::default()
};
update(&mut cx, &book, |book, cx| {
if let Some(Dialog::Pivot { layout, .. }) = &mut book.dialog {
*layout = changed.clone();
}
book.pivot_asked(cx)
});
run(&mut cx, &book, "cell.goto", &["A2"]);
let now = read(&mut cx, &book, sheet::PIVOT, |json| {
sheet::pivot(json).expect("parses")
});
let placed = Layout {
columns: vec![String::new()],
..changed
};
assert_eq!(now.expect("still there").layout, placed);
let sheets = cx.update(|app| sheet_names(app, &book));
assert_eq!(sheets.len(), 2, "the change made no sheet");
}
#[test]
fn a_chart_is_inserted_then_edited() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open(&mut cx, &book);
run(&mut cx, &book, "cell.goto", &["A1:B5"]);
ask(&mut cx, &book, Asked::Chart, |dialog| {
matches!(dialog, Dialog::Chart { chart: None, .. })
});
let range = cx.update(|app| {
let Some(Dialog::Chart { fields, .. }) = &book.read(app).dialog else {
unreachable!("a chart dialog")
};
fields.range.read(app).text().to_string()
});
assert!(range.ends_with("$A$1:$B$5"), "the picked range: {range}");
update(&mut cx, &book, |book, cx| {
let Some(Dialog::Chart { look, fields, .. }) = &mut book.dialog else {
unreachable!("a chart dialog")
};
look.kind = "line".into();
fields
.look
.title
.update(cx, |input, cx| input.set_text("Qty by name", cx));
book.chart_asked(cx)
});
let chart = |cx: &mut TestAppContext| {
read(cx, &book, sheet::CHART, |json| {
sheet::chart(json).expect("parses")
})
};
let made = chart(&mut cx).expect("the new chart is selected");
assert_eq!(
(made.look.kind.as_str(), made.look.title.as_str()),
("line", "Qty by name")
);
ask(&mut cx, &book, Asked::Chart, |dialog| {
matches!(dialog, Dialog::Chart { chart: Some(_), .. })
});
update(&mut cx, &book, |book, cx| {
if let Some(Dialog::Chart { look, .. }) = &mut book.dialog {
look.kind = "pie".into();
look.legend = false;
}
book.chart_asked(cx)
});
let edited = chart(&mut cx).expect("still selected");
assert_eq!(
(edited.look.kind.as_str(), edited.look.legend),
("pie", false)
);
assert_eq!(edited.look.title, "Qty by name", "the title stays");
}
#[test]
fn asking_on_an_empty_cell_leaves_enter_moving_down() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &book);
update(&mut cx, &book, |book, cx| book.ask(Asked::Chart, cx));
for _ in 0..200 {
cx.executor().tick();
}
quiet(&cx, &book);
let address = 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 lock");
worker.post_key(&tab.doc, '7' as u32, 0).expect("a key");
worker.post_key(&tab.doc, 0, 1280).expect("Enter");
let json = worker
.values(&tab.doc, sheet::SELECTED)
.expect("the selection");
sheet::selected_range(&json)
.expect("parses")
.expect("one")
.range
});
assert!(address.ends_with("$A$2"), "Enter moved down: {address}");
}
+302
View File
@@ -0,0 +1,302 @@
use ely_gpui_component::tables::{FilterRule, SortKey, Test};
use ely_lok::{Request, sheet};
use super::*;
/// Name, Qty, City; one repeated row.
const DATA: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:body><office:spreadsheet><table:table table:name="S">
<table:table-row><table:table-cell><text:p>Name</text:p></table:table-cell><table:table-cell><text:p>Qty</text:p></table:table-cell><table:table-cell><text:p>City</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell><text:p>a</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="3"><text:p>3</text:p></table:table-cell><table:table-cell><text:p>x</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell><text:p>b</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="1"><text:p>1</text:p></table:table-cell><table:table-cell><text:p>y</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell><text:p>a</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="3"><text:p>3</text:p></table:table-cell><table:table-cell><text:p>x</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell><text:p>c</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="2"><text:p>2</text:p></table:table-cell><table:table-cell><text:p>y</text:p></table:table-cell></table:table-row>
</table:table></office:spreadsheet></office:body>
</office:document>"#;
pub(super) fn open(cx: &mut TestAppContext, book: &Entity<Book>) {
update(cx, book, |book, cx| {
book.new_from_text("data.fods", DATA, cx)
});
settle(cx, |app| sheet_names(app, book) == ["S"]);
}
/// Waits for the active tab's queued input.
pub(super) fn quiet(cx: &TestAppContext, book: &Entity<Book>) {
settle(cx, |app| {
book.read(app)
.active_tab()
.is_some_and(|tab| tab.view.read(app).settled())
});
}
/// The cells of `range`, read through the worker.
pub(super) fn cells(cx: &mut TestAppContext, book: &Entity<Book>, range: &str) -> String {
quiet(cx, 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 lock");
worker
.run(&tab.doc, "cell.goto", &[range])
.expect("the range");
tab.doc
.selection_text(&mut worker)
.expect("the text")
.trim()
.to_string()
})
}
pub(super) fn run(cx: &mut TestAppContext, book: &Entity<Book>, id: &str, values: &[&str]) {
update(cx, book, |book, cx| book.run_active(id, values, cx));
quiet(cx, book);
}
/// The column's entries the filter drops.
fn dropped(cx: &mut TestAppContext, book: &Entity<Book>, column: u32) -> Vec<String> {
quiet(cx, 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 lock");
let json = worker
.values(&tab.doc, &sheet::cell_query(sheet::FILTER, column, 0))
.expect("the entries");
sheet::filter_values(&json)
.expect("parses")
.entries
.into_iter()
.filter(|entry| !entry.selected)
.map(|entry| entry.text)
.collect()
})
}
pub(super) fn asked(cx: &TestAppContext, book: &Entity<Book>, is: fn(&Dialog) -> bool) {
settle(cx, |app| book.read(app).dialog.as_ref().is_some_and(is));
}
#[test]
fn sort_filter_duplicates_and_columns_run_from_their_dialogs() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open(&mut cx, &book);
run(&mut cx, &book, "cell.goto", &["B3"]);
update(&mut cx, &book, |book, cx| {
book.ask_data(crate::data::Asked::Sort, cx)
});
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Sort { .. }));
update(&mut cx, &book, |book, cx| {
let Some(Dialog::Sort {
picked,
keys,
header,
..
}) = &mut book.dialog
else {
unreachable!("checked above");
};
assert_eq!(picked.top, ["Name", "Qty", "City"]);
assert!(*header, "the kernel sees headers");
assert_eq!(crate::data::title(picked, 1, *header), "Qty");
*keys = vec![SortKey {
column: "1".into(),
rising: true,
}];
book.data_asked(cx);
});
assert_eq!(cells(&mut cx, &book, "A2:A5"), "b\nc\na\na");
run(&mut cx, &book, "cell.goto", &["A1"]);
update(&mut cx, &book, |book, cx| {
book.ask_data(crate::data::Asked::Filter, cx)
});
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Filter { .. }));
update(&mut cx, &book, |book, cx| {
if let Some(Dialog::Filter { rules, .. }) = &mut book.dialog {
*rules = vec![FilterRule {
column: "2".into(),
test: Test::Equals,
value: "x".into(),
}];
}
book.data_asked(cx);
});
assert_eq!(dropped(&mut cx, &book, 2), ["y"]);
run(&mut cx, &book, "data.unfilter", &[]);
run(&mut cx, &book, "cell.goto", &["A2"]);
update(&mut cx, &book, |book, cx| {
book.ask_data(crate::data::Asked::Dedupe, cx)
});
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Dedupe { .. }));
update(&mut cx, &book, |book, cx| book.data_asked(cx));
assert_eq!(cells(&mut cx, &book, "A1:A5"), "Name\nb\nc\na");
run(&mut cx, &book, "cell.goto", &["A7"]);
run(&mut cx, &book, "cell.enter", &["p;q;r"]);
run(&mut cx, &book, "cell.goto", &["A7"]);
update(&mut cx, &book, |book, cx| book.ask_columns(cx));
asked(&cx, &book, |dialog| {
matches!(dialog, Dialog::Columns { .. })
});
update(&mut cx, &book, |book, cx| {
if let Some(Dialog::Columns { separators, .. }) = &mut book.dialog {
*separators = vec!["59".into()];
}
book.columns_asked(cx);
});
assert_eq!(cells(&mut cx, &book, "A7:C7"), "p\tq\tr");
}
#[test]
fn a_column_button_lists_validation_offers_and_formats_list() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open(&mut cx, &book);
run(&mut cx, &book, "cell.goto", &["A1"]);
run(&mut cx, &book, "data.autofilter", &[]);
let id = cx.update(|app| book.read(app).core.active.expect("a tab"));
update(&mut cx, &book, |book, cx| {
book.ask_column(id, &Request::Filter { column: 0, row: 0 }, cx)
});
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Column { .. }));
update(&mut cx, &book, |book, cx| {
let Some(Dialog::Column {
entries, picked, ..
}) = &mut book.dialog
else {
unreachable!("checked above");
};
assert_eq!(picked.len(), entries.len(), "nothing filtered yet");
*picked = vec!["c".into()];
book.column_asked(None, cx);
});
assert_eq!(dropped(&mut cx, &book, 0), ["a", "b"]);
run(&mut cx, &book, "data.unfilter", &[]);
run(&mut cx, &book, "cell.goto", &["E2"]);
update(&mut cx, &book, |book, cx| book.ask_rule(cx));
asked(&cx, &book, |dialog| {
matches!(dialog, Dialog::Validation { .. })
});
update(&mut cx, &book, |book, cx| {
let Some(Dialog::Validation { rule, fields, .. }) = &mut book.dialog else {
unreachable!("checked above");
};
rule.kind = "list".into();
rule.show_input = true;
fields
.list
.update(cx, |list, cx| list.set_text("Red\nGreen", cx));
fields
.input_message
.update(cx, |note, cx| note.set_text("Pick a color", cx));
book.rule_asked(cx);
});
run(&mut cx, &book, "cell.goto", &["E3"]);
run(&mut cx, &book, "cell.goto", &["E2"]);
settle(&cx, |app| {
book.read(app)
.active_tab()
.and_then(|tab| tab.extra.rule.as_ref())
.is_some_and(|(at, rule)| at.1 == "E2" && rule.input_message == "Pick a color")
});
update(&mut cx, &book, |book, cx| {
book.offer_choices(id, (4, 1), cx)
});
settle(&cx, |app| book.read(app).choices.is_some());
let values = cx.update(|app| {
let choices = book.read(app).choices.as_ref().expect("the menu");
choices.values.clone()
});
assert_eq!(values, ["Red", "Green"]);
run(&mut cx, &book, "cell.goto", &["B2:B5"]);
cx.update_window(window.into(), |_, window, app| {
book.update(app, |book, cx| book.ask_conditional(window, cx))
})
.expect("the window lives");
update(&mut cx, &book, |book, cx| {
if let Some(Dialog::Conditional { value1, .. }) = &book.dialog {
value1.update(cx, |value, cx| value.set_text("2", cx));
}
book.conditional_asked(cx);
});
quiet(&cx, &book);
update(&mut cx, &book, |book, cx| book.ask_formats(cx));
asked(&cx, &book, |dialog| {
matches!(dialog, Dialog::Formats { .. })
});
let style = cx.update(|app| match &book.read(app).dialog {
Some(Dialog::Formats { formats, .. }) => formats[0].style.clone(),
_ => unreachable!("checked above"),
});
assert_eq!(style, "Good");
update(&mut cx, &book, |book, cx| book.format_removed(cx));
quiet(&cx, &book);
update(&mut cx, &book, |book, cx| book.ask_formats(cx));
assert!(
cx.update(|app| book.read(app).dialog.is_none()),
"none left"
);
}
#[test]
fn the_panel_reads_number_formats_and_find_counts_cells() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open(&mut cx, &book);
run(&mut cx, &book, "cell.goto", &["B2"]);
run(&mut cx, &book, "number.percent", &[]);
update(&mut cx, &book, |book, cx| {
let tab = book.active_tab().expect("a tab");
let shown = (tab.view.clone(), tab.doc.clone());
book.panel
.update(cx, |panel, cx| panel.show(Some((&shown.0, &shown.1)), cx));
});
let kind = |app: &gpui::App| book.read(app).panel.read(app).number.clone();
settle(&cx, |app| {
kind(app).is_some_and(|(_, kind)| kind == "percent")
});
let panel = cx.update(|app| book.read(app).panel.clone());
cx.update(|app| panel.update(app, |panel, cx| panel.apply_code("[ZZZ]0", cx)));
quiet(&cx, &book);
cx.update(|app| panel.update(app, |panel, cx| panel.apply_code("0.0", cx)));
settle(&cx, |app| kind(app).is_some_and(|(code, _)| code == "0.0"));
cx.update_window(window.into(), |_, window, app| {
book.update(app, |book, cx| {
book.on_find(&ely_office::FindReplace, window, cx)
})
})
.expect("the window lives");
let query = cx.update(|app| {
let finder = book.read(app).core.finder.as_ref().expect("the finder");
finder.query.clone()
});
cx.update(|app| query.update(app, |query, cx| query.set_text("a", cx)));
settle(&cx, |app| {
book.read(app)
.core
.finder
.as_ref()
.is_some_and(|finder| finder.all.len() == 3)
});
}
+364
View File
@@ -0,0 +1,364 @@
use super::*;
/// The active workbook's names, from the kernel.
fn names(app: &gpui::App, book: &Entity<Book>) -> Vec<ely_lok::sheet::Name> {
let book = book.read(app);
let tab = book.active_tab().expect("a tab");
let mut worker = book.core.worker.lock().expect("the worker lock");
let json = worker
.values(&tab.doc, ely_lok::sheet::NAMES)
.expect("names");
ely_lok::sheet::names(&json).expect("the names parse")
}
/// Opens the define dialog in the test window.
fn ask_define(cx: &mut TestAppContext, book: &Entity<Book>) {
cx.update(|app| book.update(app, |book, cx| book.ask_define(cx)));
}
/// The define dialog's range text, once it is open.
fn asked_range(cx: &TestAppContext, book: &Entity<Book>) -> String {
settle(cx, |app| {
matches!(book.read(app).dialog, Some(Dialog::Define { .. }))
});
cx.update(|app| match &book.read(app).dialog {
Some(Dialog::Define { range, .. }) => range.read(app).text().to_string(),
_ => unreachable!("checked above"),
})
}
/// Waits until every queued input of every tab landed.
fn quiet(cx: &TestAppContext, book: &Entity<Book>) {
settle(cx, |app| {
book.read(app)
.core
.tabs
.iter()
.all(|tab| tab.view.read(app).settled())
});
}
#[test]
fn a_name_is_checked_then_defined_on_the_selection() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &book);
let sheet = cx.update(|app| sheet_names(app, &book)[0].clone());
update(&mut cx, &book, |book, cx| {
book.run_active("cell.goto", &["B2:C3"], cx)
});
settle(&cx, |app| {
book.read(app)
.active_tab()
.is_some_and(|tab| tab.view.read(app).cell().address == "B2:C3")
});
update(&mut cx, &book, |book, cx| book.ask_define(cx));
settle(&cx, |app| {
matches!(book.read(app).dialog, Some(Dialog::Define { .. }))
});
let define = |cx: &mut TestAppContext, name: &str| {
cx.update(|app| {
book.update(app, |book, cx| {
if let Some(Dialog::Define { name: field, .. }) = &book.dialog {
let name = name.to_string();
field.update(cx, |field, cx| field.set_text(name, cx));
}
book.define(cx);
})
});
};
// Checks answer in order: the cell name first.
define(&mut cx, "C4");
define(&mut cx, "Area");
settle(&cx, |app| book.read(app).dialog.is_none());
settle(&cx, |app| {
book.read(app)
.active_tab()
.is_some_and(|tab| tab.view.read(app).settled())
});
let defined = cx.update(|app| names(app, &book));
assert_eq!(defined.len(), 1);
assert_eq!(
(defined[0].name.as_str(), defined[0].formula.as_str()),
("Area", format!("{sheet}!$B$2:$C$3").as_str()),
"the selection, sheet included"
);
}
#[test]
fn define_waits_for_queued_input_and_reads_named_selections() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &book);
let sheet = cx.update(|app| sheet_names(app, &book)[0].clone());
update(&mut cx, &book, |book, cx| {
book.run_active("cell.goto", &["B2:C3"], cx)
});
ask_define(&mut cx, &book);
assert_eq!(
asked_range(&cx, &book),
format!("{sheet}!$B$2:$C$3"),
"the selection after the queued move"
);
update(&mut cx, &book, |book, cx| {
book.dialog = None;
let range = format!("'{sheet}'!$D$4:$E$5");
book.run_active("names.define", &["Area", &range, ""], cx);
book.run_active("cell.goto", &["A1"], cx);
book.run_active("cell.goto", &["Area"], cx);
});
quiet(&cx, &book);
let range = cx.update(|app| {
let names = names(app, &book);
names[0].range.clone()
});
assert!(range.ends_with("!$D$4:$E$5"), "{range}");
ask_define(&mut cx, &book);
assert_eq!(
asked_range(&cx, &book),
range,
"a named selection keeps its range"
);
}
#[test]
fn the_names_list_stays_with_its_workbook_and_scope() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
for _ in 0..2 {
update(&mut cx, &book, |book, cx| book.new_blank(cx));
}
settle(&cx, |app| {
let tabs = &book.read(app).core.tabs;
tabs.len() == 2 && tabs.iter().all(|tab| !tab.extra.sheets.list.is_empty())
});
let first = cx.update(|app| book.read(app).core.tabs[0].id);
let sheet = cx.update(|app| {
book.read(app).core.tabs[0].extra.sheets.list[0]
.name
.clone()
});
update(&mut cx, &book, |book, cx| {
book.core.active = Some(first);
book.run_active("sheet.insert", &["Other", "2"], cx);
book.run_active(
"names.define",
&["Spot", &format!("'{sheet}'!$A$5"), ""],
cx,
);
book.run_active("names.define", &["Spot", "'Other'!$C$7", "Other"], cx);
book.run_active("names.define", &["Local", "'Other'!$B$2", "Other"], cx);
book.show_sheet(0, cx);
});
quiet(&cx, &book);
cx.update(|app| {
book.update(app, |book, cx| {
book.run_active("cell.enter", &["7"], cx);
book.names_in(first, cx);
})
});
settle(&cx, |app| {
matches!(book.read(app).dialog, Some(Dialog::Names { .. }))
});
update(&mut cx, &book, |book, _| {
book.core.active = Some(book.core.tabs[1].id)
});
let pick = |cx: &mut TestAppContext, name: &str, scope: &str| {
update(cx, &book, |book, cx| {
let Some(Dialog::Names { tab, names, .. }) = &book.dialog else {
panic!("the names list is open");
};
assert_eq!(*tab, first, "the list stays with the asking workbook");
let at = names
.iter()
.position(|known| known.name == name && known.scope == scope)
.expect("the name is listed");
book.choose_name(at, cx);
book.act_on_name(crate::formulas::NameAct::Go, cx);
});
};
let shown = |cx: &mut TestAppContext| {
quiet(cx, &book);
let active = cx.update(|app| book.read(app).core.active);
assert_eq!(active, Some(first), "the jump shows its workbook");
update(cx, &book, |book, cx| book.refill(cx));
let named = cx.update(|app| book.read(app).bars.name.read(app).text().to_string());
let address = cx.update(|app| {
book.read(app).core.tabs[0]
.view
.read(app)
.cell()
.address
.clone()
});
assert_eq!(named, address, "the name box shows the target");
cx.update(|app| {
let tab = &book.read(app).core.tabs[0];
(
tab.extra.sheets.current,
tab.view.read(app).cell().address.clone(),
)
})
};
pick(&mut cx, "Local", "Other");
settle(&cx, |app| {
book.read(app).core.tabs[0].extra.sheets.current == 1
});
let (part, address) = shown(&mut cx);
assert!(
part == 1 && (address == "B2" || address.starts_with("Local")),
"{part} {address}"
);
cx.update(|app| {
book.update(app, |book, cx| {
book.names_in(first, cx);
})
});
settle(&cx, |app| {
matches!(book.read(app).dialog, Some(Dialog::Names { .. }))
});
update(&mut cx, &book, |book, _| {
book.core.active = Some(book.core.tabs[1].id)
});
pick(&mut cx, "Spot", "");
settle(&cx, |app| {
book.read(app).core.tabs[0].extra.sheets.current == 0
});
let (part, address) = shown(&mut cx);
assert!(
part == 0 && (address == "A5" || address == "Spot"),
"{part} {address}"
);
}
#[test]
fn a_waiting_definition_keeps_its_workbook_sheets() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
for _ in 0..2 {
update(&mut cx, &book, |book, cx| book.new_blank(cx));
}
settle(&cx, |app| {
let tabs = &book.read(app).core.tabs;
tabs.len() == 2 && tabs.iter().all(|tab| !tab.extra.sheets.list.is_empty())
});
let (first, second) = cx.update(|app| {
let tabs = &book.read(app).core.tabs;
(tabs[0].id, tabs[1].id)
});
for (id, name) in [(first, "Origin"), (second, "Destination")] {
update(&mut cx, &book, |book, cx| {
book.core.active = Some(id);
book.run_active("sheet.rename", &[name, "1"], cx);
});
}
settle(&cx, |app| {
book.read(app)
.core
.tabs
.iter()
.zip(["Origin", "Destination"])
.all(|(tab, name)| {
tab.extra
.sheets
.list
.first()
.is_some_and(|sheet| sheet.name == name)
})
});
cx.update(|app| {
book.update(app, |book, cx| {
book.core.active = Some(first);
book.run_active("cell.goto", &["B2:C3"], cx);
book.ask_define(cx);
})
});
settle(&cx, |app| {
matches!(book.read(app).dialog, Some(Dialog::Define { .. }))
});
update(&mut cx, &book, |book, _| book.core.active = Some(second));
let scopes = cx.update(|app| book.read(app).define_scopes());
assert_eq!(
scopes,
Some(vec!["Origin".to_string()]),
"the asking workbook's sheets"
);
update(&mut cx, &book, |book, cx| {
if let Some(Dialog::Define { name, scope, .. }) = &mut book.dialog {
*scope = "Origin".into();
name.update(cx, |name, cx| name.set_text("Area", cx));
}
book.define(cx);
book.core.active = Some(first);
});
settle(&cx, |app| book.read(app).dialog.is_none());
quiet(&cx, &book);
let defined = cx.update(|app| names(app, &book));
assert!(
defined
.iter()
.any(|name| name.name == "Area" && name.scope == "Origin"),
"{defined:?}"
);
}
#[test]
fn a_rename_stays_with_its_workbook() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
for _ in 0..2 {
update(&mut cx, &book, |book, cx| book.new_blank(cx));
}
settle(&cx, |app| {
let tabs = &book.read(app).core.tabs;
tabs.len() == 2 && tabs.iter().all(|tab| !tab.extra.sheets.list.is_empty())
});
let (first, second) = cx.update(|app| {
let tabs = &book.read(app).core.tabs;
(tabs[0].id, tabs[1].id)
});
let before = cx.update(|app| {
book.read(app).core.tabs[1].extra.sheets.list[0]
.name
.clone()
});
cx.update_window(window.into(), |_, window, app| {
book.update(app, |book, cx| {
book.core.active = Some(first);
book.ask_rename(window, cx);
book.core.active = Some(second);
book.rename_asked("Kept", cx);
})
})
.expect("the window lives");
settle(&cx, |app| {
book.read(app).core.tabs[0].extra.sheets.list[0].name == "Kept"
});
assert_eq!(
cx.update(|app| book.read(app).core.tabs[1].extra.sheets.list[0]
.name
.clone()),
before,
"the other workbook keeps its sheet name"
);
}
+46
View File
@@ -0,0 +1,46 @@
//! Sheet numbers hold only in the state they were read.
use super::*;
#[test]
fn a_rename_asked_before_a_delete_renames_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &book);
for (name, at) in [("B", "2"), ("C", "3")] {
fresh(&cx, &book);
update(&mut cx, &book, |book, cx| book.run_active("sheet.insert", &[name, at], cx));
}
settle(&cx, |app| sheet_names(app, &book).len() == 3);
fresh(&cx, &book);
update(&mut cx, &book, |book, cx| book.show_sheet(1, cx));
cx.update_window(window.into(), |_, window, app| book.update(app, |book, cx| book.ask_rename(window, cx)))
.expect("the window lives");
// The first sheet goes while the dialog is open.
update(&mut cx, &book, |book, cx| book.run_active("sheet.delete", &["1"], cx));
settle(&cx, |app| sheet_names(app, &book) == ["B", "C"]);
fresh(&cx, &book);
update(&mut cx, &book, |book, cx| book.rename_asked("Renamed B", cx));
fresh(&cx, &book);
assert_eq!(cx.update(|app| sheet_names(app, &book)), ["B", "C"], "C keeps its name");
}
#[test]
fn a_failed_command_still_moves_the_state_on() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &book);
// No merged cell to split: the kernel refuses.
update(&mut cx, &book, |book, cx| book.run_active("cells.split", &[], cx));
fresh(&cx, &book);
update(&mut cx, &book, |book, cx| book.run_active("sheet.rename", &["Plan", "1"], cx));
settle(&cx, |app| sheet_names(app, &book) == ["Plan"]);
}
+379
View File
@@ -0,0 +1,379 @@
use ely_gpui_component::forms::TextInput;
use ely_lok::sheet::{self, Scale, csv::Column};
use super::data::{asked, cells, quiet, run};
use super::*;
use crate::output::Format;
/// The active tab's kernel read, after its input.
fn read<T>(
cx: &mut TestAppContext,
book: &Entity<Book>,
parse: fn(&str) -> std::io::Result<T>,
command: &str,
) -> T {
quiet(cx, 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 lock");
parse(&worker.values(&tab.doc, command).expect("the read")).expect("parses")
})
}
fn type_in(
cx: &mut TestAppContext,
book: &Entity<Book>,
field: fn(&Dialog) -> &Entity<TextInput>,
text: &str,
) {
cx.update(|app| {
let input = field(book.read(app).dialog.as_ref().expect("a dialog")).clone();
input.update(app, |input, cx| input.set_text(text, cx));
});
}
#[test]
fn a_csv_opens_through_its_choices_and_keeps_codes() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let path = dir.path().join("accounts.txt");
let mut text = "Account;Amount\n".to_string();
text.push_str(&"123;2\n".repeat(18_000));
text.push_str("007;1.5\n");
std::fs::write(&path, text).expect("writes");
update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx));
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading));
cx.update(|app| match book.read(app).dialog.as_ref() {
Some(Dialog::Import(pending)) => {
assert_eq!(pending.how.separator, ';', "the guessed separator");
assert_eq!(pending.how.columns, [Column::Text, Column::Standard]);
}
_ => unreachable!("an import dialog"),
});
update(&mut cx, &book, |book, cx| book.import_asked(cx));
settle(&cx, |app| {
book.read(app).active_tab().is_some_and(|tab| tab.name == "accounts.txt")
});
assert_eq!(cells(&mut cx, &book, "A18002"), "007", "the zero stays");
assert_eq!(cells(&mut cx, &book, "B18002"), "1.5");
let saved = dir.path().join("accounts.xlsx");
let id = cx.update(|app| book.read(app).core.tabs[0].id);
cx.update_window(window.into(), |_, window, app| {
book.update(app, |book, cx| book.write_as(id, saved.clone(), None, window, cx))
})
.expect("the window lives");
settle(&cx, |app| book.read(app).core.tabs[0].name == "accounts.xlsx");
update(&mut cx, &book, |book, cx| {
book.close_tab(id, cx);
book.open_file(saved.clone(), cx);
});
settle(&cx, |app| sheet_names(app, &book).len() == 1);
assert_eq!(cells(&mut cx, &book, "A18002"), "007", "XLSX keeps it");
}
#[test]
fn a_wrong_encoding_blocks_the_import() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let path = dir.path().join("latin.csv");
std::fs::write(&path, b"Name\nCaf\xe9\n").expect("writes");
update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx));
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading));
update(&mut cx, &book, |book, cx| {
if let Some(Dialog::Import(pending)) = &mut book.dialog {
pending.how.encoding = ely_lok::sheet::csv::Encoding::Utf8;
}
book.scan_import(cx);
});
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading));
update(&mut cx, &book, |book, cx| book.import_asked(cx));
assert!(cx.update(|app| book.read(app).dialog.is_some()), "broken bytes stay out");
assert!(cx.update(|app| book.read(app).core.loading.is_empty()));
}
#[test]
fn exports_write_the_shown_sheet_as_text() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &book);
run(&mut cx, &book, "cell.enter", &["=1+2"]);
run(&mut cx, &book, "cell.goto", &["B1"]);
run(&mut cx, &book, "cell.enter", &["a;b"]);
let id = cx.update(|app| book.read(app).core.tabs[0].id);
for (format, name, text) in [
(Format::Csv, "out.csv", "3,a;b\n"),
(Format::Tsv, "out.tsv", "3\ta;b\n"),
] {
let path = dir.path().join(name);
update(&mut cx, &book, |book, cx| book.export_to(id, path.clone(), format, cx));
settle(&cx, |_| path.exists());
quiet(&cx, &book);
assert_eq!(std::fs::read_to_string(&path).expect("reads"), text);
}
let pdf = dir.path().join("out.pdf");
update(&mut cx, &book, |book, cx| book.export_to(id, pdf.clone(), Format::Pdf, cx));
settle(&cx, |_| pdf.exists());
quiet(&cx, &book);
assert!(std::fs::read(&pdf).expect("reads").starts_with(b"%PDF"));
}
fn area(dialog: &Dialog) -> &Entity<TextInput> {
match dialog {
Dialog::Page { fields, .. } => &fields.area,
_ => unreachable!("a page dialog"),
}
}
fn rows(dialog: &Dialog) -> &Entity<TextInput> {
match dialog {
Dialog::Page { fields, .. } => &fields.rows,
_ => unreachable!("a page dialog"),
}
}
#[test]
fn page_setup_applies_titles_area_and_page() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &book);
update(&mut cx, &book, |book, cx| book.ask_page(cx));
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Page { .. }));
type_in(&mut cx, &book, area, "A1:");
update(&mut cx, &book, |book, cx| book.page_asked(cx));
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Page { .. }));
type_in(&mut cx, &book, area, "a1:c4");
type_in(&mut cx, &book, rows, "1");
update(&mut cx, &book, |book, cx| {
if let Some(Dialog::Page {
landscape, scale, ..
}) = &mut book.dialog
{
(*landscape, *scale) = (true, Scale::Fit(1, 0));
}
book.page_asked(cx)
});
let layout = read(&mut cx, &book, sheet::print_layout, sheet::LAYOUT);
assert_eq!(layout.areas, ["$A$1:$C$4"]);
assert_eq!(layout.rows, Some((1, 1)));
assert_eq!((layout.landscape, layout.scale), (true, Scale::Fit(1, 0)));
}
fn password(dialog: &Dialog) -> &Entity<TextInput> {
match dialog {
Dialog::Protect { protecting, .. } => &protecting.password,
_ => unreachable!("a protect dialog"),
}
}
fn confirm(dialog: &Dialog) -> &Entity<TextInput> {
match dialog {
Dialog::Protect { protecting, .. } => &protecting.confirm,
_ => unreachable!("a protect dialog"),
}
}
fn protected(cx: &mut TestAppContext, book: &Entity<Book>) -> bool {
read(cx, book, sheet::protection, sheet::PROTECTION).protected
}
#[test]
fn a_wrong_password_leaves_the_sheet_protected() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
blank(&mut cx, &book);
let ask = |cx: &mut TestAppContext| {
update(cx, &book, |book, cx| book.ask_protect(cx));
asked(cx, &book, |dialog| matches!(dialog, Dialog::Protect { .. }));
};
ask(&mut cx);
type_in(&mut cx, &book, password, "pw");
type_in(&mut cx, &book, confirm, "px");
update(&mut cx, &book, |book, cx| book.protect_asked(cx));
assert!(cx.update(|app| book.read(app).dialog.is_some()), "a mismatch stays");
type_in(&mut cx, &book, confirm, "pw");
update(&mut cx, &book, |book, cx| book.protect_asked(cx));
assert!(protected(&mut cx, &book));
ask(&mut cx);
type_in(&mut cx, &book, password, "no");
update(&mut cx, &book, |book, cx| book.protect_asked(cx));
cx.run_until_parked();
assert!(protected(&mut cx, &book), "still protected");
ask(&mut cx);
type_in(&mut cx, &book, password, "pw");
update(&mut cx, &book, |book, cx| book.protect_asked(cx));
cx.run_until_parked();
assert!(!protected(&mut cx, &book));
}
#[test]
fn a_new_link_names_its_unrefreshed_source() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let source = dir.path().join("source.ods");
blank(&mut cx, &book);
run(&mut cx, &book, "cell.enter", &["7"]);
let name = cx.update(|app| sheet_names(app, &book)[0].clone());
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 lock");
tab.doc
.save_as(&mut worker, &ely_lok::file_url(&source), "ods")
.expect("the source");
});
settle(&cx, |app| book.read(app).active_tab().is_some_and(|tab| tab.extra.linked.is_some()));
run(&mut cx, &book, "cell.goto", &["B1"]);
let formula = format!("='{}/[source.ods]{name}'!A1", dir.path().display());
run(&mut cx, &book, "cell.enter", &[&formula]);
let url = ely_lok::file_url(&source);
settle(&cx, |app| {
book.read(app)
.active_tab()
.is_some_and(|tab| tab.extra.links == [url.clone()])
});
}
#[test]
fn the_checked_bytes_import_even_if_the_file_changes() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let path = dir.path().join("codes.csv");
std::fs::write(&path, "Account\n00123\n").expect("writes");
update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx));
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading));
std::fs::write(&path, b"Account\n999\nA\xffB\n").expect("rewrites");
update(&mut cx, &book, |book, cx| book.import_asked(cx));
settle(&cx, |app| book.read(app).active_tab().is_some());
assert_eq!(cells(&mut cx, &book, "A2"), "00123", "what was checked");
}
#[test]
fn a_closed_dialogs_scan_never_lands_in_the_next() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let pending = |name: &str, text: &str| {
Box::new(crate::text::Pending::new(dir.path().join(name), text.into(), false))
};
// Either order: one of them lands the old scan last.
for old_first in [true, false] {
let (old, next) = (pending("old.csv", "Old\n00123\n"), pending("next.csv", "Next,Amount\n1,2\n"));
update(&mut cx, &book, |book, cx| {
let (first, second) = match old_first {
true => (Dialog::Import(old), Dialog::Import(next)),
false => (Dialog::Import(next), Dialog::Import(old)),
};
book.dialog = Some(first);
book.scan_import(cx);
let first = book.dialog.replace(second);
book.scan_import(cx);
if !old_first {
book.dialog = first;
}
});
cx.run_until_parked();
cx.update(|app| match book.read(app).dialog.as_ref() {
Some(Dialog::Import(pending)) => {
assert_eq!(pending.rows[0], ["Next", "Amount"], "old first {old_first}");
assert_eq!(pending.how.columns, [Column::Standard, Column::Standard]);
}
_ => unreachable!("an import dialog"),
});
}
}
#[test]
fn the_preview_reads_what_the_kernel_imports() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let path = dir.path().join("odd.csv");
let text = "Id,Code\n \"00123\",1\n\ny,-12345678901234567890\n";
std::fs::write(&path, text).expect("writes");
update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx));
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading));
let preview = cx.update(|app| match book.read(app).dialog.as_ref() {
Some(Dialog::Import(pending)) => pending.rows.clone(),
_ => unreachable!("an import dialog"),
});
update(&mut cx, &book, |book, cx| book.import_asked(cx));
settle(&cx, |app| book.read(app).active_tab().is_some());
// Row 4 lines up only if the empty line counted.
for (at, row) in preview.iter().enumerate().filter(|(_, row)| row.len() == 2) {
for (column, value) in ["A", "B"].iter().zip(row) {
let cell = format!("{column}{}", at + 1);
assert_eq!(&cells(&mut cx, &book, &cell), value, "{cell} as previewed");
}
}
assert_eq!(preview[3][1], "-12345678901234567890");
}
#[test]
fn text_files_wait_their_turn_for_the_import_dialog() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let paths = ["a.csv", "b.csv"].map(|name| dir.path().join(name));
for (path, text) in paths.iter().zip(["first\n", "second\n"]) {
std::fs::write(path, text).expect("writes");
}
// Another dialog is open; neither import may replace it.
update(&mut cx, &book, |book, cx| {
book.dialog = Some(Dialog::Merge { tab: 0 });
for path in &paths {
book.ask_import(path.clone(), cx);
}
});
settle(&cx, |app| book.read(app).imports.len() == 2);
cx.update(|app| assert!(matches!(book.read(app).dialog, Some(Dialog::Merge { .. }))));
update(&mut cx, &book, |book, cx| {
book.dialog = None;
cx.notify();
});
for opened in 1..=2 {
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading));
update(&mut cx, &book, |book, cx| book.import_asked(cx));
settle(&cx, |app| book.read(app).core.tabs.len() == opened);
}
let mut names = cx.update(|app| book.read(app).core.tabs.iter().map(|tab| tab.name.clone()).collect::<Vec<_>>());
names.sort();
assert_eq!(names, ["a.csv", "b.csv"], "both files open");
}
+194
View File
@@ -0,0 +1,194 @@
use ely_lok::sheet::Validation;
use super::data::{open, quiet, run};
use super::*;
/// The active workbook's cached rule for its current cell.
fn rule(app: &gpui::App, book: &Entity<Book>) -> Option<(String, Validation)> {
let tab = book.read(app).active_tab()?;
let (spot, rule) = tab.extra.rule.as_ref()?;
Some((spot.1.clone(), rule.clone()))
}
#[test]
fn a_dialog_asked_from_a_workbook_left_behind_is_dropped() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open(&mut cx, &book);
update(&mut cx, &book, |book, cx| book.new_blank(cx));
settle(&cx, |app| {
let tabs = &book.read(app).core.tabs;
tabs.len() == 2 && tabs.iter().all(|tab| !tab.extra.sheets.list.is_empty())
});
let (first, second) = cx.update(|app| {
let tabs = &book.read(app).core.tabs;
(tabs[0].id, tabs[1].id)
});
type Ask = fn(&mut Book, &mut gpui::Window, &mut gpui::Context<Book>);
let asks: [Ask; 5] = [
|book, _, cx| book.ask_rule(cx),
|book, _, cx| book.ask_columns(cx),
|book, _, cx| book.ask_formats(cx),
|book, _, cx| book.ask(crate::analysis::Asked::Chart, cx),
|book, _, cx| book.ask(crate::analysis::Asked::Pivot, cx),
];
for ask in asks {
update(&mut cx, &book, |book, cx| {
book.core.active = Some(first);
book.run_active("cell.goto", &["B2"], cx);
});
quiet(&cx, &book);
let worker = cx.update(|app| book.read(app).core.worker.clone());
// The read lands only after the switch.
let held = worker.lock().expect("the worker lock");
cx.update_window(window.into(), |_, window, app| {
book.update(app, |book, cx| {
book.dialog = None;
ask(book, window, cx);
book.core.active = Some(second);
})
})
.expect("the window lives");
drop(held);
for _ in 0..200 {
cx.executor().tick();
}
quiet(&cx, &book);
assert!(
cx.update(|app| book.read(app).dialog.is_none()),
"no dialog for a workbook left behind"
);
}
}
#[test]
fn rules_set_in_place_reach_the_cell_and_clear() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open(&mut cx, &book);
run(&mut cx, &book, "cell.goto", &["E2"]);
settle(&cx, |app| {
rule(app, &book).is_some_and(|(at, rule)| at == "E2" && rule.kind == "any")
});
let list = Validation {
kind: "list".into(),
operator: "equal".into(),
list: vec!["Red".into()],
dropdown: true,
show_input: true,
input_message: "A color".into(),
error_style: "stop".into(),
..Validation::default()
};
run(&mut cx, &book, "rules.validation", &[&list.to_json()]);
settle(&cx, |app| {
rule(app, &book).is_some_and(|(_, rule)| rule.kind == "list")
});
update(&mut cx, &book, |book, cx| book.clear_rule(cx));
quiet(&cx, &book);
settle(&cx, |app| {
rule(app, &book).is_some_and(|(_, rule)| rule.kind == "any" && !rule.show_input)
});
}
#[test]
fn the_panel_follows_a_sheet_switch_at_one_address() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open(&mut cx, &book);
run(&mut cx, &book, "sheet.insert", &["T", "2"]);
settle(&cx, |app| sheet_names(app, &book) == ["S", "T"]);
update(&mut cx, &book, |book, cx| {
let tab = book.active_tab().expect("a tab");
let (view, doc) = (tab.view.clone(), tab.doc.clone());
book.panel
.update(cx, |panel, cx| panel.show(Some((&view, &doc)), cx));
});
let kind = |app: &gpui::App| {
let number = book.read(app).panel.read(app).number.clone();
number.map(|(_, kind)| kind)
};
for (sheet, command, want) in [
(0, "number.percent", "percent"),
(1, "number.currency", "currency"),
] {
update(&mut cx, &book, |book, cx| book.show_sheet(sheet, cx));
run(&mut cx, &book, "cell.goto", &["A1"]);
run(&mut cx, &book, command, &[]);
settle(&cx, |app| kind(app).as_deref() == Some(want));
}
update(&mut cx, &book, |book, cx| book.show_sheet(0, cx));
run(&mut cx, &book, "cell.goto", &["A1"]);
settle(&cx, |app| kind(app).as_deref() == Some("percent"));
}
#[test]
fn a_format_code_lands_in_the_workbook_that_asked() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
open(&mut cx, &book);
update(&mut cx, &book, |book, cx| book.new_blank(cx));
settle(&cx, |app| {
let tabs = &book.read(app).core.tabs;
tabs.len() == 2 && tabs.iter().all(|tab| !tab.extra.sheets.list.is_empty())
});
let (first, second) = cx.update(|app| {
let tabs = &book.read(app).core.tabs;
(tabs[0].id, tabs[1].id)
});
let code = |cx: &mut TestAppContext, id: u64| {
cx.update(|app| {
let book = book.read(app);
let tab = book.tab(id).expect("a tab");
let mut worker = book.core.worker.lock().expect("the worker lock");
worker.run(&tab.doc, "cell.goto", &["A1"]).expect("A1");
let json = worker
.values(&tab.doc, ely_lok::sheet::NUMBER)
.expect("the format");
ely_lok::sheet::number_format(&json).expect("parses").0
})
};
let (panel, view, doc) = cx.update(|app| {
book.update(app, |book, _| book.core.active = Some(first));
let book = book.read(app);
let tab = book.tab(first).expect("a tab");
(book.panel.clone(), tab.view.clone(), tab.doc.clone())
});
cx.update(|app| {
panel.update(app, |panel, cx| {
panel.show(Some((&view, &doc)), cx);
panel.apply_code("0.000", cx);
})
});
// The check is still on its way.
update(&mut cx, &book, |book, _| book.core.active = Some(second));
settle(&cx, |app| {
book.read(app)
.core
.tabs
.iter()
.all(|tab| tab.view.read(app).settled())
});
quiet(&cx, &book);
for _ in 0..200 {
cx.executor().tick();
}
assert_eq!(code(&mut cx, first), "0.000", "the asking workbook");
assert_ne!(code(&mut cx, second), "0.000", "not the active one");
}
+371
View File
@@ -0,0 +1,371 @@
//! CSV and TSV files: read before opening (TABLE-12, §9.3).
use std::{
iter::Peekable,
path::{Path, PathBuf},
str::Chars,
sync::Arc,
};
use ely_lok::sheet::csv::{Column, Encoding, Import};
use ely_office::{in_window, prelude::*};
use ely_ui::commands::Open;
use gpui::{AppContext as _, Context, TaskExt as _, Window};
use crate::book::Book;
use crate::dialog::Dialog;
mod dialogs;
pub use dialogs::import_dialog;
/// Extensions the import dialog reads.
const TEXT: [&str; 4] = ["csv", "tsv", "tab", "txt"];
/// Rows the preview shows.
pub const SHOWN: usize = 8;
/// Characters a preview cell keeps.
const CELL: usize = 200;
/// Separators offered, in guessing order.
pub const SEPARATORS: [char; 5] = [',', ';', '\t', '|', ' '];
fn is_text(path: &Path) -> bool {
let ext = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
TEXT.contains(&ext.to_ascii_lowercase().as_str())
}
/// A text file waiting for its import choices.
pub struct Pending {
pub path: PathBuf,
bytes: Arc<Vec<u8>>,
pub how: Import,
/// The first rows as the choices read them.
pub rows: Vec<Vec<String>>,
/// The chosen encoding does not read the file.
pub broken: bool,
/// A scan of the whole file is on its way.
pub reading: bool,
/// The latest scan asked; older ones are dropped.
asked: u64,
}
/// What one scan of the whole file found.
struct Scan {
rows: Vec<Vec<String>>,
columns: Vec<Column>,
broken: bool,
}
impl Pending {
pub(crate) fn new(path: PathBuf, bytes: Vec<u8>, chinese: bool) -> Self {
let how = Import {
separator: guess_separator(&path, &bytes),
quote: Some('"'),
encoding: guess_encoding(&bytes, chinese),
columns: Vec::new(),
};
Pending {
path,
bytes: Arc::new(bytes),
how,
rows: Vec::new(),
broken: false,
reading: true,
asked: 0,
}
}
}
/// Reads every row: preview, column kinds, decoding.
fn scan(bytes: &[u8], how: &Import) -> Scan {
let Some(text) = decode(bytes, how.encoding) else {
return Scan {
rows: Vec::new(),
columns: Vec::new(),
broken: true,
};
};
let mut columns = Vec::new();
let mut rows = Vec::new();
for row in split(&text, how) {
if columns.len() < row.len() {
columns.resize(row.len(), Column::Standard);
}
for (at, value) in row.iter().enumerate() {
if is_code(value.trim()) {
columns[at] = Column::Text;
}
}
if rows.len() < SHOWN {
rows.push(row.iter().map(|value| value.chars().take(CELL).collect()).collect());
}
}
Scan {
rows,
columns,
broken: false,
}
}
fn guess_encoding(sample: &[u8], chinese: bool) -> Encoding {
if sample.starts_with(&[0xFF, 0xFE]) || sample.starts_with(&[0xFE, 0xFF]) {
return Encoding::Utf16;
}
match std::str::from_utf8(sample) {
Ok(_) => Encoding::Utf8,
Err(_) if chinese => Encoding::Gb18030,
Err(_) => Encoding::Windows1252,
}
}
/// TSV by name, else line one's commonest separator.
fn guess_separator(path: &Path, sample: &[u8]) -> char {
let ext = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
if ["tsv", "tab"].contains(&ext.to_ascii_lowercase().as_str()) {
return '\t';
}
let line = sample.split(|byte| *byte == b'\n').next().unwrap_or(&[]);
let count = |separator: char| line.iter().filter(|byte| char::from(**byte) == separator).count();
SEPARATORS[..4]
.iter()
.copied()
.rev()
.max_by_key(|separator| count(*separator))
.filter(|separator| count(*separator) > 0)
.unwrap_or(',')
}
/// The text, or None where a byte breaks the encoding.
fn decode(bytes: &[u8], encoding: Encoding) -> Option<String> {
let (codec, bom): (&'static encoding_rs::Encoding, &[u8]) = match encoding {
Encoding::Utf8 => (encoding_rs::UTF_8, &[0xEF, 0xBB, 0xBF]),
Encoding::Utf16 if bytes.starts_with(&[0xFE, 0xFF]) => (encoding_rs::UTF_16BE, &[0xFE, 0xFF]),
Encoding::Utf16 => (encoding_rs::UTF_16LE, &[0xFF, 0xFE]),
Encoding::Gb18030 => (encoding_rs::GB18030, &[]),
Encoding::Big5 => (encoding_rs::BIG5, &[]),
Encoding::ShiftJis => (encoding_rs::SHIFT_JIS, &[]),
Encoding::EucKr => (encoding_rs::EUC_KR, &[]),
Encoding::Windows1252 => (encoding_rs::WINDOWS_1252, &[]),
Encoding::Windows1251 => (encoding_rs::WINDOWS_1251, &[]),
// Latin-1 maps each byte to its code point.
Encoding::Latin1 => return Some(bytes.iter().map(|byte| char::from(*byte)).collect()),
};
let body = bytes.strip_prefix(bom).unwrap_or(bytes);
codec
.decode_without_bom_handling_and_without_replacement(body)
.map(|text| text.into_owned())
}
/// Rows as Calc's text import reads them (impex.cxx).
fn split(text: &str, how: &Import) -> Vec<Vec<String>> {
let mut chars = text.chars().peekable();
let mut rows = Vec::new();
while chars.peek().is_some() {
let mut row = Vec::new();
loop {
let (value, more) = field(&mut chars, how);
row.push(value);
if !more {
break;
}
}
rows.push(row);
}
rows
}
/// One field, and whether the record goes on.
fn field(chars: &mut Peekable<Chars>, how: &Import) -> (String, bool) {
let mut value = String::new();
if let Some(quote) = how.quote {
// Calc skips blanks before a quote unless blank splits.
let mut ahead = chars.clone();
while how.separator != ' ' && ahead.next_if_eq(&' ').is_some() {}
if ahead.peek() == Some(&quote) {
*chars = ahead;
chars.next();
while let Some(c) = chars.next() {
match c {
c if c == quote && chars.next_if_eq(&quote).is_some() => value.push(quote),
c if c == quote => break,
c => value.push(c),
}
}
}
}
while let Some(c) = chars.next() {
match c {
'\n' => return (value, false),
'\r' => {
chars.next_if_eq(&'\n');
return (value, false);
}
c if c == how.separator => return (value, true),
c => value.push(c),
}
}
(value, false)
}
/// A plus sign, leading zeros, or over 15 digits.
fn is_code(value: &str) -> bool {
let digits = value.trim_start_matches(['+', '-']);
let signs = value.len() - digits.len();
!digits.is_empty()
&& signs <= 1
&& digits.bytes().all(|byte| byte.is_ascii_digit())
&& (value.starts_with('+')
|| (digits.len() > 1 && digits.starts_with('0'))
|| digits.len() > 15)
}
impl Book {
/// Opens a workbook; text files ask how to read first.
pub(crate) fn on_open_file(&mut self, _: &Open, _: &mut Window, cx: &mut Context<Self>) {
self.pick(false, cx, Self::open_asked);
}
/// Opens a workbook; a text file asks first.
pub(crate) fn open_asked(&mut self, path: PathBuf, cx: &mut Context<Self>) {
match is_text(&path) {
true => self.ask_import(path, cx),
false => self.open_file(path, cx),
}
}
pub(crate) fn ask_import(&mut self, path: PathBuf, cx: &mut Context<Self>) {
let reading = path.clone();
let chinese = ely_ui::i18n::resolve(&ely_platform::locale::system()) == "zh-CN";
cx.spawn(async move |this, cx| {
let bytes = cx
.background_spawn(async move { std::fs::read(&reading) })
.await;
in_window(&this, cx, |book, _, cx| match bytes {
Ok(bytes) => {
log::info!("table: import asks for {} bytes", bytes.len());
book.imports.push_back(Pending::new(path, bytes, chinese));
book.next_import(cx);
}
Err(error) => book.failed_open(&path.display().to_string(), &error, cx),
})
})
.detach_and_log_err(cx);
}
/// The next waiting import asks, once no dialog is open.
pub(crate) fn next_import(&mut self, cx: &mut Context<Self>) {
if self.dialog.is_some() {
return;
}
if let Some(pending) = self.imports.pop_front() {
self.dialog = Some(Dialog::Import(Box::new(pending)));
self.scan_import(cx);
}
}
/// Reads the whole file again for the dialog's choices.
pub(crate) fn scan_import(&mut self, cx: &mut Context<Self>) {
let Some(Dialog::Import(pending)) = &mut self.dialog else {
return;
};
pending.asked += 1;
pending.reading = true;
let (bytes, how, asked) = (Arc::clone(&pending.bytes), pending.how.clone(), pending.asked);
let scanned = Arc::clone(&bytes);
cx.notify();
cx.spawn(async move |this, cx| {
let found = cx.background_spawn(async move { scan(&bytes, &how) }).await;
in_window(&this, cx, |book, _, cx| {
// Only this dialog's latest scan lands.
let Some(Dialog::Import(pending)) = &mut book.dialog else {
return;
};
if pending.asked != asked || !Arc::ptr_eq(&pending.bytes, &scanned) {
return;
}
log::info!(
"table: import scan: {} columns, broken {}",
found.columns.len(),
found.broken
);
(pending.rows, pending.how.columns) = (found.rows, found.columns);
(pending.broken, pending.reading) = (found.broken, false);
cx.notify();
})
})
.detach_and_log_err(cx);
}
/// Imports the file as the dialog says.
pub(crate) fn import_asked(&mut self, cx: &mut Context<Self>) {
let Some(Dialog::Import(pending)) = self.dialog.take_if(|dialog| {
matches!(dialog, Dialog::Import(pending) if !pending.reading && !pending.broken)
}) else {
return;
};
let how = &pending.how;
log::info!(
"table: imports as {:?}, {:?}, quote {:?}",
how.encoding,
how.separator,
how.quote
);
let name = ely_office::files::file_name(&pending.path);
self.import_text(&name, Arc::clone(&pending.bytes), pending.how.clone(), cx);
cx.notify();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codes_read_as_text_and_numbers_stay_numbers() {
assert!(is_code("00123") && is_code("+8613800000000"));
assert!(is_code("12345678901234567890"));
assert!(is_code("-12345678901234567890") && is_code("-007"));
assert!(!is_code("123") && !is_code("0") && !is_code("1.50") && !is_code("+"));
assert!(!is_code("-5") && !is_code("+-1"));
}
#[test]
fn guesses_follow_the_bytes_and_the_name() {
let csv = Path::new("a.csv");
assert_eq!(guess_separator(csv, b"a;b;c,d\n1;2"), ';');
assert_eq!(guess_separator(csv, b"one\n"), ',');
assert_eq!(guess_separator(Path::new("a.TSV"), b"a,b"), '\t');
assert_eq!(guess_encoding("名称".as_bytes(), false), Encoding::Utf8);
assert_eq!(guess_encoding(&[0xC3, 0xFB], true), Encoding::Gb18030);
assert_eq!(guess_encoding(&[0xC3, 0xFB], false), Encoding::Windows1252);
assert_eq!(guess_encoding(&[0xFF, 0xFE, b'a', 0], false), Encoding::Utf16);
}
#[test]
fn scans_read_every_row_and_refuse_broken_bytes() {
let mut how = Import {
separator: ',',
quote: Some('"'),
encoding: Encoding::Utf8,
columns: Vec::new(),
};
let mut text = "Name,Note\n\"Doe, J\",1\n".to_string();
text.push_str(&"Ada,123\n".repeat(20_000));
text.push_str("Bo,00123\n");
let found = scan(text.as_bytes(), &how);
assert_eq!(found.rows.len(), SHOWN);
assert_eq!(found.rows[1], ["Doe, J", "1"]);
assert_eq!(found.columns, [Column::Standard, Column::Text], "the last row counts");
let long = format!("{},x\n", "a".repeat(100_000));
assert_eq!(scan(long.as_bytes(), &how).rows[0][0].chars().count(), CELL);
assert!(scan(b"A\xffB\n", &how).broken, "no silent replacement");
let calc = scan(b"Id,Note\n \"00123\",x\n\n\"a \"\"b\"\"\"c,\"two\nlines\"\r\n", &how);
assert_eq!(calc.rows[1], ["00123", "x"], "blanks before a quote go");
assert_eq!(calc.rows[2], [""], "an empty line is a row");
assert_eq!(calc.rows[3], ["a \"b\"c", "two\nlines"]);
assert_eq!(calc.columns, [Column::Text, Column::Standard]);
how.quote = None;
assert_eq!(scan(b"\"Doe, J\"\n", &how).rows[0], ["\"Doe", " J\""]);
how.encoding = Encoding::Latin1;
assert_eq!(scan(&[b'c', 0xE9], &how).rows[0], ["cé"]);
}
}
+168
View File
@@ -0,0 +1,168 @@
//! The import dialog: encoding, separator, quote, column kinds.
use ely_gpui_component::{
buttons::{Button, ButtonVariant, SegmentedControl},
data_display::DescriptionList,
feedback::InlineMessage,
forms::{Choice, Select},
layout::ScrollArea,
overlays::Dialog as Card,
primitives::Severity,
theme::{ActiveTheme as _, ControlSize},
};
use ely_lok::sheet::csv::{COLUMNS, Column, ENCODINGS};
use gpui::{App, Entity, ParentElement as _, SharedString, Styled as _, Window, div, rems};
use super::{Pending, SEPARATORS};
use crate::book::Book;
use crate::dialog::{Dialog, edit};
fn pending_of(dialog: &mut Dialog) -> &mut Pending {
match dialog {
Dialog::Import(pending) => pending,
_ => unreachable!("an import dialog"),
}
}
/// Changes a choice that reads the file anew.
fn reread(this: &Entity<Book>, cx: &mut App, change: impl FnOnce(&mut Pending)) {
edit(this, cx, |dialog, _, _| change(pending_of(dialog)));
this.update(cx, |book, cx| book.scan_import(cx));
}
fn column_key(column: Column) -> &'static str {
match column {
Column::Standard => "table.import.standard",
Column::Text => "table.import.text",
Column::DateMdy => "table.import.mdy",
Column::DateDmy => "table.import.dmy",
Column::DateYmd => "table.import.ymd",
Column::Skip => "table.import.skip",
}
}
fn separator_key(separator: char) -> &'static str {
match separator {
',' => "table.import.comma",
';' => "table.import.semicolon",
'\t' => "table.import.tab",
'|' => "table.import.pipe",
_ => "table.import.space",
}
}
/// One column: its kind above its first cells.
fn column(this: &Entity<Book>, at: usize, kind: Column, rows: &[Vec<String>], cx: &App) -> gpui::Div {
let label = |key: &str| Book::text(cx, key, &[]);
let picking = this.clone();
let kinds = Select::new(
("table-import-kind", at),
COLUMNS
.iter()
.enumerate()
.map(|(index, kind)| Choice::new(index.to_string(), label(column_key(*kind)))),
)
.label(Book::text(cx, "table.import.kind_of", &[("n", &(at + 1).to_string())]))
.selected(COLUMNS.iter().position(|each| *each == kind).expect("listed").to_string())
.on_change(move |value, _, cx| {
let Ok(index) = value.parse::<usize>() else {
return;
};
edit(&picking, cx, |dialog, _, _| {
pending_of(dialog).how.columns[at] = COLUMNS[index];
})
});
let fg = cx.theme().colors.fg;
let cells = rows.iter().map(|row| {
div()
.h_6()
.truncate()
.text_color(fg)
.child(SharedString::from(row.get(at).cloned().unwrap_or_default()))
});
div().w_32().flex_none().flex().flex_col().gap_1().child(kinds).children(cells)
}
pub fn import_dialog(
this: Entity<Book>,
pending: &Pending,
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Card {
let label = |key: &str| Book::text(cx, key, &[]);
let how = &pending.how;
let picking = this.clone();
let encoding = Select::new(
"table-import-encoding",
ENCODINGS
.iter()
.enumerate()
.map(|(index, encoding)| Choice::new(index.to_string(), encoding.label())),
)
.label(label("table.import.encoding"))
.selected(ENCODINGS.iter().position(|e| *e == how.encoding).expect("listed").to_string())
.on_change(move |value, _, cx| {
if let Ok(index) = value.parse::<usize>() {
reread(&picking, cx, |pending| pending.how.encoding = ENCODINGS[index]);
}
});
let picking = this.clone();
let separator = Select::new(
"table-import-separator",
SEPARATORS
.iter()
.map(|each| Choice::new(each.to_string(), label(separator_key(*each)))),
)
.label(label("table.import.separator"))
.selected(how.separator.to_string())
.on_change(move |value, _, cx| {
if let Some(separator) = value.chars().next() {
reread(&picking, cx, |pending| pending.how.separator = separator);
}
});
let picking = this.clone();
let quoted = how.quote.map_or(String::new(), String::from);
let quote = SegmentedControl::new("table-import-quote", quoted)
.size(ControlSize::Sm)
.segment("\"", "\"", None)
.segment("'", "'", None)
.segment("", label("table.import.no_quote"), None)
.on_change(move |value, _, cx| {
let quote = value.chars().next();
reread(&picking, cx, |pending| pending.how.quote = quote);
});
let choices = DescriptionList::new()
.item(label("table.import.encoding"), div().w_full().max_w_48().child(encoding))
.item(label("table.import.separator"), div().w_full().max_w_48().child(separator))
.item(label("table.import.quote"), quote);
let columns = how
.columns
.iter()
.enumerate()
.map(|(at, kind)| column(&this, at, *kind, &pending.rows, cx));
// As wide as its columns, so it scrolls.
let shown = how.columns.len() as f32;
let row = div().w(rems(shown * 8.5)).flex().gap_2().pb_2().children(columns);
let preview = ScrollArea::new("table-import-preview").h_64().child(row);
let broken = pending
.broken
.then(|| InlineMessage::new(Severity::Danger, label("table.import.broken")));
let off = pending.reading || pending.broken;
let name = ely_office::files::file_name(&pending.path);
let go = label("table.import.go");
Card::new(
"table-import",
Book::text(cx, "table.import.title", &[("name", &name)]),
dismiss,
)
.child(choices)
.children(broken)
.child(preview)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("table-import-go", go)
.variant(ButtonVariant::Primary)
.disabled(off)
.on_click(move |_, _, cx| this.update(cx, |book, cx| book.import_asked(cx)))
})
}