Files
ElyOffice/apps/ely-table/src/formulas.rs
T
ZacharyZhang-NY b7fca0146d 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.
2026-10-08 21:20:08 -04:00

273 lines
9.8 KiB
Rust

//! 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,
}