Browse / github.com/storytold/gridcraft / patch
fix(ui): preview values during fill-handle drag (#2598)
[UI Parity] Drag down should show value as tooltip Create a column of values (1, 2, 3). Select the values. Drag down from the right hand corner. More values should appear as a tooltip (4, 5, 6...). At present does not show any new values until you let go.
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- bryanmenezes-qmc (imported from pull request #2661, open)
- Base
63038650549064d7238c64a7f1101a1de87a12db- Size
- +250 −1 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
6d55175e1773dcfbff0d6050
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/src/grid.rs crates/ui-egui/src/grid/fill_preview.rs crates/ui-egui/src/grid/selection.rs crates/ui-egui/tests/it/fill_options_button.rs
@@ -18,6 +18,7 @@ use crate::theme::{self, Tokens}; mod cell_paint; mod cursor; mod editor;+mod fill_preview; mod geo; mod interact; mod keyboard;new file mode 100644@@ -0,0 +1,160 @@+use super::*;++/// Maximum source length to scan on each drag frame for a fill-handle preview.+const MAX_FILL_PREVIEW_SOURCE_CELLS: u64 = 1_000;++/// Returns up to five predicted numeric fill values without changing the workbook.+///+/// Preview modes follow the engine's fill operation, including Ctrl/Cmd alternation and source+/// number formats. Oversized selections, formulas and calendar-dependent series omit the tooltip.+pub(super) fn fill_preview_text(wb: &Workbook, sh: &Sheet, src: RangeRef, target: RangeRef, alternate: bool) -> Option<String> {+ // A single-cell source has no intrinsic direction; infer it from the expanded target.+ let vertical = if src.height() > 1 {+ true+ } else if src.width() > 1 {+ false+ } else {+ target.height() > 1+ };+ if (vertical && src.width() != 1) || (!vertical && src.height() != 1) {+ return None;+ }+ let source_len = if vertical { src.height() } else { src.width() };+ if u64::from(source_len) > MAX_FILL_PREVIEW_SOURCE_CELLS {+ // This runs on every drag frame; avoid allocating and scanning huge selections for a tooltip.+ return None;+ }+ let source: Vec<CellRef> = if vertical {+ (src.start.row..=src.end.row).map(|row| CellRef::new(row, src.start.col)).collect()+ } else {+ (src.start.col..=src.end.col).map(|col| CellRef::new(src.start.row, col)).collect()+ };+ let numbers: Vec<f64> = source+ .iter()+ .map(|cell| {+ let stored = sh.cell(*cell)?;+ if stored.formula.is_some() || !stored.value.is_number() {+ return None;+ }+ stored.value.as_f64()+ })+ .collect::<Option<_>>()?;+ let style_cell = *source.first()?;+ let format = wb.styles.get(sh.style_id(style_cell)).num_fmt.as_str();+ let temporal = matches!(+ gridcraft_engine::numfmt::NumberFormat::parse(format).kind(),+ gridcraft_engine::numfmt::FormatKind::Date | gridcraft_engine::numfmt::FormatKind::Time+ );+ if !alternate && temporal {+ // The fill engine applies calendar/time-specific rules. Do not show a numeric guess that+ // would disagree with the values committed when the drag ends.+ return None;+ }+ let (forward, steps) = if vertical {+ let forward = target.end.row > src.end.row;+ let steps = if forward { target.end.row - src.end.row } else { src.start.row.saturating_sub(target.start.row) };+ (forward, steps)+ } else {+ let forward = target.end.col > src.end.col;+ let steps = if forward { target.end.col - src.end.col } else { src.start.col.saturating_sub(target.start.col) };+ (forward, steps)+ };+ if steps == 0 {+ return None;+ }+ // Match the engine's linear-series least-squares step, including flat numeric sequences.+ let (slope, fitted_first, fitted_last) = if numbers.len() == 1 {+ (0.0, numbers[0], numbers[0]) // a lone ordinary number is copied by the default fill operation+ } else {+ let n = numbers.len() as f64;+ let mean_x = (n - 1.0) / 2.0;+ let mean_y = numbers.iter().sum::<f64>() / n;+ let (numerator, denominator) = numbers+ .iter()+ .enumerate()+ .fold((0.0, 0.0), |(num, den), (i, y)| (num + (i as f64 - mean_x) * (y - mean_y), den + (i as f64 - mean_x).powi(2)));+ let slope = if denominator == 0.0 { 0.0 } else { numerator / denominator };+ let fitted_first = mean_y - slope * mean_x;+ let fitted_last = mean_y + slope * (n - 1.0 - mean_x);+ (slope, fitted_first, fitted_last)+ };+ let base = if forward { fitted_last } else { fitted_first };+ let preview = (1..=steps.min(5))+ .map(|step| {+ let sign = if forward { 1.0 } else { -1.0 };+ let source_index = if forward { (step as usize - 1) % source.len() } else { source.len() - 1 - ((step as usize - 1) % source.len()) };+ let raw = if alternate && numbers.len() == 1 && !temporal {+ // Ctrl/Cmd swaps the normal single-number copy behavior for a +1/-1 series.+ numbers[0] + f64::from(step) * sign+ } else if alternate {+ // Multi-cell sequences are copied in alternate mode rather than extrapolated.+ numbers[source_index]+ } else {+ base + slope * f64::from(step) * sign+ };+ let mag = if raw == 0.0 || !raw.is_finite() { 0 } else { raw.abs().log10().floor() as i32 };+ let precision = 10f64.powi(14 - mag);+ let rounded = if !precision.is_finite() || precision == 0.0 { raw } else { (raw * precision).round() / precision };+ let value = Value::number(rounded);+ let target_format = wb.styles.get(sh.style_id(source[source_index])).num_fmt.as_str();+ gridcraft_engine::display::value_text_as(wb, &value, target_format)+ })+ .collect::<Vec<_>>();+ Some(preview.join("\n"))+}++#[cfg(test)]+mod fill_preview_tests {+ use super::*;++ fn preview(values: &[f64], target_end: u32, alternate: bool) -> Option<String> {+ let wb = Workbook::default();+ let mut sh = Sheet::new("Sheet1");+ for (row, value) in values.iter().enumerate() {+ sh.set_value(CellRef::new(row as u32, 0), Value::number(*value));+ }+ fill_preview_text(+ &wb,+ &sh,+ RangeRef::new(CellRef::new(0, 0), CellRef::new(values.len() as u32 - 1, 0)),+ RangeRef::new(CellRef::new(0, 0), CellRef::new(target_end, 0)),+ alternate,+ )+ }++ #[test]+ fn alternate_preview_copies_multi_cell_sequences() {+ assert_eq!(preview(&[1.0, 2.0, 3.0], 5, true).as_deref(), Some("1\n2\n3"));+ }++ #[test]+ fn alternate_preview_counts_up_from_a_single_number() {+ assert_eq!(preview(&[1.0], 3, true).as_deref(), Some("2\n3\n4"));+ assert_eq!(preview(&[1.0], 3, false).as_deref(), Some("1\n1\n1"));+ }++ #[test]+ fn alternate_preview_copies_a_single_temporal_value() {+ let mut wb = Workbook::default();+ let mut sh = Sheet::new("Sheet1");+ let cell = CellRef::new(0, 0);+ let value = 45_292.0;+ sh.set_value(cell, Value::number(value));+ let style = wb.styles.intern(Style { num_fmt: gridcraft_engine::model::NumFmt::new("yyyy-mm-dd"), ..Style::default() });+ sh.set_style(cell, style);++ let expected = gridcraft_engine::display::value_text_as(&wb, &Value::number(value), "yyyy-mm-dd");+ assert_eq!(+ fill_preview_text(&wb, &sh, RangeRef::new(cell, cell), RangeRef::new(cell, CellRef::new(1, 0)), true).as_deref(),+ Some(expected.as_str())+ );+ }++ #[test]+ fn oversized_source_does_not_create_a_preview() {+ let wb = Workbook::default();+ let sh = Sheet::new("Sheet1");+ let whole_column = RangeRef::cols(0, 0);+ assert!(fill_preview_text(&wb, &sh, whole_column, whole_column, false).is_none());+ }+}@@ -1,3 +1,4 @@+use super::fill_preview::fill_preview_text; use super::geo::Geo; #[cfg(test)] use super::interact::{hidden_col_edge, hidden_row_edge};@@ -145,6 +146,19 @@ pub(crate) fn paint_selection( 4.0, 3.0, ));+ if let crate::grid::Drag::Fill { menu: false, .. } = &app.grid.drag+ && let Some(pointer) = p.ctx().pointer_hover_pos()+ && p.clip_rect().contains(pointer)+ && let Some(document) = app.session.active()+ && let Some(preview) = fill_preview_text(&document.wb, sh, sel.current(), *tr, p.ctx().input(|i| i.modifiers.command))+ {+ egui::Area::new(egui::Id::unique("fill_preview_tooltip")).order(egui::Order::Tooltip).fixed_pos(pointer + vec2(14.0, 18.0)).show(+ p.ctx(),+ |ui| {+ egui::Frame::popup(ui.style()).show(ui, |ui| ui.label(preview));+ },+ );+ } } if let crate::grid::Drag::Move { to: Some(to), .. } = &app.grid.drag { let r = sel.current();@@ -3,7 +3,7 @@ //! Days, Fill Weekdays, Fill Months or Fill Years. use egui::{Event, Modifiers, PointerButton, Pos2, vec2};-use egui_kittest::kittest::Queryable;+use egui_kittest::kittest::{NodeT as _, Queryable}; use gridcraft_engine::Session; use gridcraft_engine::core::{CellRef, Value}; use gridcraft_ui_egui::{SheetApp, grid::Geo};@@ -159,6 +159,80 @@ fn a_number_drag_offers_no_date_fills() { assert!(h.query_by_label("Fill Days").is_none()); } +#[test]+/// The preview follows the source direction and agrees with the values committed on release.+fn fill_handle_shows_numeric_values_while_dragging_without_committing_them() {+ let mut h = harness(|s| {+ s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [3]]})).unwrap();+ s.execute("selection.set", json!({"range": "B1:B3"})).unwrap();+ });+ let (start, end) = (corner(&h, "B3"), center(&h, "B6"));+ press(&mut h, start, PointerButton::Primary);+ move_to(&mut h, start, end);++ assert_eq!(values(&h, &["B4", "B5", "B6"]), [Value::Empty, Value::Empty, Value::Empty], "previewing does not mutate the sheet");+ let preview = h.get_by_label("4\n5\n6").accesskit_node().value().unwrap();++ release(&mut h, end, PointerButton::Primary);+ assert_eq!(values(&h, &["B4", "B5", "B6"]), [n(4.0), n(5.0), n(6.0)]);+ let sh = h.state().session.active().unwrap().wb.active().unwrap();+ let committed = ["B4", "B5", "B6"]+ .map(|cell| gridcraft_engine::display::value_text(&h.state().session.active().unwrap().wb, sh, CellRef::parse(cell).unwrap()))+ .join("\n");+ assert_eq!(preview, committed, "the displayed preview matches the fill result");+}++#[test]+/// A horizontal fill from one numeric cell uses the target's direction to calculate the preview.+fn one_cell_horizontal_fill_shows_a_preview() {+ let mut h = harness(|s| {+ s.execute("cell.set", json!({"cell": "C1", "input": "5"})).unwrap();+ s.execute("selection.set", json!({"cell": "C1"})).unwrap();+ });+ let (start, end) = (corner(&h, "C1"), center(&h, "E1"));+ press(&mut h, start, PointerButton::Primary);+ move_to(&mut h, start, end);+ assert!(h.query_by_label("5\n5").is_some(), "the tooltip follows the horizontal target range");+ release(&mut h, end, PointerButton::Primary);+ assert_eq!(values(&h, &["D1", "E1"]), [n(5.0), n(5.0)]);+}++#[test]+/// Extrapolated numeric sequences use the same fitted endpoint as the fill engine.+fn irregular_series_preview_matches_the_committed_values() {+ let mut h = harness(|s| {+ s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [4]]})).unwrap();+ s.execute("home.numberFormat", json!({"range": "B2", "code": "0.00%"})).unwrap();+ s.execute("selection.set", json!({"range": "B1:B3"})).unwrap();+ });+ let (start, end) = (corner(&h, "B3"), center(&h, "B5"));+ press(&mut h, start, PointerButton::Primary);+ move_to(&mut h, start, end);+ let preview = h.get_all_by_label_contains("\n").next().unwrap().accesskit_node().value().unwrap();++ release(&mut h, end, PointerButton::Primary);+ let document = h.state().session.active().unwrap();+ let sheet = document.wb.active().unwrap();+ let committed = ["B4", "B5"].map(|cell| gridcraft_engine::display::value_text(&document.wb, sheet, CellRef::parse(cell).unwrap())).join("\n");+ assert_eq!(preview, committed);++ let mut h = harness(|s| {+ s.execute("range.setValues", json!({"range": "B3", "values": [[1], [2], [4]]})).unwrap();+ s.execute("home.numberFormat", json!({"range": "B4", "code": "0.00%"})).unwrap();+ s.execute("selection.set", json!({"range": "B3:B5"})).unwrap();+ });+ let (start, end) = (corner(&h, "B5"), center(&h, "B1"));+ press(&mut h, start, PointerButton::Primary);+ move_to(&mut h, start, end);+ let preview = h.get_all_by_label_contains("\n").next().unwrap().accesskit_node().value().unwrap();++ release(&mut h, end, PointerButton::Primary);+ let document = h.state().session.active().unwrap();+ let sheet = document.wb.active().unwrap();+ let committed = ["B2", "B1"].map(|cell| gridcraft_engine::display::value_text(&document.wb, sheet, CellRef::parse(cell).unwrap())).join("\n");+ assert_eq!(preview, committed, "backward fills also use each source cell's format");+}+ fn formatted(s: &mut Session) { s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [null], [9]]})).unwrap(); s.execute("home.bold", json!({"range": "B1:B2", "on": true})).unwrap();