Files
ElyOffice/apps/ely-table/src/format.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

467 lines
16 KiB
Rust

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