1use 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
37const OPEN_EXTENSIONS: [&str; 6] = ["xlsx", "xlsm", "xls", "ods", "csv", "tsv"];
39const OPEN_MOST_BYTES: u64 = 64 * 1024 * 1024;
41
42#[derive(Clone, Debug)]
44#[non_exhaustive]
45pub struct SpreadsheetLabels {
46 pub grid: String,
48 pub formula: String,
50 pub undo: String,
52 pub redo: String,
54 pub bold: String,
56 pub italic: String,
58 pub underline: String,
60 pub strikethrough: String,
62 pub align_left: String,
64 pub align_center: String,
66 pub align_right: String,
68 pub currency: String,
70 pub percent: String,
72 pub auto_sum: String,
74 pub open: String,
76 pub save: String,
78 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 pub fn for_culture(culture: &str) -> Self {
91 if culture.starts_with("pt") { Self::pt_br() } else { Self::default() }
92 }
93
94 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 pub fn pt_br() -> Self {
119 Self::from_texts(&Texts::new(Language::BrazilianPortuguese))
120 }
121}
122
123pub 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 pub fn new(state: &SpreadsheetState) -> Self {
149 Self { state: state.clone(), labels: None, toolbar: true, file_buttons: true, read_only: false }
150 }
151
152 pub fn labels(mut self, labels: SpreadsheetLabels) -> Self {
154 self.labels = Some(labels);
155 self
156 }
157
158 pub fn toolbar(mut self, show: bool) -> Self {
160 self.toolbar = show;
161 self
162 }
163
164 pub fn file_buttons(mut self, show: bool) -> Self {
166 self.file_buttons = show;
167 self
168 }
169
170 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}