Files
ElyOffice/apps/ely-table/src/rules.rs
T

380 lines
13 KiB
Rust

//! 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.open_dialog(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.open_dialog(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.open_dialog(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");
}
}