Skip to main content

ravenrs_spreadsheet/
lib.rs

1//! A spreadsheet component for RavenRS: opens, edits and saves `.xlsx` workbooks, with formulas
2//! that recalculate, on the desktop and on the web.
3//!
4//! ```no_run
5//! use ravenrs_spreadsheet::{Spreadsheet, SpreadsheetState};
6//!
7//! let sheet = SpreadsheetState::new();
8//! sheet.set_cell("A1", "Total").unwrap();
9//! sheet.set_cell("B1", "=SUM(B2:B10)").unwrap();
10//! let view = Spreadsheet::new(&sheet);
11//! ```
12//!
13//! [`SpreadsheetState`] holds the workbook and is shared with the application; [`Spreadsheet`]
14//! shows it with a toolbar, a formula bar, the grid and the sheet tabs. The engine underneath
15//! is `ravenrs-sheet`, where every change is a command with undo.
16
17use ravenrs_app_i18n::{Language, Texts};
18use ravenrs_core::{Insets, Role};
19use ravenrs_layout::{Align, Axis, LayoutStyle};
20use ravenrs_reactive::Binding;
21use ravenrs_style::{SPACING, Semantic, TextStyle};
22use ravenrs_widgets::{
23    AnyView, BuildCx, Button, ButtonSize, ButtonStyle, Divider, FileRequest, HStack, IconButton, IconName,
24    NodeId, Paint, ScrollView, Semantics, Spacer, Text, TextField, View, ViewExt,
25};
26use serde_json::json;
27
28mod geometry;
29mod state;
30mod surface;
31
32pub use ravenrs_sheet as sheet;
33pub use state::SpreadsheetState;
34
35use state::EditPlace;
36
37/// Files Open offers.
38const OPEN_EXTENSIONS: [&str; 6] = ["xlsx", "xlsm", "xls", "ods", "csv", "tsv"];
39/// Biggest file Open reads.
40const OPEN_MOST_BYTES: u64 = 64 * 1024 * 1024;
41
42/// The text a spreadsheet shows.
43#[derive(Clone, Debug)]
44#[non_exhaustive]
45pub struct SpreadsheetLabels {
46    /// The grid's name, for screen readers.
47    pub grid: String,
48    /// The formula bar's hint.
49    pub formula: String,
50    /// Undoes the last change.
51    pub undo: String,
52    /// Redoes what was undone.
53    pub redo: String,
54    /// Bold.
55    pub bold: String,
56    /// Italic.
57    pub italic: String,
58    /// Underline.
59    pub underline: String,
60    /// Strikethrough.
61    pub strikethrough: String,
62    /// Aligns to the left.
63    pub align_left: String,
64    /// Centers.
65    pub align_center: String,
66    /// Aligns to the right.
67    pub align_right: String,
68    /// The currency format.
69    pub currency: String,
70    /// The percentage format.
71    pub percent: String,
72    /// Sums the cells above or to the left.
73    pub auto_sum: String,
74    /// Opens a file.
75    pub open: String,
76    /// Saves the workbook as `.xlsx`.
77    pub save: String,
78    /// Adds a sheet.
79    pub add_sheet: String,
80}
81
82impl Default for SpreadsheetLabels {
83    fn default() -> Self {
84        Self::from_texts(&Texts::new(Language::English))
85    }
86}
87
88impl SpreadsheetLabels {
89    /// The labels for a language and region such as `pt-BR`.
90    pub fn for_culture(culture: &str) -> Self {
91        if culture.starts_with("pt") { Self::pt_br() } else { Self::default() }
92    }
93
94    /// The texts of a catalog, in its language. What it does not have comes from English.
95    pub fn from_texts(texts: &Texts) -> Self {
96        Self {
97            grid: texts.get("spreadsheet.grid"),
98            formula: texts.get("spreadsheet.formula"),
99            undo: texts.get("spreadsheet.undo"),
100            redo: texts.get("spreadsheet.redo"),
101            bold: texts.get("spreadsheet.bold"),
102            italic: texts.get("spreadsheet.italic"),
103            underline: texts.get("spreadsheet.underline"),
104            strikethrough: texts.get("spreadsheet.strikethrough"),
105            align_left: texts.get("spreadsheet.align_left"),
106            align_center: texts.get("spreadsheet.align_center"),
107            align_right: texts.get("spreadsheet.align_right"),
108            currency: texts.get("spreadsheet.currency"),
109            percent: texts.get("spreadsheet.percent"),
110            auto_sum: texts.get("spreadsheet.auto_sum"),
111            open: texts.get("spreadsheet.open"),
112            save: texts.get("spreadsheet.save"),
113            add_sheet: texts.get("spreadsheet.add_sheet"),
114        }
115    }
116
117    /// The labels in Brazilian Portuguese.
118    pub fn pt_br() -> Self {
119        Self::from_texts(&Texts::new(Language::BrazilianPortuguese))
120    }
121}
122
123/// A spreadsheet: toolbar, formula bar, the grid of cells and the sheet tabs.
124///
125/// Click or drag to select, type to replace a cell, double-click or F2 to edit it, Enter and
126/// Tab to move on. Cmd or Ctrl with C, X, V, Z, Y, B, I, U and A copy, cut, paste, undo, redo,
127/// format and select all. Dragging a column header's edge sizes the column, and double-clicking
128/// it fits the column to its content.
129pub struct Spreadsheet {
130    state: SpreadsheetState,
131    labels: Option<SpreadsheetLabels>,
132    toolbar: bool,
133    file_buttons: bool,
134    read_only: bool,
135}
136
137impl std::fmt::Debug for Spreadsheet {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.debug_struct("Spreadsheet")
140            .field("state", &self.state)
141            .field("read_only", &self.read_only)
142            .finish_non_exhaustive()
143    }
144}
145
146impl Spreadsheet {
147    /// Shows `state`'s workbook.
148    pub fn new(state: &SpreadsheetState) -> Self {
149        Self { state: state.clone(), labels: None, toolbar: true, file_buttons: true, read_only: false }
150    }
151
152    /// Replaces the labels, which otherwise follow the application's language.
153    pub fn labels(mut self, labels: SpreadsheetLabels) -> Self {
154        self.labels = Some(labels);
155        self
156    }
157
158    /// Shows or hides the toolbar (shown by default).
159    pub fn toolbar(mut self, show: bool) -> Self {
160        self.toolbar = show;
161        self
162    }
163
164    /// Shows or hides Open and Save on the toolbar (shown by default).
165    pub fn file_buttons(mut self, show: bool) -> Self {
166        self.file_buttons = show;
167        self
168    }
169
170    /// Lets the workbook be read, selected and copied, but not changed.
171    pub fn read_only(mut self, read_only: bool) -> Self {
172        self.read_only = read_only;
173        self
174    }
175
176    fn command_button(
177        &self,
178        icon: IconName,
179        label: &str,
180        command: &'static str,
181        params: serde_json::Value,
182    ) -> AnyView {
183        let state = self.state.clone();
184        Box::new(IconButton::new(icon).label(label.to_owned()).size(ButtonSize::Small).on_click(move || {
185            let _ = state.execute(command, params.clone());
186        }))
187    }
188
189    fn text_button(
190        &self,
191        text: &str,
192        label: &str,
193        command: &'static str,
194        params: serde_json::Value,
195    ) -> AnyView {
196        let state = self.state.clone();
197        Box::new(
198            Button::new(text.to_owned())
199                .style(ButtonStyle::Ghost)
200                .size(ButtonSize::Small)
201                .accessible_name(label.to_owned())
202                .on_click(move || {
203                    let _ = state.execute(command, params.clone());
204                }),
205        )
206    }
207
208    fn toolbar_view(&self, labels: &SpreadsheetLabels, cx: &mut BuildCx<'_>) -> impl View + use<> {
209        let mut items: Vec<AnyView> = Vec::new();
210        if self.file_buttons {
211            let (open, picker, waker) = (self.state.clone(), cx.file_picker(), cx.ui_waker());
212            items.push(Box::new(
213                IconButton::new(IconName::FolderOpen)
214                    .label(labels.open.clone())
215                    .size(ButtonSize::Small)
216                    .on_click(move || {
217                        let (open, waker) = (open.clone(), waker.clone());
218                        let mut request = FileRequest::default();
219                        request.extensions = OPEN_EXTENSIONS.iter().map(|e| (*e).to_owned()).collect();
220                        request.most_bytes = Some(OPEN_MOST_BYTES);
221                        picker.pick(&request, move |files| {
222                            if let Some(file) = files.first() {
223                                if let Err(error) = open.replace(&file.name, &file.bytes) {
224                                    open.show_error(error);
225                                }
226                                waker.wake();
227                            }
228                        });
229                    }),
230            ));
231            let (save, picker) = (self.state.clone(), cx.file_picker());
232            items.push(Box::new(
233                IconButton::new(IconName::Save).label(labels.save.clone()).size(ButtonSize::Small).on_click(
234                    move || {
235                        if let Ok(bytes) = save.to_xlsx() {
236                            picker.save(&save.file_name(), &bytes);
237                        }
238                    },
239                ),
240            ));
241            items.push(Box::new(Divider::vertical()));
242        }
243        if !self.read_only {
244            items.push(self.command_button(IconName::Undo2, &labels.undo, "edit.undo", json!({})));
245            items.push(self.command_button(IconName::Redo2, &labels.redo, "edit.redo", json!({})));
246            items.push(Box::new(Divider::vertical()));
247            items.push(self.command_button(IconName::Bold, &labels.bold, "home.bold", json!({})));
248            items.push(self.command_button(IconName::Italic, &labels.italic, "home.italic", json!({})));
249            items.push(self.command_button(
250                IconName::Underline,
251                &labels.underline,
252                "home.underline",
253                json!({}),
254            ));
255            items.push(self.command_button(
256                IconName::Strikethrough,
257                &labels.strikethrough,
258                "home.strikethrough",
259                json!({}),
260            ));
261            items.push(Box::new(Divider::vertical()));
262            items.push(self.command_button(
263                IconName::AlignLeft,
264                &labels.align_left,
265                "home.alignLeft",
266                json!({}),
267            ));
268            items.push(self.command_button(
269                IconName::AlignCenter,
270                &labels.align_center,
271                "home.alignCenter",
272                json!({}),
273            ));
274            items.push(self.command_button(
275                IconName::AlignRight,
276                &labels.align_right,
277                "home.alignRight",
278                json!({}),
279            ));
280            items.push(Box::new(Divider::vertical()));
281            items.push(self.command_button(
282                IconName::DollarSign,
283                &labels.currency,
284                "home.numberFormat",
285                json!({ "format": "Currency" }),
286            ));
287            items.push(self.text_button("%", &labels.percent, "home.numberFormat", json!({ "code": "0%" })));
288            items.push(self.command_button(IconName::Sigma, &labels.auto_sum, "formulas.autoSum", json!({})));
289        }
290        HStack::new(items)
291            .spacing(SPACING.s1)
292            .align(Align::Center)
293            .wrap()
294            .padding(Insets::symmetric(SPACING.s1, SPACING.s3))
295            .background(Semantic::Surface)
296    }
297
298    fn formula_bar(&self, labels: &SpreadsheetLabels) -> impl View + use<> {
299        let active = self.state.active_cell();
300        let (read, write) = (self.state.clone(), self.state.clone());
301        let (submit, cancel) = (self.state.clone(), self.state.clone());
302        let input = TextField::new(Binding::new(
303            move || match read.edit().get() {
304                Some(edit) => edit.text,
305                None => read.cell_input(&read.active_cell()),
306            },
307            move |text: String| match write.edit().get() {
308                Some(_) => write.edit().update(|e| {
309                    if let Some(e) = e {
310                        e.text = text;
311                    }
312                }),
313                None => write.begin_edit(Some(text), EditPlace::Bar),
314            },
315        ))
316        .placeholder(labels.formula.clone())
317        .compact()
318        .flat()
319        .read_only(self.read_only)
320        .on_submit(move |_| submit.commit_edit(1, 0))
321        .on_cancel(move || cancel.cancel_edit());
322        let mut row: Vec<AnyView> = vec![
323            Box::new(Text::new(active).style(TextStyle::Callout).foreground(Semantic::InkMuted).width(72.0)),
324            Box::new(Divider::vertical()),
325            Box::new(Text::new("fx").style(TextStyle::Callout).foreground(Semantic::InkMuted)),
326            Box::new(input.grow(1.0)),
327        ];
328        if let Some(error) = self.state.error() {
329            row.push(Box::new(
330                Text::new(error).style(TextStyle::Caption).foreground(Semantic::Danger).truncate(),
331            ));
332        }
333        HStack::new(row)
334            .spacing(SPACING.s2)
335            .align(Align::Center)
336            .padding(Insets::symmetric(SPACING.s1, SPACING.s3))
337    }
338
339    fn sheet_tabs(&self, labels: &SpreadsheetLabels) -> impl View + use<> {
340        let active = self.state.active_sheet();
341        let mut tabs: Vec<AnyView> = self
342            .state
343            .sheet_names()
344            .into_iter()
345            .enumerate()
346            .map(|(i, name)| {
347                let state = self.state.clone();
348                Box::new(
349                    Button::new(name)
350                        .style(if i == active { ButtonStyle::Secondary } else { ButtonStyle::Ghost })
351                        .size(ButtonSize::Small)
352                        .on_click(move || {
353                            let _ = state.execute("sheet.activate", json!({ "sheet": i }));
354                        }),
355                ) as AnyView
356            })
357            .collect();
358        if !self.read_only {
359            tabs.push(self.command_button(
360                IconName::Plus,
361                &labels.add_sheet,
362                "home.insertSheet",
363                json!({ "before": usize::MAX }),
364            ));
365        }
366        tabs.push(Box::new(Spacer::new()));
367        ScrollView::new((HStack::new(tabs)
368            .spacing(SPACING.s1)
369            .align(Align::Center)
370            .padding(Insets::symmetric(SPACING.s1, SPACING.s3)),))
371        .horizontal()
372        .background(Semantic::Surface)
373    }
374}
375
376impl View for Spreadsheet {
377    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
378        let _ = self.state.version();
379        let labels =
380            self.labels.clone().unwrap_or_else(|| SpreadsheetLabels::for_culture(&cx.labels().culture));
381        let mut children = Vec::new();
382        if self.toolbar {
383            let bar = self.toolbar_view(&labels, cx);
384            children.push(cx.child(&bar));
385            children.push(cx.child(&Divider::horizontal()));
386        }
387        children.push(cx.child(&self.formula_bar(&labels)));
388        children.push(cx.child(&Divider::horizontal()));
389        let grid = surface::Surface {
390            state: self.state.clone(),
391            name: labels.grid.clone(),
392            read_only: self.read_only,
393        };
394        children.push(cx.child(&grid));
395        children.push(cx.child(&Divider::horizontal()));
396        children.push(cx.child(&self.sheet_tabs(&labels)));
397        let root = cx.container(
398            LayoutStyle {
399                grow: 1.0,
400                min_height: Some(0.0),
401                overflow_hidden: true,
402                align_self: Some(Align::Stretch),
403                ..LayoutStyle::stack(Axis::Vertical)
404            },
405            &children,
406            Paint::None,
407        );
408        cx.semantics(root, Semantics::new(Role::Group).name(labels.grid));
409        root
410    }
411}