visi_core/core/engine/sheet/edit.rs
1//! Cell and range accessors, text editing, styling, and the row/column
2//! structural operations.
3//!
4//! Split out of the parent module; these are the operations that change a
5//! sheet's *shape* or a cell's raw content, as opposed to evaluating it.
6
7use super::super::column::{ColumnPosition, DataColumn};
8use super::{CellRef, Direction, ResultData, Sheet, TextCellRef};
9
10/// The word surrounding `char_offset` in `text`, as a half-open range of
11/// character indices.
12///
13/// A "word" is a run of alphanumerics and underscores, a run of whitespace, or
14/// a run of punctuation -- so double-clicking in a formula selects a function
15/// name or a cell reference rather than the whole line. An offset at the end
16/// of the text, or one just past a word onto whitespace, selects the word to
17/// its left.
18pub fn get_word_boundaries_from_str(text: &str, char_offset: usize) -> (usize, usize) {
19 if text.is_empty() {
20 return (0, 0);
21 }
22
23 let chars: Vec<char> = text.chars().collect();
24 let len = chars.len();
25 let offset = char_offset.min(len);
26
27 let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
28
29 let (on_idx, on_c) = if offset < len {
30 if chars[offset].is_whitespace() && offset > 0 && is_word_char(chars[offset - 1]) {
31 (offset - 1, chars[offset - 1])
32 } else {
33 (offset, chars[offset])
34 }
35 } else if offset > 0 {
36 (offset - 1, chars[offset - 1])
37 } else {
38 return (0, 0);
39 };
40
41 if on_c.is_whitespace() {
42 let mut start = on_idx;
43 while start > 0 && chars[start - 1].is_whitespace() {
44 start -= 1;
45 }
46 let mut end = on_idx + 1;
47 while end < len && chars[end].is_whitespace() {
48 end += 1;
49 }
50 return (start, end);
51 }
52
53 if is_word_char(on_c) {
54 let mut start = on_idx;
55 while start > 0 && is_word_char(chars[start - 1]) {
56 start -= 1;
57 }
58 let mut end = on_idx + 1;
59 while end < len && is_word_char(chars[end]) {
60 end += 1;
61 }
62 return (start, end);
63 }
64
65 let mut start = on_idx;
66 while start > 0 && !is_word_char(chars[start - 1]) && !chars[start - 1].is_whitespace() {
67 start -= 1;
68 }
69 let mut end = on_idx + 1;
70 while end < len && !is_word_char(chars[end]) && !chars[end].is_whitespace() {
71 end += 1;
72 }
73 (start, end)
74}
75impl Sheet {
76 /// The computed value of a cell, or [`ResultData::None`] if it is empty
77 /// or outside the sheet's allocated grid.
78 ///
79 /// Reflects the last [`Sheet::commit`]; a cell edited since then still
80 /// reads as its old value.
81 ///
82 /// A date reads back as the plain numeric serial it is. Rendering it in
83 /// the notation the cell carries is `Sheet::get_display_string`'s job, and
84 /// only its -- do not format a `ResultData` directly if a user will see it.
85 pub fn get_result_data(&self, cell: &CellRef) -> ResultData {
86 let col = self.columns.get(cell.col);
87 if let Some(col) = col {
88 col.data.get(cell.row).unwrap_or(ResultData::None)
89 } else {
90 ResultData::None
91 }
92 }
93
94 /// The date format a formula should inherit from the cells it reads, if
95 /// any -- Excel's "date plus a number is still a date" behavior.
96 ///
97 /// The rule is deliberately about the *operator*, not about how many
98 /// cells the formula touches, because those come apart: `=YEAR(A1)` reads
99 /// exactly one date cell and returns a year, which is emphatically not a
100 /// date. So only two shapes inherit:
101 ///
102 /// - a bare reference to a date cell (`=A1`), and
103 /// - adding or subtracting a non-date from one (`=A1+1`, `=1+A1`).
104 ///
105 /// Everything else -- a function call, a product, a difference of two
106 /// dates (which is a count of days) -- declines, leaving a plain number.
107 pub(super) fn inherited_date_format(&self, ast: &crate::core::parser::Expr) -> Option<String> {
108 use crate::core::parser::{Expr, Op};
109 match ast {
110 Expr::CellRef {
111 sheet, row, col, ..
112 } if sheet.is_none() => self
113 .get_cell_style(*row, *col)
114 .and_then(|s| s.num_format.clone())
115 .filter(|code| crate::core::date::is_date_code(code)),
116 Expr::BinaryOp {
117 op: Op::Add | Op::Sub,
118 left,
119 right,
120 } => {
121 let left_fmt = self.inherited_date_format(left);
122 let right_fmt = self.inherited_date_format(right);
123 match (left_fmt, right_fmt) {
124 // Exactly one side is a date: the other is an offset in
125 // days, so the result stays that date's format.
126 (Some(fmt), None) | (None, Some(fmt)) => Some(fmt),
127 // Neither, or both (a day count) -- no date format.
128 _ => None,
129 }
130 }
131 _ => None,
132 }
133 }
134
135 /// The cell's value as it should be shown, honoring the cell's number
136 /// format.
137 ///
138 /// A date cell holds a plain numeric serial, exactly as in Excel, so
139 /// rendering it as a date is a display-time concern: this is the only
140 /// place that turns 46195 back into `6/22/26`. Everything that shows a
141 /// value to a user should go through here rather than formatting
142 /// [`ResultData`] directly, which knows nothing about formats.
143 pub fn get_display_string(&self, cell: &CellRef) -> String {
144 let value = self.get_result_data(cell);
145 let Some(code) = self
146 .get_cell_style(cell.row, cell.col)
147 .and_then(|s| s.num_format.as_deref())
148 else {
149 return value.to_string();
150 };
151 if !crate::core::date::is_date_code(code) {
152 return value.to_string();
153 }
154 // Only a number is a date serial; text and errors render as-is.
155 let serial = match value {
156 ResultData::Float(f) => f,
157 ResultData::Integer(i) => i as f64,
158 _ => return value.to_string(),
159 };
160 if serial < 0.0 {
161 return value.to_string();
162 }
163 crate::core::date::render_date_code(
164 crate::core::date::excel_serial_to_date(serial),
165 code,
166 crate::core::date::StringCase::Title,
167 )
168 }
169
170 /// Updates the src text of a particular cell but does
171 /// not automatically evaluate. Call [`Sheet::commit`] to evaluate
172 /// updated cells.
173 /// Directly sets the src of a cell and marks it dirty.
174 pub fn set_cell_src(&mut self, row: usize, col: usize, src: String) {
175 let table_clone = self.clone();
176 if let Some(column) = self.columns.get_mut(col)
177 && row < column.src.len()
178 {
179 column.src[row] = src.clone();
180 let compiled = crate::core::parser::compile_formula(&src, &[table_clone]);
181 column.compiled_src[row] = compiled;
182 column.mark_dirty(row);
183
184 self.uncommitted_actions
185 .push(crate::core::SheetAction::SetCellSrc {
186 sheet_name: self.name.clone(),
187 col,
188 row,
189 src,
190 });
191 }
192 }
193
194 /// Inserts text into a cell's source at a character offset, as typing
195 /// into it would, then recompiles and marks it dirty.
196 ///
197 /// This is a text edit within one cell, not a range insert; see
198 /// [`Sheet::insert_row`] and [`Sheet::insert_col`] for the structural
199 /// operations. Out-of-range positions are ignored.
200 pub fn insert(&mut self, pos: TextCellRef, input: &str) {
201 let TextCellRef {
202 row,
203 col,
204 char_offset,
205 } = pos;
206 let table_clone = self.clone();
207 let existing_col = self.columns.get_mut(col);
208 match existing_col {
209 Some(existing_column) => {
210 existing_column.insert(ColumnPosition { row, char_offset }, input);
211 let src = existing_column.src[row].clone();
212 let compiled = crate::core::parser::compile_formula(&src, &[table_clone]);
213 existing_column.compiled_src[row] = compiled;
214 existing_column.mark_dirty(row);
215 self.uncommitted_actions
216 .push(crate::core::SheetAction::SetCellSrc {
217 sheet_name: self.name.clone(),
218 col,
219 row,
220 src,
221 });
222 }
223 None => {
224 println!("Warning: column {} does not exist", col)
225 }
226 }
227 }
228
229 /// Delete one before (like backspace)
230 pub fn delete_one_before(&mut self, pos: TextCellRef) {
231 let char_offset = pos.char_offset;
232 let start = if char_offset > 0 {
233 TextCellRef {
234 row: pos.row,
235 col: pos.col,
236 char_offset: char_offset - 1,
237 }
238 } else {
239 pos.clone()
240 };
241 let end = pos;
242 self.delete(start, end);
243 }
244
245 /// Deletes the text between two positions, recompiling and dirtying every
246 /// cell it touches.
247 ///
248 /// Within a single cell this removes a character range; spanning cells it
249 /// truncates the first, clears those in between and trims the last.
250 /// Ignored if `end` precedes `start`.
251 pub fn delete(&mut self, start: TextCellRef, end: TextCellRef) {
252 // Validate positions are in correct order
253 if start.col > end.col || (start.col == end.col && start.row > end.row) {
254 return;
255 }
256 let table_clone = self.clone();
257 // Handle deletion within a single column
258 if start.col == end.col {
259 let start_index = start.row;
260 let end_index = end.row;
261
262 if let Some(column) = self.columns.get_mut(start.col) {
263 // Handle single row deletion
264 if start.row == end.row && start_index < column.src.len() {
265 let src = &mut column.src[start_index];
266 let end_offset = std::cmp::min(end.char_offset, src.len());
267 if start.char_offset < end_offset {
268 src.replace_range(start.char_offset..end_offset, "");
269 column.dirty_indices.push(start_index);
270 let updated_src = src.clone();
271 let compiled =
272 crate::core::parser::compile_formula(&updated_src, &[table_clone]);
273 column.compiled_src[start_index] = compiled;
274 }
275 }
276 // Handle multi-row deletion
277 else if start_index < column.len() {
278 // Delete complete rows between start and end
279 if end_index >= start_index {
280 column.drain_rows(start_index..=end_index);
281 }
282 }
283 }
284 } else {
285 // Handle multi-column deletion
286 for col in start.col..=end.col {
287 if let Some(column) = self.columns.get_mut(col) {
288 let start_index = if col == start.col { col } else { 0 };
289
290 let end_index = if col == end.col {
291 col
292 } else {
293 column.src.len() - 1
294 };
295
296 if start_index < column.len() {
297 // Delete rows in this column
298 if end_index >= start_index {
299 column.drain_rows(start_index..=end_index);
300 }
301 }
302 }
303 }
304 }
305 }
306
307 /// Grows the sheet by one empty row or column on the given side.
308 ///
309 /// [`Direction::None`] does nothing. Rows are unbounded, but sideways
310 /// growth stops once the sheet has 26 columns.
311 pub fn extend(&mut self, direction: Direction) {
312 if self.columns.is_empty() {
313 return;
314 }
315 let row_count = self.columns[0].src.len();
316 const MAX_COLS: usize = 26;
317 match direction {
318 Direction::Up => {
319 for column in &mut self.columns {
320 column.insert_row(0);
321 }
322 self.uncommitted_actions
323 .push(crate::core::SheetAction::InsertRow {
324 sheet_name: self.name.clone(),
325 index: 0,
326 });
327 }
328 Direction::Down => {
329 for column in &mut self.columns {
330 column.push_row();
331 }
332 self.uncommitted_actions
333 .push(crate::core::SheetAction::InsertRow {
334 sheet_name: self.name.clone(),
335 index: row_count,
336 });
337 }
338 Direction::Left => {
339 if self.columns.len() < MAX_COLS {
340 self.columns.insert(0, DataColumn::new(row_count));
341 self.uncommitted_actions
342 .push(crate::core::SheetAction::InsertCol {
343 sheet_name: self.name.clone(),
344 index: 0,
345 });
346 }
347 }
348 Direction::Right => {
349 if self.columns.len() < MAX_COLS {
350 self.columns.push(DataColumn::new(row_count));
351 self.uncommitted_actions
352 .push(crate::core::SheetAction::InsertCol {
353 sheet_name: self.name.clone(),
354 index: self.columns.len() - 1,
355 });
356 }
357 }
358 Direction::None => {}
359 }
360 }
361
362 /// Ensure sheet has at least target_row+1 rows and target_col+1 columns
363 pub fn ensure_capacity(&mut self, target_row: usize, target_col: usize) {
364 let current_rows = self.row_count();
365 let needed_rows = target_row + 1;
366 let final_rows = current_rows.max(needed_rows);
367
368 while self.columns.len() <= target_col {
369 let col_idx = self.columns.len();
370 let mut col = DataColumn::new(final_rows);
371 col.name = crate::core::parser::col_idx_to_letters(col_idx);
372 self.columns.push(col);
373 }
374
375 if final_rows > current_rows {
376 for col in &mut self.columns {
377 col.resize_rows(final_rows);
378 }
379 }
380 }
381
382 /// The style set on a cell, or `None` if it has none.
383 ///
384 /// This is where a date cell's `num_format` lives -- the notation half of
385 /// a date, the value half being the serial in the cell.
386 pub fn get_cell_style(&self, row: usize, col: usize) -> Option<&crate::core::CellStyle> {
387 self.columns
388 .get(col)
389 .and_then(|column| column.styles.get(row))
390 .and_then(|opt| opt.as_ref())
391 }
392
393 /// Replaces a cell's style, growing the sheet if the cell is past its
394 /// current bounds. An empty style is stored as no style at all.
395 pub fn set_cell_style(&mut self, row: usize, col: usize, style: crate::core::CellStyle) {
396 self.ensure_capacity(row, col);
397 if let Some(column) = self.columns.get_mut(col)
398 && row < column.styles.len()
399 {
400 if style.is_empty() {
401 column.styles[row] = None;
402 } else {
403 column.styles[row] = Some(style);
404 }
405 }
406 }
407
408 /// Mutates a cell's style in place, starting from the default if it has
409 /// none, so one attribute can be changed without disturbing the others.
410 ///
411 /// Grows the sheet if needed; a style left empty is dropped.
412 pub fn update_cell_style<F>(&mut self, row: usize, col: usize, f: F)
413 where
414 F: FnOnce(&mut crate::core::CellStyle),
415 {
416 self.ensure_capacity(row, col);
417 if let Some(column) = self.columns.get_mut(col)
418 && row < column.styles.len()
419 {
420 let mut current = column.styles[row].clone().unwrap_or_default();
421 f(&mut current);
422 if current.is_empty() {
423 column.styles[row] = None;
424 } else {
425 column.styles[row] = Some(current);
426 }
427 }
428 }
429
430 /// Removes a cell's style. Unlike the setters, this never grows the sheet.
431 pub fn clear_cell_style(&mut self, row: usize, col: usize) {
432 if let Some(column) = self.columns.get_mut(col)
433 && row < column.styles.len()
434 {
435 column.styles[row] = None;
436 }
437 }
438
439 /// Insert a new empty row at the specified index
440 /// If index is >= row_count, appends at the end
441 pub fn insert_row(&mut self, index: usize) {
442 let row_count = self.row_count();
443 if index >= row_count {
444 // Append at the end
445 for column in &mut self.columns {
446 column.push_row();
447 }
448 self.uncommitted_actions
449 .push(crate::core::SheetAction::InsertRow {
450 sheet_name: self.name.clone(),
451 index: row_count,
452 });
453 } else {
454 // Insert at the specified index
455 for column in &mut self.columns {
456 column.insert_row(index);
457 }
458 self.uncommitted_actions
459 .push(crate::core::SheetAction::InsertRow {
460 sheet_name: self.name.clone(),
461 index,
462 });
463 }
464 }
465
466 /// Deletes a row, shifting the rows below it up.
467 ///
468 /// Removes the entry from all three parallel per-row vectors together,
469 /// which is what keeps them the same length, and rebases the dirty queue.
470 /// Out-of-range indices are ignored. Everything is marked dirty, since
471 /// formulas above the deleted row may refer to it.
472 pub fn delete_row(&mut self, index: usize) {
473 let row_count = self.row_count();
474 if index < row_count {
475 for column in &mut self.columns {
476 column.remove_row(index);
477 }
478 self.uncommitted_actions
479 .push(crate::core::SheetAction::DeleteRow {
480 sheet_name: self.name.clone(),
481 index,
482 });
483 self.mark_all_dirty();
484 }
485 }
486
487 /// Excel's *Insert cells, shift down* over an inclusive column band.
488 ///
489 /// Unlike [`Sheet::insert_row`] this moves only `first_col..=last_col`,
490 /// leaving every other column where it is -- which is what
491 /// `ListRows.Add` actually does. Measured: adding a row to a table at
492 /// `A1:C4` moves `A8` down to `A9` but leaves `E2` alone.
493 ///
494 /// Every column keeps the same length: the sheet first grows by `count`
495 /// rows, so the rows pushed off the bottom of the band are the blank ones
496 /// just added rather than data. Everything moves through `DataColumn`'s
497 /// paired operations, so `src` / `data` / `compiled_src` / `styles` stay
498 /// aligned.
499 ///
500 /// Out-of-range bands and a zero `count` are no-ops. Formula references
501 /// are *not* rewritten here -- that is
502 /// `WorkbookManager::insert_cells_shift_down`'s job, since it spans
503 /// sheets.
504 pub fn insert_cells_shift_down(
505 &mut self,
506 row: usize,
507 first_col: usize,
508 last_col: usize,
509 count: usize,
510 ) {
511 let last_col = last_col.min(self.columns.len().saturating_sub(1));
512 if count == 0 || self.columns.is_empty() || first_col > last_col {
513 return;
514 }
515 // Grow every column together first, so the band has somewhere to
516 // push into and the sheet stays rectangular throughout.
517 for column in &mut self.columns {
518 for _ in 0..count {
519 column.push_row();
520 }
521 }
522 for column in &mut self.columns[first_col..=last_col] {
523 for _ in 0..count {
524 column.insert_row(row);
525 // Drop the blank row the growth added, so this column ends
526 // the same length as the untouched ones.
527 column.remove_row(column.len() - 1);
528 }
529 }
530 self.uncommitted_actions
531 .push(crate::core::SheetAction::InsertRow {
532 sheet_name: self.name.clone(),
533 index: row,
534 });
535 self.mark_all_dirty();
536 }
537
538 /// Excel's *Delete cells, shift up* over an inclusive column band; the
539 /// inverse of [`Sheet::insert_cells_shift_down`].
540 ///
541 /// The band's rows below `row` move up and blank rows appear at its
542 /// bottom, so the sheet keeps its shape and other columns are untouched.
543 pub fn delete_cells_shift_up(
544 &mut self,
545 row: usize,
546 first_col: usize,
547 last_col: usize,
548 count: usize,
549 ) {
550 let last_col = last_col.min(self.columns.len().saturating_sub(1));
551 if count == 0 || self.columns.is_empty() || first_col > last_col || row >= self.row_count()
552 {
553 return;
554 }
555 for column in &mut self.columns[first_col..=last_col] {
556 for _ in 0..count {
557 if row < column.len() {
558 column.remove_row(row);
559 // Keep the length: the band gains a blank row at the
560 // bottom for each one removed from the middle.
561 column.push_row();
562 }
563 }
564 }
565 self.uncommitted_actions
566 .push(crate::core::SheetAction::DeleteRow {
567 sheet_name: self.name.clone(),
568 index: row,
569 });
570 self.mark_all_dirty();
571 }
572
573 /// Deletes a column, shifting the columns to its right left.
574 ///
575 /// Out-of-range indices are ignored; everything is marked dirty.
576 pub fn delete_col(&mut self, index: usize) {
577 if index < self.columns.len() {
578 self.columns.remove(index);
579 self.uncommitted_actions
580 .push(crate::core::SheetAction::DeleteCol {
581 sheet_name: self.name.clone(),
582 index,
583 });
584 self.mark_all_dirty();
585 }
586 }
587
588 /// Insert a new empty column at the specified index
589 /// If index is >= columns.len(), appends at the end
590 pub fn insert_col(&mut self, index: usize) {
591 let row_count = self.row_count();
592 let new_col = DataColumn::new(row_count);
593 let col_count = self.columns.len();
594 if index >= col_count {
595 self.columns.push(new_col);
596 self.uncommitted_actions
597 .push(crate::core::SheetAction::InsertCol {
598 sheet_name: self.name.clone(),
599 index: col_count,
600 });
601 } else {
602 self.columns.insert(index, new_col);
603 self.uncommitted_actions
604 .push(crate::core::SheetAction::InsertCol {
605 sheet_name: self.name.clone(),
606 index,
607 });
608 }
609 self.mark_all_dirty();
610 }
611
612 /// The sheet's columns.
613 ///
614 /// Read-only: every column must keep the same number of rows, so growing
615 /// or replacing one from outside would desync the sheet. Use
616 /// [`Sheet::insert_col`], [`Sheet::delete_col`] and [`Sheet::extend`] to
617 /// change the shape.
618 pub fn columns(&self) -> &[DataColumn] {
619 &self.columns
620 }
621
622 /// Allocated rows, taken from the first column -- every column has the
623 /// same length.
624 pub fn row_count(&self) -> usize {
625 self.columns.first().map(|c| c.src.len()).unwrap_or(0)
626 }
627
628 /// Allocated columns.
629 pub fn col_count(&self) -> usize {
630 self.columns.len()
631 }
632}