nigig-org/crates/apps/spreadsheet/spreadsheet-ui/src/grid.rs
andodeki 52eff6167d
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
perf(spreadsheet-ui): invalidate full grid after scrolling
2026-08-02 09:51:13 +00:00

2622 lines
94 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `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<char> {
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<GridIntent>,
#[rust]
text_measure_cache: TextMeasureCache,
#[rust]
dirty_cells: HashSet<CellId>,
#[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<EditState>,
#[rust]
pub scroll_row_offset: f64,
#[rust]
pub scroll_col_offset: f64,
#[rust(DragState::None)]
drag_state: DragState,
#[rust]
last_touch_pos: Option<DVec2>,
#[rust]
active_touches: Vec<(u64, DVec2)>,
#[rust]
pinch_last_dist: Option<f64>,
#[rust]
pinch_last_mid: Option<DVec2>,
#[live]
handle_color: Vec4f,
#[live(24.0)]
handle_hit_size: f32,
#[live(6.0)]
handle_draw_radius: f32,
#[rust]
clipboard: Option<ClipboardEntry>,
#[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<f64>,
#[rust]
row_y_cache: Vec<f64>,
#[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<CellId>, bool) {
(
std::mem::take(&mut self.dirty_cells),
std::mem::replace(&mut self.full_invalidation, false),
)
}
pub fn drain_intents(&mut self) -> Vec<GridIntent> {
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<R>(&self, f: impl FnOnce(&SpreadsheetData) -> R) -> R {
let workbook = self.shared_model.borrow();
f(workbook.active_sheet_data())
}
fn with_data_mut<R>(&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<Vec4f>) {
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::<u32, spreadsheet_engine::autofill::Series>::new();
for c in src_min.1..=src_max.1 {
let vals: Vec<String> = (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::<u32, spreadsheet_engine::autofill::Series>::new();
for r in src_min.0..=src_max.0 {
let vals: Vec<String> = (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<u32, Option<spreadsheet_engine::autofill::Series>> =
HashMap::new();
let mut dst_col_series: HashMap<u32, Option<spreadsheet_engine::autofill::Series>> =
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<String> = (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<String> = (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<DVec2> = 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<f64>` 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<u32> {
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<u32> {
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
}
}