Compare commits
2 commits
f27ace8b7f
...
8c1a4ad446
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c1a4ad446 | |||
| f14823a6da |
6 changed files with 858 additions and 3 deletions
414
crates/apps/spreadsheet/spreadsheet-engine/src/data_source.rs
Normal file
414
crates/apps/spreadsheet/spreadsheet-engine/src/data_source.rs
Normal file
|
|
@ -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<u32>` 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<Vec<u32>>,
|
||||
/// 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<u32> = (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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
pub mod autofill;
|
||||
pub mod data;
|
||||
pub mod data_source;
|
||||
pub mod dates;
|
||||
pub mod formula2;
|
||||
pub mod persistence;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
411
crates/apps/spreadsheet/spreadsheet-ui/src/virtual_grid.rs
Normal file
411
crates/apps/spreadsheet/spreadsheet-ui/src/virtual_grid.rs
Normal file
|
|
@ -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<DVec2>,
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -81,6 +81,10 @@ pub struct SpreadsheetWorkspace {
|
|||
/// 0.25s ticker driving the market.
|
||||
#[rust]
|
||||
timer: Option<Timer>,
|
||||
/// 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::<View>() {
|
||||
v.set_visible(cx, !self.big_mode);
|
||||
}
|
||||
if let Some(mut v) = self.widget(cx, ids!(big_view)).borrow_mut::<View>() {
|
||||
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,9 +932,17 @@ script_mod! {
|
|||
}
|
||||
|
||||
grid_container := View {
|
||||
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.
|
||||
chart_pane := View {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
#
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue