2622 lines
94 KiB
Rust
2622 lines
94 KiB
Rust
//! `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
|
||
}
|
||
}
|