//! `SpreadsheetGrid` widget: the scrollable, editable cell grid. //! //! This module contains: //! * The widget struct (all the `#[live]` colour fields, the //! `#[rust]` state fields for selection/edit/scroll/touch). //! * `Widget::handle_event` — touch/keyboard input dispatch. //! * `Widget::draw_walk` — multi-quad rendering with frozen panes. //! * All grid methods: hit-testing, scrolling, copy/paste, autofill, //! styling operations, undo/redo. //! //! The grid delegates its data model to `SpreadsheetData` (in //! `data.rs`); this module is purely view + controller. //! //! ## Architecture notes //! //! The original 5000-line `mod.rs` had the grid logic inlined. It //! has been extracted verbatim apart from the module split and the //! small targeted fixes noted inline (cached column widths, no //! `format!` in hot draw loop, `VecDeque`-style undo cap). See the //! review document for the full list of recommended larger //! refactors (delta-based undo, packed `CellId`, `Rc`-shared sheet //! data, etc.). use makepad_widgets::makepad_platform::event::{TouchState, TouchUpdateEvent}; use makepad_widgets::*; use std::collections::{HashMap, HashSet}; use crate::model::SharedWorkspaceModel; use crate::text_measure::TextMeasureCache; use spreadsheet_engine::autofill::{detect_series, get_series_value}; use spreadsheet_engine::data::*; use spreadsheet_engine::style::*; use spreadsheet_engine::util::{adjust_formula_refs, write_col_letters}; use spreadsheet_engine::WorkbookCommand; /// Convert engine `Color` to makepad `Vec4f`. /// Done here (not in the engine) to keep the engine makepad-free. fn color_to_vec4(c: Color) -> Vec4f { Vec4f { x: c.r, y: c.g, z: c.b, w: c.a, } } /// Convert makepad `Vec4f` to engine `Color`. fn vec4_to_color(v: Vec4f) -> Color { Color { r: v.x, g: v.y, b: v.z, a: v.w, } } /// Map a hardware `KeyCode` plus optional Shift modifier to a single /// character suitable for insertion into a cell edit buffer. /// /// This function was moved from the engine to the UI crate because it /// depends on makepad's `KeyCode` type. (F7 crate split.) pub fn key_to_char(key_code: KeyCode, is_shift: bool) -> Option { use KeyCode::*; let c = match key_code { KeyA => 'a', KeyB => 'b', KeyC => 'c', KeyD => 'd', KeyE => 'e', KeyF => 'f', KeyG => 'g', KeyH => 'h', KeyI => 'i', KeyJ => 'j', KeyK => 'k', KeyL => 'l', KeyM => 'm', KeyN => 'n', KeyO => 'o', KeyP => 'p', KeyQ => 'q', KeyR => 'r', KeyS => 's', KeyT => 't', KeyU => 'u', KeyV => 'v', KeyW => 'w', KeyX => 'x', KeyY => 'y', KeyZ => 'z', Key0 => '0', Key1 => '1', Key2 => '2', Key3 => '3', Key4 => '4', Key5 => '5', Key6 => '6', Key7 => '7', Key8 => '8', Key9 => '9', Space => ' ', Minus => '-', Equals => '=', LBracket => '[', RBracket => ']', Backslash => '\\', Semicolon => ';', Quote => '\'', Comma => ',', Period => '.', Slash => '/', _ => return None, }; if is_shift { let upper = match c { 'a'..='z' => c.to_ascii_uppercase(), '1' => '!', '2' => '@', '3' => '#', '4' => '$', '5' => '%', '6' => '^', '7' => '&', '8' => '*', '9' => '(', '0' => ')', '-' => '_', '=' => '+', '[' => '{', ']' => '}', '\\' => '|', ';' => ':', '\'' => '"', ',' => '<', '.' => '>', '/' => '?', _ => c, }; Some(upper) } else { Some(c) } } /// Touch-drag distance (in px) below which a finger-down/up is /// treated as a tap rather than a pan. const DRAG_THRESHOLD: f64 = 8.0; /// Cursor blink period (in seconds) for in-cell editing. const CURSOR_BLINK_PERIOD: f64 = 1.067; /// Action emitted by the grid to its parent workspace. The parent /// uses these to update the formula bar, status label, etc. #[derive(Clone, Debug)] pub enum GridIntent { Apply(WorkbookCommand), } #[derive(Clone, Debug, Default, PartialEq)] pub enum CellAction { Selected(u32, u32), RangeSelected(u32, u32, u32, u32), EditStarted(u32, u32), EditCommitted(u32, u32, String), EditCancelled(u32, u32), ColumnResized(u32, f32), RowResized(u32, f32), #[default] None, } impl ActionDefaultRef for CellAction { fn default_ref() -> &'static Self { static DEFAULT: CellAction = CellAction::None; &DEFAULT } } /// In-progress drag/resize/autofill state machine. Replaces what /// would otherwise be 4-5 independent `Option<...>` fields. /// /// See review notes for a fuller `GridMode` enum that would also /// fold in `EditState` and `Idle`. #[derive(Clone, Debug, PartialEq)] enum DragState { None, Panning { last_abs: DVec2, }, AutoFilling { source_min: (u32, u32), source_max: (u32, u32), }, ColResizing { col: u32, start_x: f64, start_width: f32, }, RowResizing { row: u32, start_y: f64, start_height: f32, }, } /// In-progress in-cell edit. `cursor` is a char index (not a byte /// index) so multibyte characters behave correctly. #[derive(Clone, Debug)] pub struct EditState { pub row: u32, pub col: u32, pub text: String, pub cursor: usize, pub cursor_blink_time: f64, } /// Internal clipboard entry. Stores copied cells relative to the /// copy origin so paste can offset formula references correctly. #[derive(Clone)] struct ClipboardEntry { origin: (u32, u32), // top-left of copied range in source sheet cells: Vec<(u32, u32, CellData)>, // (rel_r, rel_c, cell) } #[derive(Script, ScriptHook, Widget)] pub struct SpreadsheetGrid { #[uid] uid: WidgetUid, #[source] source: ScriptObjectRef, #[walk] walk: Walk, #[layout] layout: Layout, #[redraw] #[live] draw_bg: DrawColor, #[live] draw_header_bg: DrawColor, #[live] draw_cell_bg: DrawColor, #[live] draw_grid_line: DrawColor, #[live] draw_text: DrawText, #[live] draw_text_bold: DrawText, #[live] draw_header_text: DrawText, #[live] draw_edit_cursor: DrawColor, #[live] draw_handle: DrawColor, #[rust] rect: Rect, #[rust] visible_rows: f64, #[live(26.0)] pub row_height: f32, #[live(100.0)] pub col_width: f32, #[live(50.0)] pub row_header_width: f32, #[live(28.0)] pub col_header_height: f32, #[live] pub header_bg_color: Vec4f, #[live] pub header_text_color: Vec4f, #[live] pub cell_bg_color: Vec4f, #[live] pub cell_alt_bg_color: Vec4f, #[live] pub selected_bg_color: Vec4f, #[live] pub selected_border_color: Vec4f, #[live] pub grid_line_color: Vec4f, #[live] pub header_border_color: Vec4f, #[live] pub text_color: Vec4f, #[live] pub formula_text_color: Vec4f, #[live] pub edit_bg_color: Vec4f, #[live] pub edit_border_color: Vec4f, #[live] pub bold_text_color: Vec4f, #[live] pub frozen_bg_color: Vec4f, #[live] pub resize_handle_color: Vec4f, #[live] pub autofill_handle_color: Vec4f, #[live(8.0)] pub col_resize_hit: f32, #[rust] /// Shared workbook handle used by all Grid reads and mutations. #[rust] pub shared_model: SharedWorkspaceModel, #[rust] pending_intents: Vec, #[rust] text_measure_cache: TextMeasureCache, #[rust] dirty_cells: HashSet, #[rust] full_invalidation: bool, #[rust] pub initialized: bool, #[rust] pub selection_anchor: Option<(u32, u32)>, #[rust] pub selection_head: Option<(u32, u32)>, #[rust] pub edit_state: Option, #[rust] pub scroll_row_offset: f64, #[rust] pub scroll_col_offset: f64, #[rust(DragState::None)] drag_state: DragState, #[rust] last_touch_pos: Option, #[rust] active_touches: Vec<(u64, DVec2)>, #[rust] pinch_last_dist: Option, #[rust] pinch_last_mid: Option, #[live] handle_color: Vec4f, #[live(24.0)] handle_hit_size: f32, #[live(6.0)] handle_draw_radius: f32, #[rust] clipboard: Option, #[rust] last_selection: Option<(u32, u32)>, /// Reusable offset caches — avoid heap allocation per frame. /// Grown to peak capacity and reused across draw calls. #[rust] col_x_cache: Vec, #[rust] row_y_cache: Vec, #[rust] pub frozen_rows: u32, #[rust] pub frozen_cols: u32, } impl Widget for SpreadsheetGrid { fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) { if let Event::TouchUpdate(tu) = event { self.handle_touch_interaction(cx, tu); if self.active_touches.len() == 2 { return; } } if self.edit_state.is_some() { if let Event::KeyDown(ke) = event { self.handle_edit_key(cx, ke); return; } } else if let Event::KeyDown(ke) = event { self.handle_nav_key(cx, ke); return; } if let Some(ref mut edit) = self.edit_state { if let Event::NextFrame(nf) = event { edit.cursor_blink_time = nf.time as f64; self.redraw(cx); } } match event.hits_with_capture_overload(cx, self.draw_bg.area(), true) { Hit::FingerDown(fe) if fe.is_primary_hit() => { self.last_touch_pos = Some(fe.abs); cx.set_key_focus(self.draw_bg.area()); if let Some(col) = self.col_resize_hit_test(fe.abs) { self.with_data_mut(|data| data.snapshot()); let w = self.col_width_or_default(col); self.drag_state = DragState::ColResizing { col, start_x: fe.abs.x, start_width: w, }; return; } if let Some(row) = self.row_resize_hit_test(fe.abs) { self.with_data_mut(|data| data.snapshot()); let h = self.row_height_or_default(row); self.drag_state = DragState::RowResizing { row, start_y: fe.abs.y, start_height: h, }; return; } // Start autofill if user hits handle on selection border if let Some((min_r, min_c, max_r, max_c)) = self.selection_bounds() { if self.handle_rect(max_r, max_c).contains(fe.abs) { self.drag_state = DragState::AutoFilling { source_min: (min_r, min_c), source_max: (max_r, max_c), }; return; } } if let Some((row, col)) = self.coords_in_grid(fe.abs) { let is_current_edit = self .edit_state .as_ref() .map(|e| e.row == row && e.col == col) .unwrap_or(false); if is_current_edit { let cell_rect = self.cell_abs_rect(row, col); let rel_x = (fe.abs.x - (cell_rect.pos.x + 6.0)).max(0.0); if let Some(ref mut edit) = self.edit_state { edit.cursor = ((rel_x / 7.0).round() as usize).min(edit.text.chars().count()); } cx.show_text_ime(self.draw_bg.area(), fe.abs); self.redraw(cx); return; } else if self.edit_state.is_some() { self.commit_edit(cx); } if fe.tap_count >= 2 { self.begin_edit(cx, row, col); return; } if fe.modifiers.shift { self.selection_head = Some((row, col)); } else { self.selection_anchor = Some((row, col)); self.selection_head = Some((row, col)); } cx.widget_action(self.widget_uid(), CellAction::Selected(row, col)); self.redraw(cx); self.drag_state = DragState::Panning { last_abs: fe.abs }; } else { if self.edit_state.is_some() { self.commit_edit(cx); } self.drag_state = DragState::Panning { last_abs: fe.abs }; } } Hit::TextInput(TextInputEvent { ref input, .. }) => { if let Some(ref mut edit) = self.edit_state { let char_count = edit.text.chars().count(); if edit.cursor <= char_count { let byte_idx = edit .text .char_indices() .nth(edit.cursor) .map(|(b, _)| b) .unwrap_or(edit.text.len()); edit.text.insert_str(byte_idx, input); edit.cursor += input.chars().count(); edit.cursor_blink_time = 0.0; self.redraw(cx); } } } Hit::FingerHoverIn(fe) | Hit::FingerHoverOver(fe) => { if self.col_resize_hit_test(fe.abs).is_some() { cx.set_cursor(MouseCursor::ColResize); } else if self.row_resize_hit_test(fe.abs).is_some() { cx.set_cursor(MouseCursor::RowResize); } else if let Some((_, _, max_r, max_c)) = self.selection_bounds() { if self.handle_rect(max_r, max_c).contains(fe.abs) { cx.set_cursor(MouseCursor::Crosshair); } else if self.coords_in_grid(fe.abs).is_some() { if self.edit_state.is_some() { cx.set_cursor(MouseCursor::Text); } else { cx.set_cursor(MouseCursor::Default); } } else { cx.set_cursor(MouseCursor::Default); } } else if self.coords_in_grid(fe.abs).is_some() { if self.edit_state.is_some() { cx.set_cursor(MouseCursor::Text); } else { cx.set_cursor(MouseCursor::Default); } } else { cx.set_cursor(MouseCursor::Default); } } Hit::FingerMove(fe) => match self.drag_state { DragState::AutoFilling { source_min, source_max, } => { cx.set_cursor(MouseCursor::Crosshair); if let Some((row, col)) = self.coords_in_grid(fe.abs) { let target_min = (source_min.0.min(row), source_min.1.min(col)); let target_max = (source_max.0.max(row), source_max.1.max(col)); self.selection_anchor = Some(target_min); self.selection_head = Some(target_max); self.redraw(cx); } } DragState::Panning { last_abs } => { cx.set_cursor(MouseCursor::Grabbing); let delta = last_abs - fe.abs; self.scroll_by(cx, delta); self.drag_state = DragState::Panning { last_abs: fe.abs }; } DragState::ColResizing { col, start_x, start_width, } => { cx.set_cursor(MouseCursor::ColResize); let delta = (fe.abs.x - start_x) as f32; let new_w = (start_width + delta).max(30.0); self.apply_command(WorkbookCommand::SetColumnWidth { col, width: new_w }); self.redraw(cx); } DragState::RowResizing { row, start_y, start_height, } => { cx.set_cursor(MouseCursor::RowResize); let delta = (fe.abs.y - start_y) as f32; let new_h = (start_height + delta).max(16.0); self.apply_command(WorkbookCommand::SetRowHeight { row, height: new_h }); self.redraw(cx); } DragState::None => { if let Some(start) = self.last_touch_pos { if (fe.abs - start).length() > DRAG_THRESHOLD { cx.set_cursor(MouseCursor::Grab); self.drag_state = DragState::Panning { last_abs: fe.abs }; } else if self.col_resize_hit_test(fe.abs).is_some() { cx.set_cursor(MouseCursor::ColResize); } else if self.row_resize_hit_test(fe.abs).is_some() { cx.set_cursor(MouseCursor::RowResize); } else { cx.set_cursor(MouseCursor::Default); } } } }, Hit::FingerUp(_) => { if self.edit_state.is_some() { cx.set_cursor(MouseCursor::Text); } else { cx.set_cursor(MouseCursor::Default); } match self.drag_state { DragState::AutoFilling { source_min, source_max, } => { if let Some(sel) = self.selection_bounds() { self.do_autofill( source_min, source_max, (sel.0, sel.1), (sel.2, sel.3), ); self.redraw(cx); } } DragState::ColResizing { col, .. } => { let w = self.col_width_or_default(col); cx.widget_action(self.widget_uid(), CellAction::ColumnResized(col, w)); } DragState::RowResizing { row, .. } => { let h = self.row_height_or_default(row); cx.widget_action(self.widget_uid(), CellAction::RowResized(row, h)); } _ => {} } self.drag_state = DragState::None; self.last_touch_pos = None; } Hit::FingerScroll(fs) => { let scroll = if fs.scroll.y.abs() > f64::EPSILON { fs.scroll.y } else { fs.scroll.x }; if fs.modifiers.shift { let visible_cols = ((self.rect.size.x - self.row_header_width as f64) / self.col_width as f64) .max(1.0); let max_offset = (NUM_COLS as f64 - visible_cols).max(0.0); self.scroll_col_offset = (self.scroll_col_offset + scroll / self.col_width as f64) .clamp(0.0, max_offset); } else { let max_offset = (NUM_ROWS as f64 - self.visible_rows).max(0.0); self.scroll_row_offset = (self.scroll_row_offset + scroll / self.row_height as f64) .clamp(0.0, max_offset); } self.redraw(cx); } _ => {} } if let Hit::FingerDown(fe) = event.hits_with_capture_overload(cx, self.draw_bg.area(), true) { if fe.tap_count == 2 { if let Some((row, col)) = self.coords_in_grid(fe.abs) { self.begin_edit(cx, row, col); } } } } fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep { if !self.initialized { self.frozen_rows = 1; self.frozen_cols = 0; self.selection_anchor = Some((0, 0)); self.selection_head = Some((0, 0)); self.initialized = true; } self.rect = cx.walk_turtle(walk); self.draw_bg.draw_abs(cx, self.rect); let header_h = self.col_header_height as f64; let row_hdr_w = self.row_header_width as f64; let frozen_cols_width: f64 = (0..self.frozen_cols) .map(|c| self.col_width_or_default(c) as f64) .sum(); let frozen_rows_height: f64 = (0..self.frozen_rows) .map(|r| self.row_height_or_default(r) as f64) .sum(); let scroll_y_start = self.rect.pos.y + header_h + frozen_rows_height; let scroll_x_start = self.rect.pos.x + row_hdr_w + frozen_cols_width; let scroll_cells_width = (self.rect.size.x - row_hdr_w - frozen_cols_width).max(1.0); let scroll_cells_height = (self.rect.size.y - header_h - frozen_rows_height).max(1.0); self.visible_rows = (scroll_cells_height / self.row_height as f64).max(1.0); let first_row = self.scroll_row_offset.floor() as u32; let sub_row_px = self.scroll_row_offset.fract() * self.row_height as f64; let first_col = self.scroll_col_offset.floor() as u32; let sub_col_px = self.scroll_col_offset.fract() * self.col_width as f64; let rows_to_draw = (scroll_cells_height / self.row_height as f64).ceil() as u32 + 3; let last_row = (first_row + rows_to_draw).min(NUM_ROWS); let cols_to_draw = (scroll_cells_width / self.col_width as f64).ceil() as u32 + 3; let last_col = (first_col + cols_to_draw).min(NUM_COLS); // 1. Main scrollable area let scroll_cells_rect = Rect { pos: DVec2 { x: scroll_x_start, y: scroll_y_start, }, size: DVec2 { x: scroll_cells_width, y: scroll_cells_height, }, }; cx.begin_turtle( Walk { abs_pos: Some(scroll_cells_rect.pos), width: Size::Fixed(scroll_cells_rect.size.x), height: Size::Fixed(scroll_cells_rect.size.y), margin: Inset::default(), metrics: Metrics::default(), }, Layout { clip_x: true, clip_y: true, ..Layout::default() }, ); self.draw_cells_in_rect( cx, scroll_cells_rect, first_row, last_row, first_col, last_col, first_row, first_col, sub_row_px, sub_col_px, ); cx.end_turtle(); // 2. Left frozen columns if self.frozen_cols > 0 { let rect = Rect { pos: DVec2 { x: self.rect.pos.x + row_hdr_w, y: scroll_y_start, }, size: DVec2 { x: frozen_cols_width, y: scroll_cells_height, }, }; cx.begin_turtle( Walk { abs_pos: Some(rect.pos), width: Size::Fixed(rect.size.x), height: Size::Fixed(rect.size.y), margin: Inset::default(), metrics: Metrics::default(), }, Layout { clip_x: true, clip_y: true, ..Layout::default() }, ); self.draw_cells_in_rect( cx, rect, first_row, last_row, 0, self.frozen_cols, first_row, 0, sub_row_px, 0.0, ); cx.end_turtle(); } // 3. Top frozen rows if self.frozen_rows > 0 { let rect = Rect { pos: DVec2 { x: scroll_x_start, y: self.rect.pos.y + header_h, }, size: DVec2 { x: scroll_cells_width, y: frozen_rows_height, }, }; cx.begin_turtle( Walk { abs_pos: Some(rect.pos), width: Size::Fixed(rect.size.x), height: Size::Fixed(rect.size.y), margin: Inset::default(), metrics: Metrics::default(), }, Layout { clip_x: true, clip_y: true, ..Layout::default() }, ); self.draw_cells_in_rect( cx, rect, 0, self.frozen_rows, first_col, last_col, 0, first_col, 0.0, sub_col_px, ); cx.end_turtle(); } // 4. Top-left frozen corner if self.frozen_rows > 0 && self.frozen_cols > 0 { let rect = Rect { pos: DVec2 { x: self.rect.pos.x + row_hdr_w, y: self.rect.pos.y + header_h, }, size: DVec2 { x: frozen_cols_width, y: frozen_rows_height, }, }; cx.begin_turtle( Walk { abs_pos: Some(rect.pos), width: Size::Fixed(rect.size.x), height: Size::Fixed(rect.size.y), margin: Inset::default(), metrics: Metrics::default(), }, Layout { clip_x: true, clip_y: true, ..Layout::default() }, ); self.draw_cells_in_rect( cx, rect, 0, self.frozen_rows, 0, self.frozen_cols, 0, 0, 0.0, 0.0, ); cx.end_turtle(); } // Draw Headers self.draw_headers( cx, header_h, row_hdr_w, first_row, last_row, first_col, last_col, scroll_x_start, scroll_y_start, sub_row_px, sub_col_px, ); // Selection box + autofill handle self.draw_selection_overlay(cx); // Edit box + cursor self.draw_edit_overlay(cx); if self.edit_state.is_some() { cx.new_next_frame(); } DrawStep::done() } } impl SpreadsheetGrid { pub fn attach_workspace_model(&mut self, model: SharedWorkspaceModel) { self.shared_model = model; } fn apply_command(&mut self, command: WorkbookCommand) { self.mark_command_dirty(&command); self.pending_intents.push(GridIntent::Apply(command)); } fn mark_command_dirty(&mut self, command: &WorkbookCommand) { let cell = match command { WorkbookCommand::SetCell { row, col, .. } | WorkbookCommand::RemoveCell { row, col } | WorkbookCommand::PutCell { row, col, .. } | WorkbookCommand::ToggleBold { row, col } | WorkbookCommand::ToggleItalic { row, col } | WorkbookCommand::ToggleUnderline { row, col } | WorkbookCommand::SetNumberFormat { row, col, .. } | WorkbookCommand::SetAlignment { row, col, .. } | WorkbookCommand::SetBackground { row, col, .. } | WorkbookCommand::SetBorders { row, col, .. } => Some(CellId::new(*row, *col)), WorkbookCommand::SetColumnWidth { .. } | WorkbookCommand::SetRowHeight { .. } => None, }; if let Some(cell) = cell { self.dirty_cells.insert(cell); } else { self.dirty_cells.clear(); self.full_invalidation = true; } } pub fn invalidate_all(&mut self) { self.dirty_cells.clear(); self.full_invalidation = true; } pub fn take_dirty_regions(&mut self) -> (HashSet, bool) { ( std::mem::take(&mut self.dirty_cells), std::mem::replace(&mut self.full_invalidation, false), ) } pub fn drain_intents(&mut self) -> Vec { std::mem::take(&mut self.pending_intents) } /// Helper: look up a column's overridden width, falling back to /// the default `col_width`. Inline this everywhere we used to /// write the long `data.col_widths.get(&c).copied().unwrap_or(self.col_width)` /// chain — same codegen, far less repetition. fn col_width_or_default(&self, col: u32) -> f32 { self.with_data(|data| data.col_widths.get(&col).copied()) .unwrap_or(self.col_width) } /// Helper: look up a row's overridden height. See /// `col_width_or_default`. fn row_height_or_default(&self, row: u32) -> f32 { self.with_data(|data| data.row_heights.get(&row).copied()) .unwrap_or(self.row_height) } #[allow(clippy::too_many_arguments)] fn draw_headers( &mut self, cx: &mut Cx2d, header_h: f64, row_hdr_w: f64, first_row: u32, last_row: u32, first_col: u32, last_col: u32, scroll_x_start: f64, scroll_y_start: f64, sub_row_px: f64, sub_col_px: f64, ) { let header_rect = Rect { pos: self.rect.pos, size: DVec2 { x: self.rect.size.x, y: header_h, }, }; self.draw_header_bg.color = self.header_bg_color; self.draw_header_bg.draw_abs(cx, header_rect); // Col headers self.draw_header_text.color = self.header_text_color; // Reusable buffer to avoid `format!` per header per frame. let mut label_buf = String::with_capacity(8); // Precompute cumulative column x-offsets for the frozen and // scrollable ranges. Reuse the cached Vecs to avoid heap // allocation per frame. self.col_x_cache.clear(); self.col_x_cache.push(0.0); for c in 0..self.frozen_cols { let prev = *self.col_x_cache.last().unwrap(); self.col_x_cache .push(prev + self.col_width_or_default(c) as f64); } for col in 0..self.frozen_cols { let cw = self.col_width_or_default(col) as f64; let x = self.rect.pos.x + row_hdr_w + self.col_x_cache[col as usize]; label_buf.clear(); write_col_letters(col, &mut label_buf); self.draw_header_text.draw_abs( cx, dvec2( x + cw * 0.5 - label_buf.len() as f64 * 4.0, self.rect.pos.y + (header_h - 14.0) * 0.5, ), &label_buf, ); } // Rebuild cache for scrollable column range. let scroll_col_count = last_col.saturating_sub(first_col) as usize; self.col_x_cache.clear(); self.col_x_cache.push(0.0); for i in 0..scroll_col_count { let c = first_col + i as u32; let prev = *self.col_x_cache.last().unwrap(); self.col_x_cache .push(prev + self.col_width_or_default(c) as f64); } for col in first_col..last_col { let cw = self.col_width_or_default(col) as f64; let x = scroll_x_start + self.col_x_cache[(col - first_col) as usize] - sub_col_px; label_buf.clear(); write_col_letters(col, &mut label_buf); self.draw_header_text.draw_abs( cx, dvec2( x + cw * 0.5 - label_buf.len() as f64 * 4.0, self.rect.pos.y + (header_h - 14.0) * 0.5, ), &label_buf, ); } // Row headers — same cumulative-offset optimization. self.row_y_cache.clear(); self.row_y_cache.push(0.0); for r in 0..self.frozen_rows { let prev = *self.row_y_cache.last().unwrap(); self.row_y_cache .push(prev + self.row_height_or_default(r) as f64); } for row in 0..self.frozen_rows { let y = self.rect.pos.y + header_h + self.row_y_cache[row as usize]; let rh = self.row_height_or_default(row) as f64; let rownum_rect = Rect { pos: DVec2 { x: self.rect.pos.x, y, }, size: DVec2 { x: row_hdr_w, y: rh, }, }; self.draw_header_bg.color = self.header_bg_color; self.draw_header_bg.draw_abs(cx, rownum_rect); self.draw_header_text.color = self.header_text_color; label_buf.clear(); use std::fmt::Write; let _ = write!(label_buf, "{}", row + 1); self.draw_header_text.draw_abs( cx, dvec2( rownum_rect.pos.x + row_hdr_w - 10.0 - label_buf.len() as f64 * 6.0, y + (rh - 14.0) * 0.5, ), &label_buf, ); } // Rebuild cache for scrollable row range. let scroll_row_count = last_row.saturating_sub(first_row) as usize; self.row_y_cache.clear(); self.row_y_cache.push(0.0); for i in 0..scroll_row_count { let r = first_row + i as u32; let prev = *self.row_y_cache.last().unwrap(); self.row_y_cache .push(prev + self.row_height_or_default(r) as f64); } for row in first_row..last_row { let y = scroll_y_start + self.row_y_cache[(row - first_row) as usize] - sub_row_px; let rh = self.row_height_or_default(row) as f64; let rownum_rect = Rect { pos: DVec2 { x: self.rect.pos.x, y, }, size: DVec2 { x: row_hdr_w, y: rh, }, }; self.draw_header_bg.color = self.header_bg_color; self.draw_header_bg.draw_abs(cx, rownum_rect); self.draw_header_text.color = self.header_text_color; label_buf.clear(); use std::fmt::Write; let _ = write!(label_buf, "{}", row + 1); self.draw_header_text.draw_abs( cx, dvec2( rownum_rect.pos.x + row_hdr_w - 10.0 - label_buf.len() as f64 * 6.0, y + (rh - 14.0) * 0.5, ), &label_buf, ); } self.draw_grid_line.color = self.header_border_color; self.draw_grid_line.draw_abs( cx, Rect { pos: DVec2 { x: self.rect.pos.x, y: self.rect.pos.y + header_h, }, size: DVec2 { x: self.rect.size.x, y: 2.0, }, }, ); self.draw_grid_line.draw_abs( cx, Rect { pos: DVec2 { x: self.rect.pos.x + row_hdr_w, y: self.rect.pos.y, }, size: DVec2 { x: 2.0, y: self.rect.size.y, }, }, ); if self.frozen_cols > 0 { self.draw_grid_line.draw_abs( cx, Rect { pos: DVec2 { x: scroll_x_start, y: self.rect.pos.y + header_h, }, size: DVec2 { x: 2.0, y: self.rect.size.y - header_h, }, }, ); } if self.frozen_rows > 0 { self.draw_grid_line.draw_abs( cx, Rect { pos: DVec2 { x: self.rect.pos.x + row_hdr_w, y: scroll_y_start, }, size: DVec2 { x: self.rect.size.x - row_hdr_w, y: 2.0, }, }, ); } } /// Draw the selection rectangle + the autofill handle dot at the /// bottom-right corner of the selection. fn draw_selection_overlay(&mut self, cx: &mut Cx2d) { let sel_bounds = self.selection_bounds(); if let Some((min_r, min_c, max_r, max_c)) = sel_bounds { let border_rect = self.range_abs_rect(min_r, min_c, max_r, max_c); self.draw_grid_line.color = self.selected_border_color; let t = 2.0; // top self.draw_grid_line.draw_abs( cx, Rect { pos: border_rect.pos, size: DVec2 { x: border_rect.size.x, y: t, }, }, ); // bottom self.draw_grid_line.draw_abs( cx, Rect { pos: DVec2 { x: border_rect.pos.x, y: border_rect.pos.y + border_rect.size.y - t, }, size: DVec2 { x: border_rect.size.x, y: t, }, }, ); // left self.draw_grid_line.draw_abs( cx, Rect { pos: border_rect.pos, size: DVec2 { x: t, y: border_rect.size.y, }, }, ); // right self.draw_grid_line.draw_abs( cx, Rect { pos: DVec2 { x: border_rect.pos.x + border_rect.size.x - t, y: border_rect.pos.y, }, size: DVec2 { x: t, y: border_rect.size.y, }, }, ); if let Some(head) = self.selection_head { let hr = self.cell_abs_rect(head.0, head.1); let hs = self.handle_draw_radius as f64; self.draw_handle.color = self.autofill_handle_color; self.draw_handle.draw_abs( cx, Rect { pos: DVec2 { x: hr.pos.x + hr.size.x - hs, y: hr.pos.y + hr.size.y - hs, }, size: DVec2 { x: hs * 2.0, y: hs * 2.0, }, }, ); } } } /// Draw the in-cell edit box: highlighted background, border, /// the typed text, and the blinking cursor. /// /// Uses `Option::take()` to temporarily move `EditState` out of /// `self`, avoiding the `String` clone that was previously needed /// to work around a borrow conflict between `&self` (for /// `cell_abs_rect`) and `&mut self` (for `draw_abs`). fn draw_edit_overlay(&mut self, cx: &mut Cx2d) { let Some(edit) = self.edit_state.take() else { return; }; let cell_rect = self.cell_abs_rect(edit.row, edit.col); self.draw_cell_bg.color = self.edit_bg_color; self.draw_cell_bg.draw_abs(cx, cell_rect); self.draw_grid_line.color = self.edit_border_color; let t = 2.0; self.draw_grid_line.draw_abs( cx, Rect { pos: cell_rect.pos, size: DVec2 { x: cell_rect.size.x, y: t, }, }, ); self.draw_grid_line.draw_abs( cx, Rect { pos: DVec2 { x: cell_rect.pos.x, y: cell_rect.pos.y + cell_rect.size.y - t, }, size: DVec2 { x: cell_rect.size.x, y: t, }, }, ); self.draw_grid_line.draw_abs( cx, Rect { pos: cell_rect.pos, size: DVec2 { x: t, y: cell_rect.size.y, }, }, ); self.draw_grid_line.draw_abs( cx, Rect { pos: DVec2 { x: cell_rect.pos.x + cell_rect.size.x - t, y: cell_rect.pos.y, }, size: DVec2 { x: t, y: cell_rect.size.y, }, }, ); self.draw_text.color = self.text_color; let text_x = cell_rect.pos.x + 6.0; let text_y = cell_rect.pos.y + (cell_rect.size.y - 14.0) * 0.5; self.draw_text .draw_abs(cx, dvec2(text_x, text_y), &edit.text); let blink_on = (edit.cursor_blink_time / CURSOR_BLINK_PERIOD * 2.0) < 1.0; if blink_on { let byte_idx = edit .text .char_indices() .nth(edit.cursor) .map(|(b, _)| b) .unwrap_or(edit.text.len()); let prefix_len = edit.text[..byte_idx].chars().count(); let cursor_x = text_x + prefix_len as f64 * 7.0; self.draw_edit_cursor.draw_abs( cx, Rect { pos: DVec2 { x: cursor_x, y: text_y - 2.0, }, size: DVec2 { x: 2.0, y: 18.0 }, }, ); } self.edit_state = Some(edit); } #[allow(clippy::too_many_arguments)] fn draw_cells_in_rect( &mut self, cx: &mut Cx2d, rect: Rect, min_row: u32, max_row: u32, min_col: u32, max_col: u32, origin_row: u32, origin_col: u32, sub_row_px: f64, sub_col_px: f64, ) { let sel_bounds = self.selection_bounds(); // Precompute cumulative column widths and row heights for // this rect's visible range. Reuse the cached Vecs to avoid // heap allocation per frame — they grow to peak capacity and // are cleared/refilled each call. let col_count = (max_col.saturating_sub(min_col)) as usize; self.col_x_cache.clear(); self.col_x_cache.push(0.0); for i in 0..col_count { let c = min_col + i as u32; let prev = *self.col_x_cache.last().unwrap(); self.col_x_cache .push(prev + self.col_width_or_default(c) as f64); } let row_count = (max_row.saturating_sub(min_row)) as usize; self.row_y_cache.clear(); self.row_y_cache.push(0.0); for i in 0..row_count { let r = min_row + i as u32; let prev = *self.row_y_cache.last().unwrap(); self.row_y_cache .push(prev + self.row_height_or_default(r) as f64); } // In the current callers, `origin_col == min_col` and // `origin_row == min_row` (verified by reading all four // call sites in `draw_walk`). If that ever stops being // true, this lookup will be wrong and we'd need to fall // back to the O(N) sum. Assert defensively. debug_assert_eq!(origin_col, min_col); debug_assert_eq!(origin_row, min_row); // Reusable buffer for write_display_value — avoids one String // allocation per visible cell per frame. Local to this call // so it doesn't conflict with &mut self borrows on draw_text. let mut display_buf = String::new(); for row in min_row..max_row { let y = rect.pos.y + self.row_y_cache[(row - min_row) as usize] - sub_row_px; let rh = self.row_height_or_default(row) as f64; if y > rect.pos.y + rect.size.y { break; } if y + rh < rect.pos.y { continue; } for col in min_col..max_col { let cw = self.col_width_or_default(col) as f64; let x = rect.pos.x + self.col_x_cache[(col - min_col) as usize] - sub_col_px; if x > rect.pos.x + rect.size.x { break; } if x + cw < rect.pos.x { continue; } let cell_rect = Rect { pos: DVec2 { x, y }, size: DVec2 { x: cw, y: rh }, }; let is_selected = sel_bounds .map(|(min_r, min_c, max_r, max_c)| { row >= min_r && row <= max_r && col >= min_c && col <= max_c }) .unwrap_or(false); let is_frozen = row < self.frozen_rows || col < self.frozen_cols; let cell_data = self.with_data(|data| data.cells.get(&CellId::new(row, col)).cloned()); let cell_ref = cell_data.as_ref(); self.draw_cell_bg.color = if is_selected { self.selected_bg_color } else if let Some(bg) = cell_ref.and_then(|c| c.style.bg_color) { color_to_vec4(bg) } else if is_frozen { self.frozen_bg_color } else if row % 2 == 1 { self.cell_alt_bg_color } else { self.cell_bg_color }; self.draw_cell_bg.draw_abs(cx, cell_rect); // Use `display_value_from_cell` to avoid a redundant // HashMap lookup — `cell_data` was already fetched // earlier for styling decisions. Also get `is_formula` // from the same `cell_data` instead of calling // `get_raw` (another redundant lookup). // (Review improvement B29: was 3 HashMap lookups per // cell per frame, now 1.) let display_is_empty = if let Some(c) = cell_ref { self.with_data(|data| data.write_display_value(c, &mut display_buf)); display_buf.is_empty() } else { display_buf.clear(); true }; let is_editing_this_cell = self .edit_state .as_ref() .map(|e| e.row == row && e.col == col) .unwrap_or(false); if !display_is_empty && !is_editing_this_cell { let is_formula = cell_ref.map(|c| c.value.starts_with('=')).unwrap_or(false); let is_bold = cell_ref.map(|c| c.style.bold).unwrap_or(false); let draw_text = if is_bold { &mut self.draw_text_bold } else { &mut self.draw_text }; draw_text.color = if let Some(tc) = cell_ref.and_then(|c| c.style.text_color) { color_to_vec4(tc) } else if is_bold { self.bold_text_color } else if is_formula { self.formula_text_color } else { self.text_color }; let align = cell_ref.map(|c| c.style.align).unwrap_or_default(); let text_width = self.text_measure_cache.measure(&display_buf, is_bold); let text_x = match align { CellAlign::Right => x + cw - 8.0 - text_width, CellAlign::Center => x + cw * 0.5 - text_width * 0.5, CellAlign::Left => x + 6.0, }; let text_y = y + (rh - 14.0) * 0.5; draw_text.draw_abs(cx, dvec2(text_x, text_y), &display_buf); if cell_ref.map(|c| c.style.underline).unwrap_or(false) { self.draw_grid_line.color = draw_text.color; self.draw_grid_line.draw_abs( cx, Rect { pos: DVec2 { x: text_x, y: text_y + 14.0, }, size: DVec2 { x: text_width, y: 1.0, }, }, ); } } self.draw_grid_line.color = self.grid_line_color; self.draw_grid_line.draw_abs( cx, Rect { pos: DVec2 { x, y: y + rh - 1.0 }, size: DVec2 { x: cw, y: 1.0 }, }, ); self.draw_grid_line.draw_abs( cx, Rect { pos: DVec2 { x: x + cw - 1.0, y }, size: DVec2 { x: 1.0, y: rh }, }, ); // Custom per-cell borders if let Some(cd) = cell_ref { if let Some(b) = cd.style.border_top { self.draw_grid_line.color = color_to_vec4(b.color); let w = b.width as f64; self.draw_grid_line.draw_abs( cx, Rect { pos: DVec2 { x, y }, size: DVec2 { x: cw, y: w }, }, ); } if let Some(b) = cd.style.border_bottom { self.draw_grid_line.color = color_to_vec4(b.color); let w = b.width as f64; self.draw_grid_line.draw_abs( cx, Rect { pos: DVec2 { x, y: y + rh - w }, size: DVec2 { x: cw, y: w }, }, ); } if let Some(b) = cd.style.border_left { self.draw_grid_line.color = color_to_vec4(b.color); let w = b.width as f64; self.draw_grid_line.draw_abs( cx, Rect { pos: DVec2 { x, y }, size: DVec2 { x: w, y: rh }, }, ); } if let Some(b) = cd.style.border_right { self.draw_grid_line.color = color_to_vec4(b.color); let w = b.width as f64; self.draw_grid_line.draw_abs( cx, Rect { pos: DVec2 { x: x + cw - w, y }, size: DVec2 { x: w, y: rh }, }, ); } } } } } fn with_data(&self, f: impl FnOnce(&SpreadsheetData) -> R) -> R { let workbook = self.shared_model.borrow(); f(workbook.active_sheet_data()) } fn with_data_mut(&mut self, f: impl FnOnce(&mut SpreadsheetData) -> R) -> R { f(self.shared_model.borrow_mut().active_sheet_data_mut()) } pub fn get_display_value(&self, row: u32, col: u32) -> String { self.with_data(|data| data.get_display_value(row, col)) } pub fn get_edit_value(&self, row: u32, col: u32) -> String { self.with_data(|data| data.get_edit_value(row, col)) } pub fn selection_bounds(&self) -> Option<(u32, u32, u32, u32)> { self.selection_anchor.and_then(|a| { self.selection_head .map(|h| (a.0.min(h.0), a.1.min(h.1), a.0.max(h.0), a.1.max(h.1))) }) } pub fn selected_cell(&self) -> (u32, u32) { self.selection_anchor.unwrap_or((0, 0)) } pub fn take_selection_changed(&mut self) -> Option<(u32, u32)> { let current = self.selected_cell(); if self.last_selection != Some(current) { self.last_selection = Some(current); Some(current) } else { None } } pub fn commit_selected_value(&mut self, cx: &mut Cx, text: &str) { let (row, col) = self.selected_cell(); self.set_cell_value(cx, row, col, text); } pub fn get_selected_range_coords(&self) -> Vec<(u32, u32)> { if let Some((min_r, min_c, max_r, max_c)) = self.selection_bounds() { let mut coords = Vec::new(); for r in min_r..=max_r { for c in min_c..=max_c { coords.push((r, c)); } } coords } else { Vec::new() } } pub fn select_cell(&mut self, cx: &mut Cx, row: u32, col: u32) { self.selection_anchor = Some((row, col)); self.selection_head = Some((row, col)); self.scroll_to_visible(cx, row, col); self.redraw(cx); } pub fn freeze_panes(&mut self, cx: &mut Cx, rows: u32, cols: u32) { self.frozen_rows = rows; self.frozen_cols = cols; self.redraw(cx); } pub fn set_cell_value(&mut self, cx: &mut Cx, row: u32, col: u32, value: &str) { // Short-circuit no-op commits: if the new value equals the // existing value (after trim), skip the entire // snapshot + set_cell + redraw cycle. This avoids creating // and discarding an empty `ChangeSet` on every formula-bar // Return keypress, even when the user didn't change anything. // (Follow-up to review bug B18.) // // `set_cell` itself has the same short-circuit, but calling // `snapshot()` first would still create an empty recording // and immediately discard it via `commit_pending`. Skipping // here avoids that overhead entirely. let trimmed = value.trim(); let existing = self.with_data(|data| data.cells.get(&CellId::new(row, col)).cloned()); let unchanged = match existing { None => trimmed.is_empty(), Some(cell) => { let new_formula = if trimmed.starts_with('=') { Some(trimmed) } else { None }; cell.value == trimmed && cell.formula.as_deref() == new_formula } }; if unchanged { return; } self.with_data_mut(|data| data.snapshot()); self.apply_command(WorkbookCommand::SetCell { row, col, value: value.to_owned(), }); self.redraw(cx); } pub fn begin_edit(&mut self, cx: &mut Cx, row: u32, col: u32) { let text = self.get_edit_value(row, col); cx.set_key_focus(self.draw_bg.area()); cx.show_text_ime(self.draw_bg.area(), self.cell_abs_rect(row, col).pos); self.edit_state = Some(EditState { row, col, cursor: text.chars().count(), text, cursor_blink_time: 0.0, }); cx.widget_action(self.widget_uid(), CellAction::EditStarted(row, col)); self.redraw(cx); } pub fn commit_edit(&mut self, cx: &mut Cx) { cx.hide_text_ime(); if let Some(edit) = self.edit_state.take() { self.with_data_mut(|data| data.snapshot()); self.apply_command(WorkbookCommand::SetCell { row: edit.row, col: edit.col, value: edit.text.clone(), }); cx.widget_action( self.widget_uid(), CellAction::EditCommitted(edit.row, edit.col, edit.text.clone()), ); self.redraw(cx); } } fn cancel_edit(&mut self, cx: &mut Cx) { cx.hide_text_ime(); if let Some(edit) = self.edit_state.take() { cx.widget_action( self.widget_uid(), CellAction::EditCancelled(edit.row, edit.col), ); self.redraw(cx); } } fn handle_edit_key(&mut self, cx: &mut Cx, ke: &KeyEvent) { let edit = self.edit_state.as_mut().unwrap(); match ke.key_code { KeyCode::ReturnKey => { let row = edit.row; let col = edit.col; self.commit_edit(cx); if row + 1 < NUM_ROWS { self.selection_anchor = Some((row + 1, col)); self.selection_head = Some((row + 1, col)); } self.redraw(cx); } KeyCode::Tab => { let row = edit.row; let col = edit.col; self.commit_edit(cx); let new_col = if ke.modifiers.shift { col.saturating_sub(1) } else { (col + 1).min(NUM_COLS - 1) }; self.selection_anchor = Some((row, new_col)); self.selection_head = Some((row, new_col)); self.redraw(cx); } KeyCode::Escape => self.cancel_edit(cx), KeyCode::ArrowLeft => { if edit.cursor > 0 { edit.cursor -= 1; } edit.cursor_blink_time = 0.0; self.redraw(cx); } KeyCode::ArrowRight => { if edit.cursor < edit.text.chars().count() { edit.cursor += 1; } edit.cursor_blink_time = 0.0; self.redraw(cx); } KeyCode::Home => { edit.cursor = 0; edit.cursor_blink_time = 0.0; self.redraw(cx); } KeyCode::End => { edit.cursor = edit.text.chars().count(); edit.cursor_blink_time = 0.0; self.redraw(cx); } KeyCode::Backspace => { let char_count = edit.text.chars().count(); if edit.cursor > 0 && edit.cursor <= char_count { let byte_idx = edit .text .char_indices() .nth(edit.cursor - 1) .map(|(b, _)| b) .unwrap_or(0); edit.text.remove(byte_idx); edit.cursor -= 1; edit.cursor_blink_time = 0.0; self.redraw(cx); } } KeyCode::Delete => { let char_count = edit.text.chars().count(); if edit.cursor < char_count { let byte_idx = edit .text .char_indices() .nth(edit.cursor) .map(|(b, _)| b) .unwrap_or(edit.text.len()); edit.text.remove(byte_idx); edit.cursor_blink_time = 0.0; self.redraw(cx); } } _ => { if !ke.modifiers.control && !ke.modifiers.logo { if let Some(ch) = key_to_char(ke.key_code, ke.modifiers.shift) { let char_count = edit.text.chars().count(); if edit.cursor <= char_count { let byte_idx = edit .text .char_indices() .nth(edit.cursor) .map(|(b, _)| b) .unwrap_or(edit.text.len()); edit.text.insert(byte_idx, ch); edit.cursor += 1; edit.cursor_blink_time = 0.0; self.redraw(cx); } } } } } } fn handle_nav_key(&mut self, cx: &mut Cx, ke: &KeyEvent) { let (row, col) = self.selected_cell(); let ctrl = ke.modifiers.control || ke.modifiers.logo; match ke.key_code { KeyCode::ArrowUp => { let nr = row.saturating_sub(1); self.selection_anchor = Some((nr, col)); self.selection_head = Some((nr, col)); self.scroll_to_visible(cx, nr, col); } KeyCode::ArrowDown => { let nr = (row + 1).min(NUM_ROWS - 1); self.selection_anchor = Some((nr, col)); self.selection_head = Some((nr, col)); self.scroll_to_visible(cx, nr, col); } KeyCode::ArrowLeft => { let nc = col.saturating_sub(1); self.selection_anchor = Some((row, nc)); self.selection_head = Some((row, nc)); self.scroll_to_visible(cx, row, nc); } KeyCode::ArrowRight => { let nc = (col + 1).min(NUM_COLS - 1); self.selection_anchor = Some((row, nc)); self.selection_head = Some((row, nc)); self.scroll_to_visible(cx, row, nc); } KeyCode::Tab => { let nc = if ke.modifiers.shift { col.saturating_sub(1) } else { (col + 1).min(NUM_COLS - 1) }; self.selection_anchor = Some((row, nc)); self.selection_head = Some((row, nc)); self.scroll_to_visible(cx, row, nc); } KeyCode::ReturnKey => { let nr = (row + 1).min(NUM_ROWS - 1); self.selection_anchor = Some((nr, col)); self.selection_head = Some((nr, col)); self.scroll_to_visible(cx, nr, col); } KeyCode::Delete | KeyCode::Backspace => { self.with_data_mut(|data| data.snapshot()); let mut changed_cells = Vec::new(); if let Some(sel) = self.selection_bounds() { for r in sel.0..=sel.2 { for c in sel.1..=sel.3 { self.apply_command(WorkbookCommand::RemoveCell { row: r, col: c }); changed_cells.push(CellId::new(r, c)); } } } self.with_data_mut(|data| data.recalculate_incremental(&changed_cells)); self.redraw(cx); } KeyCode::KeyC if ctrl => self.copy_selection(), KeyCode::KeyV if ctrl => self.paste_selection(cx), KeyCode::KeyX if ctrl => { self.copy_selection(); self.with_data_mut(|data| data.snapshot()); let mut changed_cells = Vec::new(); if let Some(sel) = self.selection_bounds() { for r in sel.0..=sel.2 { for c in sel.1..=sel.3 { self.apply_command(WorkbookCommand::RemoveCell { row: r, col: c }); changed_cells.push(CellId::new(r, c)); } } } self.with_data_mut(|data| data.recalculate_incremental(&changed_cells)); self.redraw(cx); } KeyCode::KeyZ if ctrl => self.undo(cx), KeyCode::KeyY if ctrl => self.redo(cx), KeyCode::KeyB if ctrl => self.toggle_bold(cx), _ => { if let Some(ch) = key_to_char(ke.key_code, ke.modifiers.shift) { if !ctrl { let (row, col) = self.selected_cell(); self.begin_edit(cx, row, col); if let Some(edit) = self.edit_state.as_mut() { edit.text.clear(); edit.text.push(ch); edit.cursor = 1; } self.redraw(cx); } } } } } fn scroll_to_visible(&mut self, cx: &mut Cx, row: u32, col: u32) { let visible_rows = self.visible_rows; let first_row = self.scroll_row_offset.floor() as u32; if row >= self.frozen_rows { if row < first_row { self.scroll_row_offset = row as f64; } else if row >= first_row + visible_rows as u32 - self.frozen_rows { self.scroll_row_offset = row as f64 - visible_rows + self.frozen_rows as f64 + 1.0; } } let visible_cols = ((self.rect.size.x - self.row_header_width as f64) / self.col_width as f64).max(1.0); let first_col = self.scroll_col_offset.floor() as u32; if col >= self.frozen_cols { if col < first_col { self.scroll_col_offset = col as f64; } else if col >= first_col + visible_cols as u32 - self.frozen_cols { self.scroll_col_offset = col as f64 - visible_cols + self.frozen_cols as f64 + 1.0; } } self.redraw(cx); } fn copy_selection(&mut self) { if let Some(sel) = self.selection_bounds() { let mut cells = Vec::new(); for r in sel.0..=sel.2 { for c in sel.1..=sel.3 { if let Some(cell) = self.with_data(|data| data.cells.get(&CellId::new(r, c)).cloned()) { let rel_r = r - sel.0; let rel_c = c - sel.1; cells.push((rel_r, rel_c, cell.clone())); } } } self.clipboard = Some(ClipboardEntry { origin: (sel.0, sel.1), cells, }); } } fn paste_selection(&mut self, cx: &mut Cx) { // Borrow the clipboard cells without cloning the entire // entry — we only need read access during the loop. let clip_cells: Vec<(u32, u32, CellData)> = match &self.clipboard { Some(c) => c .cells .iter() .map(|(r, co, cd)| (*r, *co, cd.clone())) .collect(), None => return, }; let clip_origin = match &self.clipboard { Some(c) => c.origin, None => return, }; let (base_r, base_c) = self.selected_cell(); self.with_data_mut(|data| data.snapshot()); let mut changed_cells = Vec::new(); for (rel_r, rel_c, cell) in &clip_cells { let src_r = clip_origin.0 + *rel_r; let src_c = clip_origin.1 + *rel_c; let dst_r = base_r + *rel_r; let dst_c = base_c + *rel_c; if dst_r >= NUM_ROWS || dst_c >= NUM_COLS { continue; } let mut new_cell = cell.clone(); if let Some(f) = &new_cell.formula { let row_delta = dst_r as i64 - src_r as i64; let col_delta = dst_c as i64 - src_c as i64; let adj = adjust_formula_refs(f, row_delta, col_delta); new_cell.formula = Some(adj.clone()); new_cell.value = adj; new_cell.computed_value.clear(); } else { new_cell.computed_value.clear(); } // `put_cell` handles the dep-graph update AND records // the old/new state for undo. (Previously this was a // direct `cells.insert` + manual `update_dependency_graph`, // which bypassed undo recording.) self.apply_command(WorkbookCommand::PutCell { row: dst_r, col: dst_c, cell: new_cell, }); changed_cells.push(CellId::new(dst_r, dst_c)); } self.with_data_mut(|data| data.recalculate_incremental(&changed_cells)); self.redraw(cx); } pub fn toggle_bold(&mut self, cx: &mut Cx) { if let Some(sel) = self.selection_bounds() { self.with_data_mut(|data| data.snapshot()); for r in sel.0..=sel.2 { for c in sel.1..=sel.3 { self.apply_command(WorkbookCommand::ToggleBold { row: r, col: c }); } } self.redraw(cx); } } pub fn toggle_italic(&mut self, cx: &mut Cx) { if let Some(sel) = self.selection_bounds() { self.with_data_mut(|data| data.snapshot()); for r in sel.0..=sel.2 { for c in sel.1..=sel.3 { self.apply_command(WorkbookCommand::ToggleItalic { row: r, col: c }); } } self.redraw(cx); } } pub fn toggle_underline(&mut self, cx: &mut Cx) { if let Some(sel) = self.selection_bounds() { self.with_data_mut(|data| data.snapshot()); for r in sel.0..=sel.2 { for c in sel.1..=sel.3 { self.apply_command(WorkbookCommand::ToggleUnderline { row: r, col: c }); } } self.redraw(cx); } } pub fn set_borders(&mut self, cx: &mut Cx, target: BorderTarget) { if let Some(sel) = self.selection_bounds() { self.with_data_mut(|data| data.snapshot()); let edge = BorderEdge { color: Color::new(0.85, 0.85, 0.9, 1.0), width: 1.5, }; for r in sel.0..=sel.2 { for c in sel.1..=sel.3 { let (top, bottom, left, right) = match target { BorderTarget::All => (true, true, true, true), BorderTarget::Outer => (r == sel.0, r == sel.2, c == sel.1, c == sel.3), BorderTarget::None => (false, false, false, false), }; self.apply_command(WorkbookCommand::SetBorders { row: r, col: c, target, edge, top, bottom, left, right, }); } } self.redraw(cx); } } pub fn set_number_format(&mut self, cx: &mut Cx, fmt: NumberFormat) { if let Some(sel) = self.selection_bounds() { self.with_data_mut(|data| data.snapshot()); for r in sel.0..=sel.2 { for c in sel.1..=sel.3 { self.apply_command(WorkbookCommand::SetNumberFormat { row: r, col: c, format: fmt, }); } } self.redraw(cx); } } pub fn set_alignment(&mut self, cx: &mut Cx, align: CellAlign) { if let Some(sel) = self.selection_bounds() { self.with_data_mut(|data| data.snapshot()); for r in sel.0..=sel.2 { for c in sel.1..=sel.3 { self.apply_command(WorkbookCommand::SetAlignment { row: r, col: c, align, }); } } self.redraw(cx); } } pub fn set_bg_color(&mut self, cx: &mut Cx, color: Option) { if let Some(sel) = self.selection_bounds() { self.with_data_mut(|data| data.snapshot()); for r in sel.0..=sel.2 { for c in sel.1..=sel.3 { self.apply_command(WorkbookCommand::SetBackground { row: r, col: c, color: color.map(vec4_to_color), }); } } self.redraw(cx); } } fn do_autofill( &mut self, src_min: (u32, u32), src_max: (u32, u32), dst_min: (u32, u32), dst_max: (u32, u32), ) { self.with_data_mut(|data| data.snapshot()); let src_height = (src_max.0 - src_min.0 + 1) as i64; let src_width = (src_max.1 - src_min.1 + 1) as i64; let mut changed_cells = Vec::new(); let mut v_series = HashMap::::new(); for c in src_min.1..=src_max.1 { let vals: Vec = (src_min.0..=src_max.0) .map(|r| { self.with_data(|data| { data.cells .get(&CellId::new(r, c)) .map(|cell| cell.value.clone()) .unwrap_or_default() }) }) .collect(); v_series.insert(c, detect_series(&vals)); } let mut h_series = HashMap::::new(); for r in src_min.0..=src_max.0 { let vals: Vec = (src_min.1..=src_max.1) .map(|c| { self.with_data(|data| { data.cells .get(&CellId::new(r, c)) .map(|cell| cell.value.clone()) .unwrap_or_default() }) }) .collect(); h_series.insert(r, detect_series(&vals)); } // Memoization caches for the fallback branch below. The // original implementation rebuilt `row_vals`/`col_vals` from // scratch and called `detect_series` once per destination // cell, making a 100-cell autofill do 100× series detection // and 100× cell scans. With these caches, each row/column's // series is computed at most once per autofill. (Bug B14.) let mut dst_row_series: HashMap> = HashMap::new(); let mut dst_col_series: HashMap> = HashMap::new(); for r in dst_min.0..=dst_max.0 { for c in dst_min.1..=dst_max.1 { if r >= src_min.0 && r <= src_max.0 && c >= src_min.1 && c <= src_max.1 { continue; } let src_r = src_min.0 + (r as i64 - dst_min.0 as i64).rem_euclid(src_height) as u32; let src_c = src_min.1 + (c as i64 - dst_min.1 as i64).rem_euclid(src_width) as u32; let src_cell_opt = self.with_data(|data| data.cells.get(&CellId::new(src_r, src_c)).cloned()); if let Some(src_cell) = src_cell_opt { let mut new_cell = src_cell.clone(); if let Some(ref formula) = src_cell.formula { let row_delta = r as i64 - src_r as i64; let col_delta = c as i64 - src_c as i64; let adj = adjust_formula_refs(formula, row_delta, col_delta); new_cell.formula = Some(adj.clone()); new_cell.value = adj; new_cell.computed_value.clear(); } else { let mut new_val = None; if c >= src_min.1 && c <= src_max.1 { let series = v_series.get(&c).unwrap(); let idx = r as i64 - src_min.0 as i64; new_val = get_series_value(series, idx); } else if r >= src_min.0 && r <= src_max.0 { let series = h_series.get(&r).unwrap(); let idx = c as i64 - src_min.1 as i64; new_val = get_series_value(series, idx); } else { // Fallback: try the destination row's // already-filled cells, then the // destination column's. Memoize the // series detection per row/column. let row_series_entry = dst_row_series.entry(r).or_insert_with(|| { let row_vals: Vec = (src_min.1..c) .map(|cc| { self.with_data(|data| { data.cells .get(&CellId::new(r, cc)) .map(|cell| cell.value.clone()) .unwrap_or_default() }) }) .collect(); if row_vals.is_empty() { None } else { Some(detect_series(&row_vals)) } }); if let Some(series) = row_series_entry { let idx = c as i64 - src_min.1 as i64; new_val = get_series_value(series, idx); } if new_val.is_none() { let col_series_entry = dst_col_series.entry(c).or_insert_with(|| { let col_vals: Vec = (src_min.0..r) .map(|rr| { self.with_data(|data| { data.cells .get(&CellId::new(rr, c)) .map(|cell| cell.value.clone()) .unwrap_or_default() }) }) .collect(); if col_vals.is_empty() { None } else { Some(detect_series(&col_vals)) } }); if let Some(series) = col_series_entry { let idx = r as i64 - src_min.0 as i64; new_val = get_series_value(series, idx); } } if new_val.is_none() { new_val = Some(src_cell.value.clone()); } } if let Some(val) = new_val { new_cell.value = val.clone(); new_cell.computed_value = val; } } // `put_cell` handles dep-graph update + undo recording. self.apply_command(WorkbookCommand::PutCell { row: r, col: c, cell: new_cell, }); changed_cells.push(CellId::new(r, c)); } } } self.with_data_mut(|data| data.recalculate_incremental(&changed_cells)); } pub fn undo(&mut self, cx: &mut Cx) { // `data.undo()` now applies a `ChangeSet` (delta-based) and // returns `true` if anything was undone. No more // serialize/deserialize round-trip. if self.with_data_mut(|data| data.undo()) { self.redraw(cx); } } pub fn redo(&mut self, cx: &mut Cx) { if self.with_data_mut(|data| data.redo()) { self.redraw(cx); } } fn scroll_by(&mut self, cx: &mut Cx, delta: DVec2) { let max_row_off = (NUM_ROWS as f64 - self.visible_rows).max(0.0); self.scroll_row_offset = (self.scroll_row_offset + delta.y / self.row_height as f64).clamp(0.0, max_row_off); let visible_cols = ((self.rect.size.x - self.row_header_width as f64) / self.col_width as f64).max(1.0); let max_col_off = (NUM_COLS as f64 - visible_cols).max(0.0); self.scroll_col_offset = (self.scroll_col_offset + delta.x / self.col_width as f64).clamp(0.0, max_col_off); self.invalidate_all(); self.redraw(cx); } fn handle_touch_interaction(&mut self, cx: &mut Cx, tu: &TouchUpdateEvent) { let rect = self.draw_bg.area().rect(cx); let mut single_finger_delta: Option = None; for touch in &tu.touches { match touch.state { TouchState::Start => { if rect.contains(touch.abs) && !self.active_touches.iter().any(|(u, _)| *u == touch.uid) { self.active_touches.push((touch.uid, touch.abs)); } } TouchState::Move | TouchState::Stable => { if let Some(entry) = self .active_touches .iter_mut() .find(|(u, _)| *u == touch.uid) { let delta = touch.abs - entry.1; entry.1 = touch.abs; if self.active_touches.len() == 1 && (delta.x.abs() > 0.1 || delta.y.abs() > 0.1) { single_finger_delta = Some(delta); } } } TouchState::Stop => { self.active_touches.retain(|(u, _)| *u != touch.uid); } } } if self.active_touches.len() == 2 { self.drag_state = DragState::None; let p0 = self.active_touches[0].1; let p1 = self.active_touches[1].1; let cur_dist = (p1 - p0).length(); let mid = DVec2 { x: (p0.x + p1.x) * 0.5, y: (p0.y + p1.y) * 0.5, }; if let Some(last_dist) = self.pinch_last_dist { if last_dist > 1.0 && cur_dist > 1.0 { let factor = (cur_dist / last_dist).clamp(0.8, 1.2); self.col_width = (self.col_width * factor as f32).clamp(40.0, 400.0); self.row_height = (self.row_height * factor as f32).clamp(16.0, 100.0); self.redraw(cx); } } if let Some(last_mid) = self.pinch_last_mid { let mid_delta = mid - last_mid; if mid_delta.length() > 0.25 { self.scroll_by(cx, mid_delta); } } self.pinch_last_dist = Some(cur_dist); self.pinch_last_mid = Some(mid); return; } else { self.pinch_last_dist = None; self.pinch_last_mid = None; } if let Some(delta) = single_finger_delta { if matches!(self.drag_state, DragState::None) { self.scroll_by(cx, delta); } } } /// `accumulated_col_width(first_col, col)` returns the total /// width of columns `[first_col, col)` (i.e. exclusive of `col`). /// /// This is O(N) in the column span. It is no longer called from /// the per-cell draw loop (which now uses a precomputed /// `Vec` cumulative array — see `draw_cells_in_rect` and /// `draw_headers`). The remaining callers are `cell_abs_rect` /// and its users (`range_abs_rect`, `handle_rect`, /// `draw_edit_overlay`, `draw_selection_overlay`), which are /// called only a few times per frame for hit-testing and overlay /// drawing — the O(N) cost there is negligible. fn accumulated_col_width(&self, first_col: u32, col: u32) -> f64 { let mut w = 0.0_f64; for c in first_col..col { w += self.col_width_or_default(c) as f64; } w } fn accumulated_row_height(&self, first_row: u32, row: u32) -> f64 { let mut h = 0.0_f64; for r in first_row..row { h += self.row_height_or_default(r) as f64; } h } fn col_at_x(&self, x: f64) -> Option<(u32, f64)> { let row_hdr_w = self.row_header_width as f64; let frozen_cols_width: f64 = (0..self.frozen_cols) .map(|c| self.col_width_or_default(c) as f64) .sum(); let scroll_x_start = self.rect.pos.x + row_hdr_w + frozen_cols_width; if x < scroll_x_start { if x >= self.rect.pos.x + row_hdr_w { let mut acc = self.rect.pos.x + row_hdr_w; for c in 0..self.frozen_cols { let cw = self.col_width_or_default(c) as f64; if x < acc + cw { return Some((c, acc)); } acc += cw; } } return None; } let mut x_off = x - scroll_x_start; let sub_col_px = self.scroll_col_offset.fract() * self.col_width as f64; x_off += sub_col_px; let first_col = self.scroll_col_offset.floor() as u32; let mut acc = 0.0; for col in first_col..NUM_COLS { let cw = self.col_width_or_default(col) as f64; if x_off < acc + cw { return Some((col, scroll_x_start + acc - sub_col_px)); } acc += cw; } None } fn row_at_y(&self, y: f64) -> Option<(u32, f64)> { let header_h = self.col_header_height as f64; let frozen_rows_height: f64 = (0..self.frozen_rows) .map(|r| self.row_height_or_default(r) as f64) .sum(); let scroll_y_start = self.rect.pos.y + header_h + frozen_rows_height; if y < scroll_y_start { if y >= self.rect.pos.y + header_h { let mut acc = self.rect.pos.y + header_h; for r in 0..self.frozen_rows { let rh = self.row_height_or_default(r) as f64; if y < acc + rh { return Some((r, acc)); } acc += rh; } } return None; } let mut y_off = y - scroll_y_start; let sub_row_px = self.scroll_row_offset.fract() * self.row_height as f64; y_off += sub_row_px; let first_row = self.scroll_row_offset.floor() as u32; let mut acc = 0.0; for row in first_row..NUM_ROWS { let rh = self.row_height_or_default(row) as f64; if y_off < acc + rh { return Some((row, scroll_y_start + acc - sub_row_px)); } acc += rh; } None } fn coords_in_grid(&self, abs_pos: DVec2) -> Option<(u32, u32)> { let (row, _) = self.row_at_y(abs_pos.y)?; let (col, _) = self.col_at_x(abs_pos.x)?; Some((row, col)) } fn col_resize_hit_test(&self, abs_pos: DVec2) -> Option { if abs_pos.y > self.rect.pos.y + self.col_header_height as f64 { return None; } let hit = self.col_resize_hit as f64 / 2.0; let (col, x) = self.col_at_x(abs_pos.x)?; let cw = self.col_width_or_default(col) as f64; if abs_pos.x > x + cw - hit { return Some(col); } if abs_pos.x < x + hit && col > 0 { return Some(col - 1); } None } fn row_resize_hit_test(&self, abs_pos: DVec2) -> Option { if abs_pos.x > self.rect.pos.x + self.row_header_width as f64 { return None; } let hit = 4.0; let (row, y) = self.row_at_y(abs_pos.y)?; let rh = self.row_height_or_default(row) as f64; if abs_pos.y > y + rh - hit { return Some(row); } if abs_pos.y < y + hit && row > 0 { return Some(row - 1); } None } fn cell_abs_rect(&self, row: u32, col: u32) -> Rect { let row_hdr_w = self.row_header_width as f64; let header_h = self.col_header_height as f64; let frozen_cols_width: f64 = (0..self.frozen_cols) .map(|c| self.col_width_or_default(c) as f64) .sum(); let frozen_rows_height: f64 = (0..self.frozen_rows) .map(|r| self.row_height_or_default(r) as f64) .sum(); let scroll_x_start = self.rect.pos.x + row_hdr_w + frozen_cols_width; let scroll_y_start = self.rect.pos.y + header_h + frozen_rows_height; let sub_col_px = self.scroll_col_offset.fract() * self.col_width as f64; let sub_row_px = self.scroll_row_offset.fract() * self.row_height as f64; let first_col = self.scroll_col_offset.floor() as u32; let first_row = self.scroll_row_offset.floor() as u32; let x = if col < self.frozen_cols { self.rect.pos.x + row_hdr_w + self.accumulated_col_width(0, col) } else { scroll_x_start + self.accumulated_col_width(first_col, col) - sub_col_px }; let y = if row < self.frozen_rows { self.rect.pos.y + header_h + self.accumulated_row_height(0, row) } else { scroll_y_start + self.accumulated_row_height(first_row, row) - sub_row_px }; let cw = self.col_width_or_default(col) as f64; let rh = self.row_height_or_default(row) as f64; Rect { pos: DVec2 { x, y }, size: DVec2 { x: cw, y: rh }, } } fn range_abs_rect(&self, min_r: u32, min_c: u32, max_r: u32, max_c: u32) -> Rect { let tl = self.cell_abs_rect(min_r, min_c); let br = self.cell_abs_rect(max_r, max_c); Rect { pos: tl.pos, size: DVec2 { x: br.pos.x + br.size.x - tl.pos.x, y: br.pos.y + br.size.y - tl.pos.y, }, } } fn handle_rect(&self, row: u32, col: u32) -> Rect { let cell = self.cell_abs_rect(row, col); let hs = (self.handle_hit_size as f64).max(32.0); Rect { pos: DVec2 { x: cell.pos.x + cell.size.x - hs * 0.75, y: cell.pos.y + cell.size.y - hs * 0.75, }, size: DVec2 { x: hs * 1.5, y: hs * 1.5, }, } } } // The script_mod! macro generates a `script_mod` function. // We wrap it in `pub fn` so `lib.rs` can call it. script_mod! { use mod.prelude.widgets.* mod.widgets.SpreadsheetGrid = #(SpreadsheetGrid::register_widget(vm)) { width: Fill, height: Fill row_height: 26.0, col_width: 100.0, row_header_width: 50.0, col_header_height: 28.0 header_bg_color: #x242438, header_text_color: #x8a8aa5 cell_bg_color: #x1a1a2e, cell_alt_bg_color: #x1e1e30 selected_bg_color: #x2d4a63, selected_border_color: #x4fc3f7 grid_line_color: #x2a2a3e, header_border_color: #x3a3a4e text_color: #xd8d8e8, formula_text_color: #x81c995 edit_bg_color: #x1a1a2e, edit_border_color: #x4fc3f7 bold_text_color: #xffffff, frozen_bg_color: #x2a2a40 resize_handle_color: #x4fc3f7, autofill_handle_color: #x4fc3f7 col_resize_hit: 8.0 draw_bg +: { draw_depth: 0.0 color: #x1a1a2e } draw_header_bg +: { draw_depth: 0.1 } draw_cell_bg +: { draw_depth: 0.15 } draw_grid_line +: { draw_depth: 0.2 } draw_text +: { draw_depth: 0.3 color: #xd8d8e8 text_style: theme.font_regular } draw_text_bold +: { draw_depth: 0.3 color: #xd8d8e8 text_style: theme.font_bold } draw_header_text +: { draw_depth: 0.3 color: #x8a8aa5 text_style: theme.font_bold } draw_edit_cursor +: { draw_depth: 0.5 color: #x4fc3f7 } draw_handle +: { draw_depth: 0.6 color: #x4fc3f7 } handle_color: #x4fc3f7 handle_hit_size: 24.0 handle_draw_radius: 6.0 } }