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:
817 files changed
+143101
No files matched your search
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"]"#));
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, &[]));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
@@ -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", &[¤t.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"]);
|
||||
}
|
||||
}
|
||||
@@ -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", ¤t)]);
|
||||
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)))
|
||||
})
|
||||
}
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('"', """);
|
||||
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"
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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("e) {
|
||||
*chars = ahead;
|
||||
chars.next();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
c if c == quote && chars.next_if_eq("e).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é"]);
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user