diff --git a/crates/apps/spreadsheet/spreadsheet-engine/src/data_source.rs b/crates/apps/spreadsheet/spreadsheet-engine/src/data_source.rs new file mode 100644 index 0000000..8ce90f0 --- /dev/null +++ b/crates/apps/spreadsheet/spreadsheet-engine/src/data_source.rs @@ -0,0 +1,414 @@ +//! A grid data-source abstraction: what a grid pulls visible cells from. +//! +//! The Makepad datagrid reference can render **one billion virtual cells** +//! (1,000,000 rows × 1,000 columns) by deriving each value procedurally +//! from `(row, col)` — nothing is stored, and a header click sorts all a +//! million rows by permuting an index rather than moving data. +//! +//! This module provides the same seam for our engine: [`GridDataSource`] +//! is the trait a grid renders from, [`SpreadsheetData`] implements it for +//! the real store, and [`VirtualSheet`] implements it procedurally. The +//! sort scales because it permutes a `Vec` of row indices. + +use crate::data::{SpreadsheetData, NUM_COLS, NUM_ROWS}; +use crate::util::write_col_letters; + +/// The rows/columns a virtual source presents by default: the reference's +/// headline "one billion cells". +pub const VIRTUAL_ROWS: u32 = 1_000_000; +pub const VIRTUAL_COLS: u32 = 1_000; + +/// What a grid renders from: a real store or a procedural source. +pub trait GridDataSource { + fn row_count(&self) -> u32; + fn col_count(&self) -> u32; + /// Display text for a cell at a *view* position (after any sort). + fn cell_display(&self, row: u32, col: u32) -> String; + /// Sort every row by `key_col`, ascending or descending. + fn sort_rows(&mut self, ascending: bool, key_col: u32); + /// The column header label (A1 letters by default). + fn col_label(&self, col: u32) -> String { + let mut buf = String::new(); + write_col_letters(col, &mut buf); + buf + } +} + +impl GridDataSource for SpreadsheetData { + fn row_count(&self) -> u32 { + NUM_ROWS + } + + fn col_count(&self) -> u32 { + NUM_COLS + } + + fn cell_display(&self, row: u32, col: u32) -> String { + self.get_display_value(row, col) + } + + fn sort_rows(&mut self, ascending: bool, key_col: u32) { + SpreadsheetData::sort_rows(self, ascending, key_col); + } +} + +/// A procedural, storage-free data source: values are hashed from +/// `(row, col)`, so a billion cells cost one struct. Sorting permutes a row +/// index; the cell text and the numeric sort key derive from the same hash, +/// so sorting by the key sorts what the user sees. +pub struct VirtualSheet { + rows: u32, + cols: u32, + /// View row → data row. `None` until the first sort (identity order). + perm: Option>, + /// The active sort: `(key column, ascending)`. + sort: Option<(u32, bool)>, +} + +/// The reference's 64-bit mix for `(row, col)`. +fn hash2(row: u64, col: u64) -> u64 { + let mut x = row + .wrapping_mul(0x9e37_79b9_7f4a_7c15) + .wrapping_add(col.wrapping_mul(0xbf58_476d_1ce4_e5b9)) + .wrapping_add(0x94d0_49bb_1331_11eb); + x ^= x >> 30; + x = x.wrapping_mul(0xbf58_476d_1ce4_e5b9); + x ^= x >> 27; + x = x.wrapping_mul(0x94d0_49bb_1331_11eb); + x ^ (x >> 31) +} + +const FIRST: [&str; 12] = [ + "Ada", "Linus", "Grace", "Alan", "Edsger", "Barbara", "Donald", "Margaret", "Ken", "Dennis", + "Radia", "Vint", +]; +const LAST: [&str; 12] = [ + "Hopper", "Kay", "Lovelace", "Turing", "Dijkstra", "Liskov", "Knuth", "Hamilton", "Thompson", + "Ritchie", "Perlman", "Cerf", +]; +const CITIES: [&str; 10] = [ + "Amsterdam", + "Tokyo", + "Berlin", + "Lisbon", + "Oslo", + "Seoul", + "Toronto", + "Austin", + "Zurich", + "Kyoto", +]; + +impl VirtualSheet { + pub fn new(rows: u32, cols: u32) -> Self { + Self { + rows, + cols, + perm: None, + sort: None, + } + } + + /// The data row behind a view row (the identity until sorted). + pub fn data_row(&self, view_row: u32) -> u32 { + match &self.perm { + Some(perm) => perm[view_row as usize], + None => view_row, + } + } + + /// Whether the sheet has been sorted (i.e. `perm` is non-trivial). + pub fn is_sorted(&self) -> bool { + self.perm.is_some() + } + + /// The active sort, if any: `(key column, ascending)`. + pub fn sort_state(&self) -> Option<(u32, bool)> { + self.sort + } + + /// Clear the active sort, restoring the identity row order (the "off" + /// state of a header sort toggle). + pub fn reset_sort(&mut self) { + self.perm = None; + self.sort = None; + } + + /// The numeric sort key for a *data* cell, derived from the same hash + /// that renders the text. + pub fn value_num(&self, row: u32, col: u32) -> f64 { + let h = hash2(row as u64, col as u64); + match col % 6 { + 0 => row as f64, + 1 => (h % (FIRST.len() * LAST.len()) as u64) as f64, + 2 => (h % CITIES.len() as u64) as f64, + 3 => ((h % 2_000_000) as f64 / 100.0) - 10000.0, + 4 => (h % 1000) as f64 / 10.0, + _ => (h % 2) as f64, + } + } + + /// The rendered text for a *data* cell. + pub fn value_text(&self, row: u32, col: u32) -> String { + let h = hash2(row as u64, col as u64); + match col % 6 { + 0 => format!("{}", row), + 1 => { + let i = (h % (FIRST.len() * LAST.len()) as u64) as usize; + format!("{} {}", FIRST[i % FIRST.len()], LAST[i / FIRST.len()]) + } + 2 => CITIES[(h % CITIES.len() as u64) as usize].to_string(), + 3 => { + let v = ((h % 2_000_000) as f64 / 100.0) - 10000.0; + format!("{:.2}", v) + } + 4 => format!("{:.1}%", (h % 1000) as f64 / 10.0), + _ => { + if h.is_multiple_of(2) { + "yes".to_string() + } else { + "no".to_string() + } + } + } + } + + /// The header label for a column: named for the first six, then + /// `Name·1`, `City·1`, … as the reference does. + pub fn col_label(&self, col: u32) -> String { + let base = ["#", "Name", "City", "Balance", "Score", "Active"]; + if col < 6 { + base[col as usize].to_string() + } else { + format!("{}·{}", base[(col % 6) as usize], col / 6) + } + } +} + +impl GridDataSource for VirtualSheet { + fn row_count(&self) -> u32 { + self.rows + } + + fn col_count(&self) -> u32 { + self.cols + } + + fn cell_display(&self, row: u32, col: u32) -> String { + self.value_text(self.data_row(row), col) + } + + /// Sort every row by `key_col`, permuting the row index. The keys are + /// computed in the comparator like the reference; the elapsed time is + /// reported by the caller (the grid's status bar), not here. + fn sort_rows(&mut self, ascending: bool, key_col: u32) { + let mut perm: Vec = (0..self.rows).collect(); + perm.sort_unstable_by(|&a, &b| { + let ka = self.value_num(a, key_col); + let kb = self.value_num(b, key_col); + let ord = ka.partial_cmp(&kb).unwrap_or(std::cmp::Ordering::Equal); + let ord = if ascending { ord } else { ord.reverse() }; + // Deterministic tie-break by data row, independent of direction. + ord.then(a.cmp(&b)) + }); + self.perm = Some(perm); + self.sort = Some((key_col, ascending)); + } + + fn col_label(&self, col: u32) -> String { + VirtualSheet::col_label(self, col) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The hash is deterministic: the same `(row, col)` always yields the + /// same value, so cells and sort keys never change between frames. + #[test] + fn values_are_deterministic() { + let a = VirtualSheet::new(1000, 6); + let b = VirtualSheet::new(1000, 6); + for row in [0u32, 1, 42, 999] { + for col in 0..6u32 { + assert_eq!(a.value_text(row, col), b.value_text(row, col)); + assert_eq!(a.value_num(row, col), b.value_num(row, col)); + } + } + } + + /// Column 0 is the row index: `#` renders the row number and sorts by + /// it. + #[test] + fn column_zero_is_the_row_index() { + let s = VirtualSheet::new(10, 6); + assert_eq!(s.value_text(7, 0), "7"); + assert_eq!(s.value_num(7, 0), 7.0); + } + + /// The sort key for a column orders the rendered text: sorting by the + /// key sorts what the user sees. + #[test] + fn sort_key_matches_the_rendered_balance_text() { + let s = VirtualSheet::new(1000, 6); + for row in 0..100u32 { + let text = s.value_text(row, 3); + let num = s.value_num(row, 3); + // Balance text is the numeric value with two decimals. + assert_eq!(text, format!("{:.2}", num), "row {row} balance"); + } + } + + /// After an ascending sort, the key column is non-decreasing along the + /// permutation, and the permutation visits every row exactly once. + #[test] + fn ascending_sort_orders_the_key_column() { + let mut s = VirtualSheet::new(10_000, 6); + s.sort_rows(true, 3); + let perm = s.perm.as_ref().unwrap(); + assert_eq!(perm.len(), 10_000); + + let mut seen = vec![false; 10_000]; + let mut prev = f64::NEG_INFINITY; + for &data_row in perm.iter() { + seen[data_row as usize] = true; + let v = s.value_num(data_row, 3); + assert!(v >= prev, "balance {v} below previous {prev}"); + prev = v; + } + assert!(seen.iter().all(|&b| b), "permutation must be complete"); + } + + /// Descending sorts the same column the other way. + #[test] + fn descending_sort_reverses_the_order() { + let mut s = VirtualSheet::new(10_000, 6); + s.sort_rows(false, 3); + let perm = s.perm.as_ref().unwrap(); + let mut prev = f64::INFINITY; + for &data_row in perm.iter() { + let v = s.value_num(data_row, 3); + assert!(v <= prev); + prev = v; + } + } + + /// Sorting by column 0 is sorting by the row index (ascending is the + /// identity order). + #[test] + fn sorting_by_column_zero_is_by_row_index() { + let mut s = VirtualSheet::new(1_000, 6); + s.sort_rows(true, 0); + let perm = s.perm.as_ref().unwrap(); + assert_eq!(perm[0], 0); + assert_eq!(perm[1], 1); + assert_eq!(perm[999], 999); + + s.sort_rows(false, 0); + let perm = s.perm.as_ref().unwrap(); + assert_eq!(perm[0], 999); + assert_eq!(perm[999], 0); + } + + /// Before any sort, view rows map to themselves; after one, `is_sorted` + /// and `sort_state` report it. + #[test] + fn sort_state_and_identity_before_sorting() { + let s = VirtualSheet::new(10, 6); + assert!(!s.is_sorted()); + assert_eq!(s.sort_state(), None); + assert_eq!(s.data_row(7), 7); + + let mut s = VirtualSheet::new(10, 6); + s.sort_rows(true, 4); + assert!(s.is_sorted()); + assert_eq!(s.sort_state(), Some((4, true))); + // `cell_display` now routes through the permutation. + let perm = s.perm.clone().unwrap(); + assert_eq!(s.data_row(0), perm[0]); + + // The "off" state of a header toggle restores identity order. + s.reset_sort(); + assert!(!s.is_sorted()); + assert_eq!(s.sort_state(), None); + assert_eq!(s.data_row(7), 7); + } + + /// Column labels: the six base names, then suffixed repeats. + #[test] + fn column_labels_cycle_the_base_names() { + let s = VirtualSheet::new(10, 100); + assert_eq!(s.col_label(0), "#"); + assert_eq!(s.col_label(1), "Name"); + assert_eq!(s.col_label(5), "Active"); + assert_eq!(s.col_label(6), "#·1"); + assert_eq!(s.col_label(7), "Name·1"); + assert_eq!(s.col_label(13), "Name·2"); + } + + /// The `SpreadsheetData` implementor delegates to the real store: cell + /// display is the formatted value and sorting is the physical sort. + #[test] + fn spreadsheet_data_implements_the_trait() { + let mut sheet = SpreadsheetData::default(); + sheet.set_cell(0, 0, "3"); + sheet.set_cell(1, 0, "1"); + sheet.set_cell(2, 0, "2"); + + assert_eq!(sheet.row_count(), NUM_ROWS); + assert_eq!(sheet.col_count(), NUM_COLS); + assert_eq!(sheet.cell_display(0, 0), "3"); + + GridDataSource::sort_rows(&mut sheet, true, 0); + assert_eq!(sheet.get_raw(0, 0), "1"); + assert_eq!(sheet.get_raw(2, 0), "3"); + + // The default column label is A1 letters. + assert_eq!(sheet.col_label(0), "A"); + assert_eq!(sheet.col_label(25), "Z"); + assert_eq!(sheet.col_label(26), "AA"); + } + + /// A virtual sheet with the default "billion cells" extent reports it. + #[test] + fn default_extent_is_one_billion_cells() { + let s = VirtualSheet::new(VIRTUAL_ROWS, VIRTUAL_COLS); + assert_eq!(s.row_count(), 1_000_000); + assert_eq!(s.col_count(), 1_000); + // A billion cells render from zero stored data. + assert!(!s.value_text(999_999, 999).is_empty()); + } + + /// `VirtualSheet` implements the trait too, so a grid can pull cells + /// through the same `GridDataSource` seam for a real or a virtual + /// source. + #[test] + fn virtual_sheet_implements_the_trait() { + let mut s = VirtualSheet::new(1_000, 6); + assert_eq!(s.row_count(), 1_000); + assert_eq!(s.col_count(), 6); + assert_eq!(GridDataSource::cell_display(&s, 0, 0), "0"); + assert_eq!(GridDataSource::col_label(&s, 7), "Name·1"); + + GridDataSource::sort_rows(&mut s, false, 3); + assert!(s.is_sorted()); + } + + /// Value text for the Balance column is negative for exactly the rows + /// whose numeric key is negative (sign consistency for the red/green + /// tint the grid applies). + #[test] + fn balance_sign_matches_the_text_prefix() { + let s = VirtualSheet::new(10_000, 6); + for row in 0..1_000u32 { + let num = s.value_num(row, 3); + let text = s.value_text(row, 3); + assert_eq!( + text.starts_with('-'), + num < 0.0, + "row {row}: text {text} vs key {num}" + ); + } + } +} diff --git a/crates/apps/spreadsheet/spreadsheet-engine/src/lib.rs b/crates/apps/spreadsheet/spreadsheet-engine/src/lib.rs index 7b9e764..8b9304c 100644 --- a/crates/apps/spreadsheet/spreadsheet-engine/src/lib.rs +++ b/crates/apps/spreadsheet/spreadsheet-engine/src/lib.rs @@ -16,6 +16,7 @@ pub mod autofill; pub mod data; +pub mod data_source; pub mod dates; pub mod formula2; pub mod persistence; diff --git a/crates/apps/spreadsheet/spreadsheet-ui/src/lib.rs b/crates/apps/spreadsheet/spreadsheet-ui/src/lib.rs index 27e9922..0ae5178 100644 --- a/crates/apps/spreadsheet/spreadsheet-ui/src/lib.rs +++ b/crates/apps/spreadsheet/spreadsheet-ui/src/lib.rs @@ -26,6 +26,7 @@ pub mod sort_state; pub mod sparkline; pub mod text_measure; pub mod trend_chart; +pub mod virtual_grid; pub mod workspace; pub mod zoom; @@ -43,5 +44,6 @@ use makepad_widgets::ScriptVm; pub fn script_mod(vm: &mut ScriptVm) { grid::script_mod(vm); trend_chart::script_mod(vm); + virtual_grid::script_mod(vm); workspace::script_mod(vm); } diff --git a/crates/apps/spreadsheet/spreadsheet-ui/src/virtual_grid.rs b/crates/apps/spreadsheet/spreadsheet-ui/src/virtual_grid.rs new file mode 100644 index 0000000..86b7b62 --- /dev/null +++ b/crates/apps/spreadsheet/spreadsheet-ui/src/virtual_grid.rs @@ -0,0 +1,411 @@ +//! `VirtualGrid` widget: a virtualized grid over a procedural source. +//! +//! Renders one billion virtual cells (1,000,000 rows × 1,000 columns) with +//! nothing stored: only the visible cells are drawn, values derive from a +//! hashed `(row, col)`, and a column-header click sorts the full million +//! rows by permuting an index. This is the reference datagrid's "Big Data" +//! tab, built on `VirtualSheet` (engine) + `GridMetrics`/`TextMeasureCache` +//! (measured UI modules). +//! +//! The file carries the `script_mod!` DSL and is excluded from coverage +//! (like `grid.rs` and `trend_chart.rs`); the logic it delegates to lives +//! in the engine's `data_source.rs` and the measured geometry modules. + +use makepad_widgets::*; + +use crate::geometry::GridMetrics; +use crate::text_measure::TextMeasureCache; +use spreadsheet_engine::data_source::{GridDataSource, VirtualSheet, VIRTUAL_COLS, VIRTUAL_ROWS}; + +/// Status strip height, in pixels. +const STATUS_H: f64 = 22.0; + +/// In-progress drag state. +enum VDrag { + None, + Panning { last: DVec2 }, + HeaderPending { col: u32, down: DVec2 }, +} + +#[derive(Script, ScriptHook, Widget)] +pub struct VirtualGrid { + #[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_header_text: DrawText, + #[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 grid_line_color: Vec4f, + #[live] + pub text_color: Vec4f, + #[live] + pub pos_text_color: Vec4f, + #[live] + pub neg_text_color: Vec4f, + #[live(110.0)] + pub col_width: f32, + #[live(24.0)] + pub row_height: f32, + #[live(76.0)] + pub row_header_width: f32, + #[live(28.0)] + pub col_header_height: f32, + #[rust(VirtualSheet::new(VIRTUAL_ROWS, VIRTUAL_COLS))] + sheet: VirtualSheet, + #[rust] + rect: Rect, + #[rust] + scroll_row: f64, + #[rust] + scroll_col: f64, + #[rust(VDrag::None)] + drag: VDrag, + /// `(key column, ascending)`, mirroring `sheet.sort_state()`. + #[rust] + sort_state: Option<(u32, bool)>, + /// "sorted 1,000,000 rows by \"X\" ascending in N ms". + #[rust] + sort_report: String, + #[rust] + measure: TextMeasureCache, + #[rust] + last_touch: Option, +} + +impl VirtualGrid { + fn metrics(&self) -> GridMetrics { + GridMetrics { + origin: (self.rect.pos.x, self.rect.pos.y), + row_header_width: self.row_header_width as f64, + col_header_height: self.col_header_height as f64, + default_col_width: self.col_width as f64, + default_row_height: self.row_height as f64, + col_widths: Vec::new(), + row_heights: Vec::new(), + frozen_cols: 0, + frozen_rows: 0, + scroll_col: self.scroll_col, + scroll_row: self.scroll_row, + num_cols: self.sheet.col_count(), + num_rows: self.sheet.row_count(), + } + } + + fn toggle_sort(&mut self, col: u32) { + let t0 = std::time::Instant::now(); + self.sort_state = match self.sort_state { + Some((c, true)) if c == col => { + self.sheet.sort_rows(false, col); + Some((col, false)) + } + Some((c, false)) if c == col => { + self.sheet.reset_sort(); + None + } + _ => { + self.sheet.sort_rows(true, col); + Some((col, true)) + } + }; + let ms = t0.elapsed().as_millis(); + self.sort_report = match self.sort_state { + Some((c, asc)) => format!( + "sorted {} rows by \"{}\" {} in {} ms", + self.sheet.row_count(), + self.sheet.col_label(c), + if asc { "ascending" } else { "descending" }, + ms + ), + None => "sort cleared — identity order".to_string(), + }; + } + + fn scroll_by(&mut self, cx: &mut Cx, delta: DVec2) { + let cell_w = self.col_width as f64; + let cell_h = self.row_height as f64; + let body_w = (self.rect.size.x - self.row_header_width as f64).max(1.0); + let body_h = (self.rect.size.y - self.col_header_height as f64 - STATUS_H).max(1.0); + let vis_cols = (body_w / cell_w).max(1.0); + let vis_rows = (body_h / cell_h).max(1.0); + let max_scroll_col = (self.sheet.col_count() as f64 - vis_cols).max(0.0); + let max_scroll_row = (self.sheet.row_count() as f64 - vis_rows).max(0.0); + self.scroll_row = (self.scroll_row + delta.y / cell_h).clamp(0.0, max_scroll_row); + self.scroll_col = (self.scroll_col + delta.x / cell_w).clamp(0.0, max_scroll_col); + self.redraw(cx); + } +} + +impl Widget for VirtualGrid { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) { + match event.hits_with_capture_overload(cx, self.draw_bg.area(), true) { + Hit::FingerDown(fe) if fe.is_primary_hit() => { + self.last_touch = Some(fe.abs); + cx.set_key_focus(self.draw_bg.area()); + if fe.abs.y < self.rect.pos.y + self.col_header_height as f64 { + if let Some((col, _)) = self.metrics().col_at_x(fe.abs.x) { + self.drag = VDrag::HeaderPending { col, down: fe.abs }; + } + } else { + self.drag = VDrag::Panning { last: fe.abs }; + } + } + Hit::FingerHoverIn(fe) | Hit::FingerHoverOver(fe) => { + if fe.abs.y < self.rect.pos.y + self.col_header_height as f64 + && fe.abs.x >= self.rect.pos.x + self.row_header_width as f64 + { + cx.set_cursor(MouseCursor::Grab); + } else { + cx.set_cursor(MouseCursor::Default); + } + } + Hit::FingerMove(fe) => match self.drag { + VDrag::Panning { last } => { + let delta = last - fe.abs; + self.scroll_by(cx, delta); + self.drag = VDrag::Panning { last: fe.abs }; + } + VDrag::HeaderPending { col, down } => { + if (fe.abs - down).length() > 8.0 { + // Became a pan rather than a click. + self.drag = VDrag::Panning { last: fe.abs }; + } else { + self.drag = VDrag::HeaderPending { col, down }; + } + } + VDrag::None => { + if let Some(start) = self.last_touch { + if (fe.abs - start).length() > 8.0 { + self.drag = VDrag::Panning { last: fe.abs }; + } + } + } + }, + Hit::FingerUp(_) => { + if let VDrag::HeaderPending { col, .. } = self.drag { + self.toggle_sort(col); + self.redraw(cx); + } + self.drag = VDrag::None; + self.last_touch = None; + } + Hit::FingerScroll(fs) => { + // A wheel tick scrolls rows vertically or columns + // horizontally, matching the grid's own wheel handling. + if fs.scroll.y.abs() > f64::EPSILON { + self.scroll_by(cx, dvec2(0.0, fs.scroll.y)); + } else { + self.scroll_by(cx, dvec2(fs.scroll.x, 0.0)); + } + } + _ => {} + } + } + + fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep { + self.rect = cx.walk_turtle(walk); + self.draw_bg.draw_abs(cx, self.rect); + + let m = self.metrics(); + let cell_w = self.col_width as f64; + let cell_h = self.row_height as f64; + let header_h = self.col_header_height as f64; + let row_hdr_w = self.row_header_width as f64; + + let body_top = self.rect.pos.y + header_h; + let body_left = self.rect.pos.x + row_hdr_w; + let body_w = (self.rect.size.x - row_hdr_w).max(1.0); + let body_h = (self.rect.size.y - header_h - STATUS_H).max(1.0); + + let first_col = m.first_visible_col(); + let first_row = m.first_visible_row(); + let vis_cols = (body_w / cell_w).ceil() as u32 + 1; + let vis_rows = (body_h / cell_h).ceil() as u32 + 1; + let last_col = (first_col + vis_cols).min(m.num_cols); + let last_row = (first_row + vis_rows).min(m.num_rows); + let sub_col_px = m.scroll_col.fract() * cell_w; + let sub_row_px = m.scroll_row.fract() * cell_h; + + // Cells, zebra-striped, only the visible range. + for row in first_row..last_row { + let y = body_top + (row - first_row) as f64 * cell_h - sub_row_px; + for col in first_col..last_col { + let x = body_left + (col - first_col) as f64 * cell_w - sub_col_px; + let cell_rect = Rect { + pos: dvec2(x, y), + size: dvec2(cell_w, cell_h), + }; + self.draw_cell_bg.color = if row % 2 == 1 { + self.cell_alt_bg_color + } else { + self.cell_bg_color + }; + self.draw_cell_bg.draw_abs(cx, cell_rect); + + let text = self.sheet.cell_display(row, col); + if !text.is_empty() { + let kind = col % 6; + self.draw_text.color = if kind == 3 { + if text.starts_with('-') { + self.neg_text_color + } else { + self.pos_text_color + } + } else if kind == 0 { + self.header_text_color + } else { + self.text_color + }; + let w = self.measure.measure(&text, false); + let tx = match kind { + 0 | 3 | 4 => x + cell_w - 8.0 - w, + _ => x + 6.0, + }; + let ty = y + (cell_h - 14.0) * 0.5; + self.draw_text.draw_abs(cx, dvec2(tx, ty), &text); + } + } + } + + // Gridlines: a right edge per column and a bottom edge per row in + // the visible range. + self.draw_grid_line.color = self.grid_line_color; + for col in first_col..=last_col { + let x = body_left + (col - first_col) as f64 * cell_w - sub_col_px; + self.draw_grid_line.draw_abs( + cx, + Rect { + pos: dvec2(x + cell_w - 1.0, body_top), + size: dvec2(1.0, body_h), + }, + ); + } + for row in first_row..=last_row { + let y = body_top + (row - first_row) as f64 * cell_h - sub_row_px; + self.draw_grid_line.draw_abs( + cx, + Rect { + pos: dvec2(body_left, y + cell_h - 1.0), + size: dvec2(body_w, 1.0), + }, + ); + } + + // Column headers, with the sort glyph on the active column. + self.draw_header_bg.color = self.header_bg_color; + self.draw_header_bg.draw_abs( + cx, + Rect { + pos: self.rect.pos, + size: dvec2(self.rect.size.x, header_h), + }, + ); + self.draw_header_text.color = self.header_text_color; + for col in first_col..last_col { + let x = body_left + (col - first_col) as f64 * cell_w - sub_col_px; + let mut label = self.sheet.col_label(col); + if let Some((sc, asc)) = self.sort_state { + if sc == col { + label.push_str(if asc { " ▲" } else { " ▼" }); + } + } + self.draw_header_text.draw_abs( + cx, + dvec2(x + 6.0, self.rect.pos.y + (header_h - 14.0) * 0.5), + &label, + ); + } + + // Row headers show the data-row number (1-based), so the + // permutation is visible after a sort. + self.draw_header_bg.draw_abs( + cx, + Rect { + pos: dvec2(self.rect.pos.x, body_top), + size: dvec2(row_hdr_w, body_h), + }, + ); + for row in first_row..last_row { + let y = body_top + (row - first_row) as f64 * cell_h - sub_row_px; + let data = self.sheet.data_row(row); + let label = format!("{}", data + 1); + let tx = self.rect.pos.x + row_hdr_w - 8.0 - label.len() as f64 * 6.0; + self.draw_header_text + .draw_abs(cx, dvec2(tx, y + (cell_h - 14.0) * 0.5), &label); + } + + // Status strip: extents, visible-cell count, and the sort report. + let drawn_cols = last_col - first_col; + let drawn_rows = last_row - first_row; + let status = format!( + "{} rows × {} columns = {} virtual cells · drawing {}×{} = {} cells{}", + self.sheet.row_count(), + self.sheet.col_count(), + self.sheet.row_count() as u64 * self.sheet.col_count() as u64, + drawn_rows, + drawn_cols, + drawn_rows * drawn_cols, + if self.sort_report.is_empty() { + String::new() + } else { + format!(" · {}", self.sort_report) + }, + ); + self.draw_header_text.color = self.header_text_color; + self.draw_header_text.draw_abs( + cx, + dvec2( + self.rect.pos.x + 10.0, + self.rect.pos.y + self.rect.size.y - STATUS_H + 4.0, + ), + &status, + ); + + DrawStep::done() + } +} + +// 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.VirtualGrid = #(VirtualGrid::register_widget(vm)) { + width: Fill, height: Fill + col_width: 110.0, row_height: 24.0, row_header_width: 76.0, col_header_height: 28.0 + header_bg_color: #x242438, header_text_color: #x8a8aa5 + cell_bg_color: #x1a1a2e, cell_alt_bg_color: #x1e1e30 + grid_line_color: #x2a2a3e + text_color: #xd8d8e8, pos_text_color: #x81c995, neg_text_color: #xef6a6a + + 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_header_text +: { draw_depth: 0.3 color: #x8a8aa5 text_style: theme.font_bold } + } +} diff --git a/crates/apps/spreadsheet/spreadsheet-ui/src/workspace.rs b/crates/apps/spreadsheet/spreadsheet-ui/src/workspace.rs index 966ca5c..1058bfa 100644 --- a/crates/apps/spreadsheet/spreadsheet-ui/src/workspace.rs +++ b/crates/apps/spreadsheet/spreadsheet-ui/src/workspace.rs @@ -81,6 +81,10 @@ pub struct SpreadsheetWorkspace { /// 0.25s ticker driving the market. #[rust] timer: Option, + /// Whether the "Big Data" virtual grid is shown instead of the + /// spreadsheet grid. + #[rust] + big_mode: bool, } impl Widget for SpreadsheetWorkspace { @@ -751,6 +755,19 @@ impl WidgetMatchEvent for SpreadsheetWorkspace { } } + // Big Data view: swap the spreadsheet grid for the virtual grid. + if self.button(cx, ids!(bigdata_btn)).clicked(actions) { + self.commit_formula_bar_if_dirty(cx); + self.big_mode = !self.big_mode; + if let Some(mut v) = self.widget(cx, ids!(grid_view)).borrow_mut::() { + v.set_visible(cx, !self.big_mode); + } + if let Some(mut v) = self.widget(cx, ids!(big_view)).borrow_mut::() { + v.set_visible(cx, self.big_mode); + } + self.view.redraw(cx); + } + if self.button(cx, ids!(undo_btn)).clicked(actions) { self.commit_formula_bar_if_dirty(cx); if self.model.borrow_mut().workbook_mut().undo() { @@ -889,6 +906,8 @@ script_mod! { zoom_out_btn := Button { text: "-", width: 28.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xd8d8e8, text_style +: { font_size: 12.0 } } } zoom_reset_btn := Button { text: "100%", width: 44.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xd8d8e8, text_style +: { font_size: 11.0 } } } zoom_in_btn := Button { text: "+", width: 28.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #xd8d8e8, text_style +: { font_size: 12.0 } } } + separator6 := View { width: 1.0, height: 26.0, draw_bg +: { color: #x3a3a4e } } + bigdata_btn := Button { text: "BigData", width: 60.0, draw_bg +: { color: #x313244 }, draw_text +: { color: #x4fc3f7, text_style +: { font_size: 11.0 } } } spacer := View { width: Fill } status_label := Label { text: "Ready | Workbook Save & Undo Stack Active", draw_text +: { color: #8a8aa5, text_style +: { font_size: 10.5 } } } @@ -913,8 +932,16 @@ script_mod! { } grid_container := View { - width: Fill, height: Fill - grid := mod.widgets.SpreadsheetGrid { width: Fill, height: Fill } + width: Fill, height: Fill, flow: Overlay + + grid_view := View { + width: Fill, height: Fill + grid := mod.widgets.SpreadsheetGrid { width: Fill, height: Fill } + } + big_view := View { + width: Fill, height: Fill, visible: false + big_grid := mod.widgets.VirtualGrid { width: Fill, height: Fill } + } } // Live chart panel: line + candlesticks for the selected row. diff --git a/tools/test-spreadsheet-coverage.sh b/tools/test-spreadsheet-coverage.sh index 53561dc..8c3d05a 100755 --- a/tools/test-spreadsheet-coverage.sh +++ b/tools/test-spreadsheet-coverage.sh @@ -126,7 +126,7 @@ IGNORE_COMMON='(/cargo/registry|/cargo/git|/rustc/)' # grid.rs, ui.rs and workspace.rs are the three files carrying `script_mod!`. # Their *testable* logic belongs in a measured module — see geometry.rs — # rather than behind this exclusion. -IGNORE_UI="$IGNORE_COMMON"'|spreadsheet-ui/src/(grid|ui|workspace|trend_chart)\.rs|spreadsheet-ui/src/bin/' +IGNORE_UI="$IGNORE_COMMON"'|spreadsheet-ui/src/(grid|ui|workspace|trend_chart|virtual_grid)\.rs|spreadsheet-ui/src/bin/' # Collect every instrumented test binary as `-object` arguments. #