diff --git a/contributors/people.toml b/contributors/people.toml index 734bf8190..7b69da3aa 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -29,6 +29,11 @@ sync_github_name = false [people.edurbs] sync_github_name = true +[people.arcanii] +real_name = "Bryan Mark" +display_name = "Arcanii" +sync_github_name = false + [people.vangel76] real_name = "Valentin Klein" sync_github_name = true diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7c9a39e11..3ec3f7225 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -12,8 +12,8 @@ //! - `ui.ribbon {tab}`, `ui.dialog {name}`, `ui.dialog.set {field, value}`, //! `ui.dialog.confirm`, `ui.dialog.cancel`, `ui.message.dismiss` //! - `ui.edit.begin {text?}`, `ui.edit.commit {move?}`, `ui.edit.cancel` -//! - `ui.screenshot {path?}` — PNG of the window (base64 when no path) -//! - `ui.resize {width, height}`, `ui.set {dark?, formulaBar?}` +//! - `ui.screenshot {path?}` — PNG of the window (base64 when no path); animations are settled +//! - `ui.resize {width, height}`, `ui.set {dark?, formulaBar?, ribbonCollapsed?, animations?}` //! - `app.open {path}`, `app.save {path?}`, `app.quit` use std::sync::mpsc::Sender; @@ -50,6 +50,14 @@ pub struct Shots { pending: Vec<(u64, Option, Sender, f64)>, } +impl Shots { + /// A screenshot was asked for and hasn't been delivered yet: animations hold still, so the + /// picture shows the settled state. + pub(crate) fn in_flight(&self) -> bool { + !self.queued.is_empty() || !self.pending.is_empty() + } +} + fn ok(v: Json) -> Outcome { Outcome::Done(json!({"ok": true, "result": v})) } @@ -172,6 +180,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("view.collapseRibbon", "Collapse Ribbon", ""), ("view.darkMode", "Dark Mode", ""), ("view.theme", "Display Theme", ""), + ("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"), ("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"), ("ui.dialog", "Open Dialog", ""), ("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"), @@ -202,6 +211,9 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - if let Some(v) = p.get("ribbonCollapsed").and_then(Json::as_bool) { app.ui.ribbon_collapsed = v; } + if let Some(v) = p.get("animations").and_then(Json::as_bool) { + app.ui.animations = v; + } let mut out = serde_json::to_value(&app.ui).unwrap_or_default(); if let Some(o) = out.as_object_mut() { o.insert("formulaBar".into(), json!(app.session.prefs.formula_bar)); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 307c58662..aac48672c 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -70,6 +70,8 @@ pub struct GridState { pub filter_menu: Option<(u32, Pos2)>, pub context_menu: Option, pub marching_phase: f32, + /// The selection outline on its way to a new selection. + pub glide: crate::selection_glide::SelectionGlide, pub last_click: Option<(CellRef, f64)>, pub hover_cell: Option, pub autosave: bool, @@ -380,9 +382,10 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let si = wb.active_sheet; let Some(sh) = wb.sheet(si) else { return }; let sel = d.selection.clone(); + let place = (d.uid, si); let mut view = app.view(); // Scrolling. - let (scroll_delta, zoom_delta, modifiers) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers)); + let (scroll_delta, zoom_delta, modifiers, now) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.time)); if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) { if zoom_delta != 1.0 { let z = (sh.zoom as f32 * zoom_delta).clamp(10.0, 400.0).round(); @@ -435,6 +438,20 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { *v = view; } app.grid.cells_rect = Some(geo.mirror_rect(geo.cells)); + // The selection outline glides to a new selection. It stays on its cells when Animations is + // off, when egui's animations are (the headless harness), and while a screenshot is on its way. + let outline = app.grid.glide.frame(crate::selection_glide::Frame { + place, + range: sel.current(), + target: geo.range_rect(sh, sel.current()), + view: geo.cells, + now, + frame_nr: ui.ctx().cumulative_frame_nr(), + animate: app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight(), + }); + if app.grid.glide.is_gliding() { + ui.ctx().request_repaint(); + } let painter = ui.painter_at(rect); painter.rect_filled(rect, 0.0, t.grid_bg); // Everything up to the headers is painted in logical (left-to-right) coordinates. @@ -462,7 +479,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } let p = painter.with_clip_rect(*clip); paint_quadrant(&p, &geo, &wb, si, sh, *rows, *cols, &mut cf, &t); - paint_selection(&p, &geo, sh, &sel, &t, app, *rows, *cols); + paint_selection(&p, &geo, sh, &sel, outline, &t, app, *rows, *cols); } // Freeze lines. if geo.fr > 0 { @@ -500,7 +517,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) }; vertical_scrollbar(app, ui, sh, &geo, track_x, max_y, &t); if app.session.clipboard.is_some() { - app.grid.marching_phase = (app.grid.marching_phase + 0.5) % 8.0; + // By the clock, not per frame: a gliding outline repaints more often than the ants step. + app.grid.marching_phase = crate::selection_glide::ants_phase(now); ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); } } @@ -1040,6 +1058,51 @@ fn paint_cf_icon(p: &Painter, r: Rect, set: &str, idx: usize) { } } +/// Where the active cell is on screen: the whole merged cell when it is part of one. +fn active_rect(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection) -> Rect { + match sh.merge_at(sel.active) { + Some(m) => geo.range_rect(sh, m), + None => geo.cell_rect(sh, sel.active), + } +} + +/// The rectangles shaded for a selection: every area except the active cell, which stays clear so +/// it is plain where typing goes. A single selected cell has no fill. `outline` is where the +/// current area is drawn this frame: it glides to a new selection, and its fill with it. +pub fn selection_fill(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect) -> Vec { + selection_fill_where(geo, sh, sel, outline, |_| true) +} + +/// `selection_fill` for the areas `keep` accepts (a frozen pane draws only the areas it shows). +fn selection_fill_where(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect, keep: impl Fn(&RangeRef) -> bool) -> Vec { + if sel.is_single_cell() { + return vec![]; + } + let active = active_rect(geo, sh, sel); + let area = |r: &RangeRef| if *r == sel.current() { outline } else { geo.range_rect(sh, *r) }; + sel.ranges.iter().filter(|r| keep(r)).flat_map(|r| rect_around(area(r), active)).collect() +} + +/// `area` without `hole`: the strips above and below the hole, then what is left beside it. The +/// selection fill is translucent, so the active cell can't be cleared by painting over it; the +/// pieces never overlap for the same reason. Pieces with no size, or whose numbers aren't finite, +/// are dropped. +fn rect_around(area: Rect, hole: Rect) -> impl Iterator { + // `intersect` takes the sane side of a NaN, which would make a broken hole cover the area. + let hole = if hole.is_finite() { area.intersect(hole) } else { Rect::NOTHING }; + let parts = if hole.is_positive() { + [ + Rect::from_min_max(area.min, pos2(area.max.x, hole.min.y)), + Rect::from_min_max(pos2(area.min.x, hole.max.y), area.max), + Rect::from_min_max(pos2(area.min.x, hole.min.y), pos2(hole.min.x, hole.max.y)), + Rect::from_min_max(pos2(hole.max.x, hole.min.y), pos2(area.max.x, hole.max.y)), + ] + } else { + [area, Rect::NOTHING, Rect::NOTHING, Rect::NOTHING] + }; + parts.into_iter().filter(|r| r.is_positive() && r.is_finite()) +} + /// True when `r` covers at least one cell of the pane that shows `rows` x `cols` (inclusive). /// The panes of a frozen sheet share one coordinate system, so a range that lives only in the /// scrolling pane would otherwise be drawn over the frozen pane as well. @@ -1047,36 +1110,27 @@ fn range_in_pane(r: RangeRef, rows: (u32, u32), cols: (u32, u32)) -> bool { r.start.row <= rows.1 && r.end.row >= rows.0 && r.start.col <= cols.1 && r.end.col >= cols.0 } +/// `outline` is where the current area's border is drawn this frame (see `selection_glide`). #[allow(clippy::too_many_arguments)] fn paint_selection( p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, + outline: Rect, t: &Tokens, app: &SheetApp, rows: (u32, u32), cols: (u32, u32), ) { // Fill all areas except the active cell. - for r in &sel.ranges { - if !range_in_pane(*r, rows, cols) { - continue; - } - if r.is_single() && sel.ranges.len() == 1 { - continue; - } - let rect = geo.range_rect(sh, *r); + for rect in selection_fill_where(geo, sh, sel, outline, |r| range_in_pane(*r, rows, cols)) { p.rect_filled(rect, 0.0, t.sel_fill); } let active_range = sh.merge_at(sel.active).unwrap_or_else(|| RangeRef::cell(sel.active)); - let active = geo.range_rect(sh, active_range); + let active = active_rect(geo, sh, sel); let active_here = range_in_pane(active_range, rows, cols); let cur_here = range_in_pane(sel.current(), rows, cols); - if active_here && !(sel.ranges.len() == 1 && sel.current().is_single()) { - p.rect_filled(active.shrink(1.0), 0.0, t.grid_bg.gamma_multiply(0.0)); - } - let cur = geo.range_rect(sh, sel.current()); // Editing a formula: outline its references instead of the selection. if let Some(ed) = &app.editor && ed.is_formula() @@ -1098,14 +1152,14 @@ fn paint_selection( } } if cur_here { - p.rect_stroke(cur.expand(0.5), 0.0, Stroke::new(2.0, t.sel_border), StrokeKind::Middle); + p.rect_stroke(outline.expand(0.5), 0.0, Stroke::new(2.0, t.sel_border), StrokeKind::Middle); } if active_here && sel.ranges.len() > 1 { p.rect_stroke(active, 0.0, Stroke::new(1.0, t.sel_border), StrokeKind::Inside); } // Fill handle (bottom-right of the current area). if app.editor.is_none() && range_in_pane(RangeRef::cell(sel.current().end), rows, cols) { - let h = Rect::from_center_size(cur.right_bottom(), vec2(6.0, 6.0)); + let h = Rect::from_center_size(outline.right_bottom(), vec2(6.0, 6.0)); p.rect_filled(h.expand(1.0), 0.0, Color32::WHITE); p.rect_filled(h, 0.0, t.sel_border); } @@ -2582,6 +2636,67 @@ fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) { p.rect_stroke(pr, 0.0, Stroke::new(3.0, blue), StrokeKind::Outside); } +#[cfg(test)] +mod selection_fill_tests { + use super::*; + + #[test] + fn rect_around_leaves_the_hole_and_never_overlaps() { + let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(400.0, 250.0)); + let check = |hole: Rect, pieces: usize| { + let parts: Vec = rect_around(area, hole).collect(); + assert_eq!(parts.len(), pieces, "{hole:?}: {parts:?}"); + let cut = area.intersect(hole); + let cut_area = if cut.is_positive() { cut.area() } else { 0.0 }; + let shaded: f32 = parts.iter().map(Rect::area).sum(); + assert!((shaded - (area.area() - cut_area)).abs() < 0.5, "everything but the hole is shaded: {hole:?}"); + for (i, a) in parts.iter().enumerate() { + assert!(area.contains_rect(*a), "nothing outside the area: {a:?}"); + assert!(!a.shrink(0.01).intersects(hole), "the hole stays clear: {a:?} vs {hole:?}"); + // The fill is translucent: a part shaded twice would show darker. + assert!(parts.iter().skip(i + 1).all(|b| !a.shrink(0.01).intersects(*b)), "{parts:?}"); + } + }; + let cell = |x: f32, y: f32| Rect::from_min_size(pos2(x, y), vec2(64.0, 16.0)); + // In the middle, in each corner (where a drag began), along an edge. + check(cell(200.0, 100.0), 4); + check(cell(100.0, 50.0), 2); + check(cell(336.0, 50.0), 2); + check(cell(100.0, 234.0), 2); + check(cell(336.0, 234.0), 2); + check(cell(100.0, 100.0), 3); + // A merged cell as wide, or as tall, as the area; one that is all of it, or more. + check(Rect::from_min_max(pos2(100.0, 100.0), pos2(400.0, 132.0)), 2); + check(Rect::from_min_max(pos2(200.0, 50.0), pos2(264.0, 250.0)), 2); + check(area, 0); + check(area.expand(30.0), 0); + // The active cell is in another area, touches this one, or is only partly inside it. + check(cell(500.0, 300.0), 1); + check(cell(400.0, 50.0), 1); + check(cell(380.0, 240.0), 2); + } + + #[test] + fn rect_around_survives_hostile_numbers() { + let sane = Rect::from_min_max(pos2(0.0, 0.0), pos2(64.0, 16.0)); + let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, -1.0, 0.0]; + for a in bad { + for b in bad { + let weird = Rect::from_min_max(pos2(a, b), pos2(b, a)); + for (area, hole) in [(sane, weird), (weird, sane), (weird, weird)] { + for part in rect_around(area, hole) { + assert!(part.is_positive() && part.is_finite(), "{area:?} around {hole:?}: {part:?}"); + } + } + // A hole that makes no sense leaves the whole area shaded. + if !weird.intersects(sane) { + assert_eq!(rect_around(sane, weird).collect::>(), vec![sane]); + } + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 9ebde7eda..edab0950b 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -374,6 +374,11 @@ const JAPANESE: &[(&str, &str)] = &[ ("Unfreeze Panes", "ウィンドウ枠固定の解除"), ("Dark Mode", "ダーク モード"), ("Display theme", "表示テーマ"), + ("Animations", "アニメーション"), + ( + "Glide the selection outline to the cell you move to instead of jumping there", + "選択範囲の枠を、移動先のセルまで滑らかに動かします(瞬時には切り替えません)", + ), ("System", "システム"), ("Light", "ライト"), ("Dark", "ダーク"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 96b760408..da30a439d 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -184,6 +184,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Mřížka", "Headings" => "Záhlaví", "Zoom" => "Lupa", + "Animations" => "Animace", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "Plynule posouvat rámeček výběru na buňku, na kterou přejdete, místo skoku" + } "Insert Copied Cells" => "Vložit zkopírované buňky", "Insert Copied Cells…" => "Vložit zkopírované buňky…", "Insert Cut Cells" => "Vložit vyjmuté buňky", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 1fbf2e3bc..f7cb4fa62 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -184,6 +184,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Gitternetzlinien", "Headings" => "Überschriften", "Zoom" => "Zoom", + "Animations" => "Animationen", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "Den Auswahlrahmen zur angesteuerten Zelle gleiten lassen, statt zu springen" + } "Insert Copied Cells" => "Kopierte Zellen einfügen", "Insert Copied Cells…" => "Kopierte Zellen einfügen…", "Insert Cut Cells" => "Ausgeschnittene Zellen einfügen", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 00fa6b93d..351a4d4b6 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -185,6 +185,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Líneas de cuadrícula", "Headings" => "Encabezados", "Zoom" => "Zoom", + "Animations" => "Animaciones", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "Desliza el contorno de la selección hasta la celda a la que te mueves en lugar de saltar" + } "Insert Copied Cells" => "Insertar celdas copiadas", "Insert Copied Cells…" => "Insertar celdas copiadas…", "Insert Cut Cells" => "Insertar celdas cortadas", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 334a96c41..28f03abb1 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -287,6 +287,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "기타\n함수", "Page Layout|Orientation" => "용지 방향", "Display theme" => "표시 테마", + "Animations" => "애니메이션", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "선택 테두리가 바로 건너뛰지 않고 이동한 셀까지 부드럽게 미끄러집니다" + } "System" => "시스템", "Light" => "밝게", "Dark" => "어둡게", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index a22e95f39..ba27c0528 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -288,6 +288,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "Mais\nFunções", "Page Layout|Orientation" => "Orientação", "Display theme" => "Tema de exibição", + "Animations" => "Animações", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "Desliza o contorno da seleção até a nova célula em vez de saltar" + } "System" => "Sistema", "Light" => "Claro", "Dark" => "Escuro", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 2ec76dad7..c692c4c59 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -287,6 +287,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "Другие\nфункции", "Page Layout|Orientation" => "Ориентация", "Display theme" => "Тема оформления", + "Animations" => "Анимация", + "Glide the selection outline to the cell you move to instead of jumping there" => { + "Рамка выделения плавно скользит к новой ячейке, а не перескакивает" + } "System" => "Системная", "Light" => "Светлая", "Dark" => "Тёмная", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 26ba89e52..74793b8e2 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -287,6 +287,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "更多\n函数", "Page Layout|Orientation" => "纸张方向", "Display theme" => "显示主题", + "Animations" => "动画", + "Glide the selection outline to the cell you move to instead of jumping there" => "选择框平滑滑动到新的单元格,而不是直接跳过去", "System" => "系统", "Light" => "浅色", "Dark" => "深色", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d17439708..a039a3b2a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -24,6 +24,7 @@ pub mod keytips; pub mod panes; pub mod pivot_pane; pub mod ribbon; +pub mod selection_glide; pub mod tabs; pub mod text_box; pub mod theme; @@ -49,6 +50,8 @@ pub struct UiState { pub ribbon_collapsed: bool, pub formula_bar_expanded: bool, pub status_bar: bool, + /// Motion in the UI: the selection outline glides to a new selection (View ▸ Animations). + pub animations: bool, pub recent: Vec, /// Interface language. English by default (so tests and headless renders never depend on the /// host's locale); the desktop app starts from [`i18n::Language::system`] when `ui.json` has @@ -86,6 +89,7 @@ impl Default for UiState { ribbon_collapsed: false, formula_bar_expanded: false, status_bar: true, + animations: true, recent: vec![], language: i18n::Language::En, interface_scale: 100, @@ -366,6 +370,10 @@ impl SheetApp { } Ok(json!({"mode": self.ui.theme_mode()})) } + "view.animations" => { + self.ui.animations = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.animations); + Ok(json!({"on": self.ui.animations})) + } "view.interfaceScale" => { // `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped. let percent = match p.get("percent") { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 42c8a681f..620f690a0 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1783,6 +1783,11 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { } } }); + let mut animations = app.ui.animations; + let hint = lang.tr("Glide the selection outline to the cell you move to instead of jumping there"); + if ui.checkbox(&mut animations, lang.tr("Animations")).on_hover_text(hint).changed() { + act(app, "view.animations", json!({"on": animations})); + } }); sep(ui); // Interface size (issue #230): scales the whole window on top of the desktop's scale. diff --git a/crates/ui-egui/src/selection_glide.rs b/crates/ui-egui/src/selection_glide.rs new file mode 100644 index 000000000..dbd8bfa64 --- /dev/null +++ b/crates/ui-egui/src/selection_glide.rs @@ -0,0 +1,383 @@ +//! The selection outline glides to a new selection instead of jumping there, like Excel's cell +//! cursor: press an arrow key or click a cell and the border slides over in about a tenth of a +//! second; extend the selection and it grows edge by edge. View ▸ Animations turns it off. +//! +//! The behaviour comes from using Excel as a user; the duration and the easing curve are an +//! estimate ([`GLIDE_SECS`]), not yet measured against Excel. +//! +//! What glides and what doesn't: +//! - the selection changed (arrows, Enter, Tab, a click, a drag, Shift+arrows, the Name Box, any +//! command): the outline of the current area, its fill and its fill handle glide from wherever +//! they are drawn right now, so a held arrow key reads as one continuous motion; +//! - the same cells moved on screen (scrolling, zoom, a resized column or window): the outline +//! stays glued to its cells; +//! - another sheet or workbook, an outline that wasn't drawn the frame before, a move that starts +//! or ends off screen: it appears in place; +//! - a control-channel screenshot is on its way, or egui's animations are off (the headless +//! harness turns them off): it appears in place, so scripted pictures never catch it in flight; +//! - Animations is turned off (`view.animations`): it jumps. +//! +//! Only painting is delayed. The selection itself, hit testing (the fill handle, the border you +//! drag) and the cell editor use the real cells at once. +//! +//! The glide is kept as each edge's distance from its target, decaying to zero, so the outline is +//! always "the cells' rectangle plus what is left of the offset", in screen points. Scrolling +//! under a gliding outline therefore carries it along, and an arrow key held at the edge of the +//! window (the sheet scrolls, the cell stays where it is on screen) doesn't make it wobble. + +use egui::{Rect, pos2}; +use gridcraft_engine::core::RangeRef; + +/// How long a glide takes, in seconds. +pub const GLIDE_SECS: f64 = 0.12; +/// How far outside the cell area a glide may start or end (screen points). +const OFFSCREEN_SLACK: f32 = 48.0; + +/// What the grid knows about the selection outline in one frame. +#[derive(Clone, Copy, Debug)] +pub struct Frame { + /// The workbook (its uid) and sheet on screen: the outline never glides between two of them. + pub place: (u64, usize), + /// The current area of the selection. + pub range: RangeRef, + /// Where that area's cells are on screen. + pub target: Rect, + /// The grid's cell area on screen. + pub view: Rect, + /// egui's clock, in seconds. + pub now: f64, + /// egui's frame counter: an outline that skipped a frame doesn't glide from its stale place. + pub frame_nr: u64, + /// Glides may run this frame; otherwise the outline is on its cells. + pub animate: bool, +} + +/// The outline's motion between frames. Time-based, so it looks the same at any frame rate. +#[derive(Clone, Debug)] +pub struct SelectionGlide { + /// What the outline was last drawn for. + last: Option<((u64, usize), RangeRef)>, + /// Where it was drawn (screen points). + drawn: Rect, + /// Each edge's distance from the target when the glide set off: left, top, right, bottom. + offset: [f32; 4], + started: f64, + gliding: bool, + frame_nr: u64, +} + +impl Default for SelectionGlide { + fn default() -> Self { + SelectionGlide { last: None, drawn: Rect::ZERO, offset: [0.0; 4], started: 0.0, gliding: false, frame_nr: 0 } + } +} + +impl SelectionGlide { + /// Advance to this frame; returns where to draw the outline. + pub fn frame(&mut self, f: Frame) -> Rect { + let fresh = f.frame_nr.wrapping_sub(self.frame_nr) <= 1; + self.frame_nr = f.frame_nr; + let key = Some((f.place, f.range)); + if self.last != key { + // A new destination: set off from where the outline is drawn now (mid-glide or at rest). + let same_sheet = self.last.is_some_and(|(place, _)| place == f.place); + self.offset = if same_sheet && fresh { offset_between(self.drawn, f.target, f.view) } else { [0.0; 4] }; + self.started = f.now; + self.last = key; + } + if !f.animate { + self.offset = [0.0; 4]; + } + // Arrived when the time is up (or the clock went backwards). + let progress = (f.now - self.started) / GLIDE_SECS; + self.gliding = self.offset != [0.0; 4] && (0.0..1.0).contains(&progress); + let left = if self.gliding { 1.0 - ease_out(progress as f32) } else { 0.0 }; + let [l, t, r, b] = self.offset.map(|o| o * left); + self.drawn = Rect::from_min_max(pos2(f.target.min.x + l, f.target.min.y + t), pos2(f.target.max.x + r, f.target.max.y + b)); + self.drawn + } + + /// The outline is on its way somewhere: keep repainting. + pub fn is_gliding(&self) -> bool { + self.gliding + } + + /// Where the outline was drawn last frame (screen points). + pub fn drawn(&self) -> Rect { + self.drawn + } +} + +/// Decelerating curve: quick off the mark, soft landing. +fn ease_out(t: f32) -> f32 { + let r = 1.0 - t.clamp(0.0, 1.0); + 1.0 - r * r * r +} + +/// Each edge's distance from `to` back to `from`. No glide (all zero) when either end is off +/// screen or the numbers are not sane. An edge sets off at most one view away, so the far edge of +/// a whole column or row slides in from the border of the window instead of popping in at the end. +fn offset_between(from: Rect, to: Rect, view: Rect) -> [f32; 4] { + let seen = view.expand(OFFSCREEN_SLACK); + if !(seen.intersects(from) && seen.intersects(to)) { + return [0.0; 4]; + } + let limit = |v: f32| if v.is_finite() { v.abs() } else { 0.0 }; + let (lx, ly) = (limit(view.width()), limit(view.height())); + let offset = [ + (from.min.x - to.min.x).clamp(-lx, lx), + (from.min.y - to.min.y).clamp(-ly, ly), + (from.max.x - to.max.x).clamp(-lx, lx), + (from.max.y - to.max.y).clamp(-ly, ly), + ]; + if offset.iter().all(|o| o.is_finite()) { offset } else { [0.0; 4] } +} + +/// The marching ants' dash offset around a copied range, by egui's clock: half a point every +/// 60 ms, wrapping with the 8-point dash pattern. Stepping it once per painted frame is steady +/// only while nothing else repaints; a gliding outline repaints every frame and would make the +/// ants lurch forward. +pub fn ants_phase(now: f64) -> f32 { + let phase = (now / 0.12).rem_euclid(8.0) as f32; + if phase.is_finite() { phase } else { 0.0 } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::vec2; + use gridcraft_engine::core::CellRef; + + const PLACE: (u64, usize) = (1, 0); + const VIEW: Rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(1000.0, 600.0)); + const DT: f64 = 1.0 / 240.0; + + fn cell(row: u32, col: u32) -> RangeRef { + RangeRef::cell(CellRef::new(row, col)) + } + + /// A cell-sized rectangle at a place on screen. + fn at(x: f32, y: f32) -> Rect { + Rect::from_min_size(pos2(x, y), vec2(64.0, 16.0)) + } + + fn near(a: Rect, b: Rect) -> bool { + (a.min - b.min).length() < 1e-3 && (a.max - b.max).length() < 1e-3 + } + + /// Drives a glide the way the grid does: one frame per step, a clock, a frame counter. + struct Rig { + glide: SelectionGlide, + f: Frame, + } + + impl Rig { + fn new(range: RangeRef, target: Rect) -> Self { + let f = Frame { place: PLACE, range, target, view: VIEW, now: 10.0, frame_nr: 100, animate: true }; + let mut rig = Rig { glide: SelectionGlide::default(), f }; + rig.step(0.0); + rig + } + + /// The next frame, `dt` seconds later. + fn step(&mut self, dt: f64) -> Rect { + self.f.now += dt; + self.f.frame_nr += 1; + self.glide.frame(self.f) + } + + /// The selection changes; the next frame shows it. + fn select(&mut self, range: RangeRef, target: Rect) { + self.f.range = range; + self.f.target = target; + } + + /// Steps until the outline rests, returning every rectangle drawn on the way. + fn settle(&mut self, dt: f64) -> Vec { + let mut drawn = vec![self.step(dt)]; + while self.glide.is_gliding() && drawn.len() < 10_000 { + drawn.push(self.step(dt)); + } + drawn + } + } + + #[test] + fn the_first_outline_is_on_its_cells() { + let r = Rig::new(cell(0, 0), at(10.0, 10.0)); + assert_eq!(r.glide.drawn(), at(10.0, 10.0)); + assert!(!r.glide.is_gliding()); + } + + #[test] + fn a_move_glides_and_lands_exactly() { + let (a, b) = (at(0.0, 0.0), at(64.0, 32.0)); + let mut r = Rig::new(cell(0, 0), a); + r.select(cell(2, 1), b); + let drawn = r.settle(DT); + assert_eq!(drawn.first(), Some(&a), "it sets off from where it was"); + assert_eq!(drawn.last(), Some(&b), "and lands exactly on the cells"); + assert!(drawn.len() > 20, "over about {GLIDE_SECS} s, not in one frame: {} frames", drawn.len()); + for w in drawn.windows(2) { + assert!(w[0].min.x <= w[1].min.x && w[0].min.y <= w[1].min.y, "never backwards or past the cells: {w:?}"); + assert!(w[1].min.x <= b.min.x && w[1].min.y <= b.min.y); + assert!((w[1].width() - 64.0).abs() < 1e-3 && (w[1].height() - 16.0).abs() < 1e-3, "it keeps its shape on the way"); + } + // Decelerating: more than half of the way in the first third of the time. + let third = drawn[drawn.len() / 3]; + assert!(third.min.x > 32.0, "{third:?}"); + } + + #[test] + fn the_glide_is_the_same_at_any_frame_rate() { + let at_time = |dt: f64, frames: usize| { + let mut r = Rig::new(cell(0, 0), at(0.0, 0.0)); + r.select(cell(0, 5), at(320.0, 0.0)); + r.step(0.0); + (0..frames).map(|_| r.step(dt)).last().unwrap() + }; + // 60 ms into the glide at 50, 100 and 200 frames a second. + let (slow, mid, fast) = (at_time(0.02, 3), at_time(0.01, 6), at_time(0.005, 12)); + assert!(near(slow, mid) && near(mid, fast), "{slow:?} {mid:?} {fast:?}"); + assert!(slow.min.x > 0.0 && slow.min.x < 320.0); + } + + #[test] + fn extending_the_selection_grows_it_edge_by_edge() { + let one = at(0.0, 0.0); + let two = Rect::from_min_size(pos2(0.0, 0.0), vec2(128.0, 16.0)); + let mut r = Rig::new(cell(0, 0), one); + r.select(RangeRef::new(CellRef::new(0, 0), CellRef::new(0, 1)), two); + r.step(0.0); + let mid = r.step(GLIDE_SECS / 2.0); + assert_eq!(mid.min, one.min, "the anchored corner stays put"); + assert!(mid.max.x > one.max.x && mid.max.x < two.max.x && mid.max.y == two.max.y, "{mid:?}"); + } + + #[test] + fn a_new_move_takes_over_from_where_the_outline_is() { + let mut r = Rig::new(cell(0, 0), at(0.0, 0.0)); + r.select(cell(0, 1), at(64.0, 0.0)); + r.step(0.0); + let mid = r.step(GLIDE_SECS / 3.0); + assert!(mid.min.x > 0.0 && mid.min.x < 64.0); + // The next arrow key lands before the first glide is over. + r.select(cell(0, 2), at(128.0, 0.0)); + let next = r.step(0.0); + assert!(near(next, mid), "no jump when the destination changes: {next:?} vs {mid:?}"); + assert_eq!(r.settle(DT).last(), Some(&at(128.0, 0.0))); + } + + #[test] + fn the_same_cells_moving_on_screen_never_glide() { + let mut r = Rig::new(cell(4, 0), at(0.0, 64.0)); + // Scrolled, zoomed, a column resized: same range, another rectangle. + r.f.target = at(0.0, 16.0); + assert_eq!(r.step(DT), at(0.0, 16.0)); + r.f.target = Rect::from_min_size(pos2(8.0, 24.0), vec2(96.0, 24.0)); + assert_eq!(r.step(DT), r.f.target); + assert!(!r.glide.is_gliding()); + // Scrolling under a gliding outline carries it along with the sheet. + r.select(cell(5, 0), at(0.0, 200.0)); + r.step(0.0); + let before = r.step(GLIDE_SECS / 4.0); + r.f.target = at(0.0, 300.0); + let after = r.step(0.0); + assert!(near(after, before.translate(vec2(0.0, 100.0))), "{after:?} vs {before:?}"); + // An arrow key held at the window's edge: the sheet scrolls and the next cell is drawn + // where the last one was. Nothing to glide. + let mut r = Rig::new(cell(30, 0), at(0.0, 580.0)); + r.select(cell(31, 0), at(0.0, 580.0)); + assert_eq!(r.step(DT), at(0.0, 580.0)); + assert!(!r.glide.is_gliding()); + } + + #[test] + fn another_sheet_a_stale_or_off_screen_outline_appears_in_place() { + // Another sheet, another workbook. + let mut r = Rig::new(cell(0, 0), at(0.0, 0.0)); + r.f.place = (1, 1); + r.select(cell(9, 9), at(500.0, 300.0)); + assert_eq!(r.step(DT), at(500.0, 300.0)); + r.f.place = (2, 1); + r.select(cell(0, 0), at(0.0, 0.0)); + assert_eq!(r.step(DT), at(0.0, 0.0)); + assert!(!r.glide.is_gliding()); + // The outline wasn't drawn for a few frames: its old place means nothing now. + r.f.frame_nr += 5; + r.select(cell(0, 3), at(192.0, 0.0)); + assert_eq!(r.step(DT), at(192.0, 0.0)); + // It was scrolled out of sight, and the move brings the view back. + let mut r = Rig::new(cell(500, 0), at(0.0, -8000.0)); + r.select(cell(501, 0), at(0.0, 300.0)); + assert_eq!(r.step(DT), at(0.0, 300.0)); + // The move goes somewhere off screen. + r.select(cell(900, 0), at(0.0, 9000.0)); + assert_eq!(r.step(DT), at(0.0, 9000.0)); + assert!(!r.glide.is_gliding()); + } + + #[test] + fn far_edges_set_off_no_further_than_one_view_away() { + // A whole column: its bottom edge is millions of points below the window. + let column = Rect::from_min_max(pos2(0.0, 0.0), pos2(64.0, 16_000_000.0)); + let mut r = Rig::new(RangeRef::cols(0, 0), column); + r.select(cell(3, 0), at(0.0, 48.0)); + let first = r.step(DT); + assert_eq!(first.min, column.min); + assert_eq!(first.max.y, 64.0 + VIEW.height(), "from the window's height below, not from row 1,048,576"); + assert_eq!(r.settle(DT).last(), Some(&at(0.0, 48.0))); + // And back: the far edge leaves the same way. + r.select(RangeRef::cols(0, 0), column); + assert_eq!(r.step(DT).max.y, 16_000_000.0 - VIEW.height()); + assert_eq!(r.settle(DT).last(), Some(&column)); + } + + #[test] + fn turned_off_or_hostile_numbers_never_glide_or_panic() { + // No animations this frame: on the cells, and a glide under way stops. + let mut r = Rig::new(cell(0, 0), at(0.0, 0.0)); + r.f.animate = false; + r.select(cell(0, 1), at(64.0, 0.0)); + assert_eq!(r.step(DT), at(64.0, 0.0)); + r.f.animate = true; + r.select(cell(0, 2), at(128.0, 0.0)); + r.step(0.0); + r.step(DT); + assert!(r.glide.is_gliding()); + r.f.animate = false; + assert_eq!(r.step(DT), at(128.0, 0.0)); + assert!(!r.glide.is_gliding()); + // Geometry and clocks that make no sense: whatever is drawn meanwhile, nothing panics, + // and a sane selection afterwards is drawn on its cells once its glide is over. + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, -1.0, 0.0] { + for now in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0e300, 0.0] { + let mut r = Rig::new(cell(0, 0), at(0.0, 0.0)); + r.f.view = Rect::from_min_max(pos2(v, 0.0), pos2(1000.0, v)); + r.select(cell(0, 1), Rect::from_min_max(pos2(v, v), pos2(64.0, 16.0))); + r.step(DT); + r.f.now = now; + r.select(cell(0, 2), Rect::from_min_size(pos2(64.0, 0.0), egui::Vec2::splat(v))); + r.step(DT); + r.step(DT); + r.f.view = VIEW; + r.f.now = 50.0; + r.select(cell(0, 3), at(192.0, 0.0)); + r.step(DT); + assert_eq!(r.step(1.0), at(192.0, 0.0), "view {v}, clock {now}"); + } + } + assert_eq!((ease_out(-3.0), ease_out(0.0), ease_out(1.0), ease_out(7.0)), (0.0, 0.0, 1.0, 1.0)); + } + + #[test] + fn the_ants_march_by_the_clock() { + assert_eq!(ants_phase(0.0), 0.0); + assert!((ants_phase(0.06) - 0.5).abs() < 1e-4, "half a point every 60 ms"); + assert!((ants_phase(0.96 + 0.06) - 0.5).abs() < 1e-4, "wrapping with the dash pattern"); + for now in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -12.3, 1.0e300, 7.77] { + let p = ants_phase(now); + assert!((0.0..=8.0).contains(&p), "{now}: {p}"); + } + } +} diff --git a/crates/ui-egui/tests/selection_fill.rs b/crates/ui-egui/tests/selection_fill.rs new file mode 100644 index 000000000..446ccdd51 --- /dev/null +++ b/crates/ui-egui/tests/selection_fill.rs @@ -0,0 +1,99 @@ +//! The selection's fill leaves the active cell clear, so it is plain where typing goes. + +use egui::Rect; +use gridcraft_engine::Session; +use gridcraft_engine::core::RangeRef; +use gridcraft_ui_egui::grid::{Geo, selection_fill}; +use serde_json::json; + +fn session() -> Session { + let mut session = Session::new(); + session.new_workbook(); + session +} + +fn geo(session: &Session) -> Geo { + let sheet = session.active().unwrap().wb.active().unwrap(); + Geo::new(sheet, Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1200.0, 800.0)), egui::Vec2::ZERO) +} + +/// Where a cell or range is on screen. +fn cells(session: &Session, a1: &str) -> Rect { + let sheet = session.active().unwrap().wb.active().unwrap(); + geo(session).range_rect(sheet, RangeRef::parse(a1).unwrap()) +} + +/// The fill at rest: the current area's outline is on its cells. +fn fill(session: &Session) -> Vec { + let doc = session.active().unwrap(); + let sheet = doc.wb.active().unwrap(); + let geo = geo(session); + selection_fill(&geo, sheet, &doc.selection, geo.range_rect(sheet, doc.selection.current())) +} + +fn shaded(fill: &[Rect], cell: Rect) -> bool { + fill.iter().any(|part| part.intersects(cell.shrink(1.0))) +} + +fn area(fill: &[Rect]) -> f32 { + fill.iter().map(Rect::area).sum() +} + +#[test] +fn the_active_cell_of_a_range_is_not_shaded() { + let mut s = session(); + // At the corner where a drag began, then wherever Tab and Enter move it inside the range. + for active in ["B2", "C4", "D5"] { + s.execute("selection.set", json!({"range": "B2:D5", "active": active})).unwrap(); + let fill = fill(&s); + assert!(!shaded(&fill, cells(&s, active)), "{active} stays clear: {fill:?}"); + for other in ["B2", "B5", "C3", "C4", "D2", "D5"].into_iter().filter(|c| *c != active) { + assert!(shaded(&fill, cells(&s, other)), "{other} is shaded with {active} active"); + } + assert!((area(&fill) - (cells(&s, "B2:D5").area() - cells(&s, active).area())).abs() < 0.5, "the rest of the range, once"); + } +} + +#[test] +fn a_single_selected_cell_has_no_fill() { + let mut s = session(); + s.execute("selection.set", json!({"cell": "C3"})).unwrap(); + assert!(fill(&s).is_empty()); +} + +#[test] +fn other_areas_are_shaded_whole_and_a_lone_active_cell_not_at_all() { + let mut s = session(); + s.execute("selection.set", json!({"range": "A1:A3,C2:D3,F6", "active": "F6"})).unwrap(); + assert_eq!(s.active().unwrap().selection.ranges.len(), 3); + let fill = fill(&s); + assert!(!shaded(&fill, cells(&s, "F6"))); + assert!(shaded(&fill, cells(&s, "A2")) && shaded(&fill, cells(&s, "D3"))); + assert!((area(&fill) - (cells(&s, "A1:A3").area() + cells(&s, "C2:D3").area())).abs() < 0.5, "{fill:?}"); +} + +#[test] +fn a_merged_active_cell_is_left_clear_as_a_whole() { + let mut s = session(); + s.execute("selection.set", json!({"range": "B2:C3"})).unwrap(); + s.execute("home.mergeCells", json!({})).unwrap(); + s.execute("selection.set", json!({"range": "B2:E6", "active": "B2"})).unwrap(); + let fill = fill(&s); + assert!(!shaded(&fill, cells(&s, "B2:C3")), "the merged cell, not just its first cell: {fill:?}"); + assert!(shaded(&fill, cells(&s, "D2")) && shaded(&fill, cells(&s, "B4")) && shaded(&fill, cells(&s, "E6"))); + assert!((area(&fill) - (cells(&s, "B2:E6").area() - cells(&s, "B2:C3").area())).abs() < 0.5); +} + +#[test] +fn the_current_area_is_shaded_where_its_gliding_outline_is() { + let mut s = session(); + s.execute("selection.set", json!({"range": "A1:A3,C2:D3", "active": "C2"})).unwrap(); + let doc = s.active().unwrap(); + let sheet = doc.wb.active().unwrap(); + // Mid-glide the outline of the current area (C2:D3) is somewhere else on screen. + let outline = Rect::from_min_max(egui::pos2(600.0, 400.0), egui::pos2(700.0, 500.0)); + let fill = selection_fill(&geo(&s), sheet, &doc.selection, outline); + assert!(fill.contains(&outline), "the current area follows its outline: {fill:?}"); + assert!(shaded(&fill, cells(&s, "A2")), "the other areas stay on their cells"); + assert!(!shaded(&fill, cells(&s, "D3")), "nothing is left behind on the current area's cells"); +} diff --git a/crates/ui-egui/tests/selection_glide.rs b/crates/ui-egui/tests/selection_glide.rs new file mode 100644 index 000000000..fd8bf4e73 --- /dev/null +++ b/crates/ui-egui/tests/selection_glide.rs @@ -0,0 +1,203 @@ +//! The selection outline glides to a new selection, and View ▸ Animations turns that off. + +use egui::Rect; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::control::{self, Outcome}; +use gridcraft_ui_egui::{ControlRequest, SheetApp, UiState, grid::Geo}; +use serde_json::{Value, json}; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// 240 frames a second, so a glide spans many frames. +const DT: f32 = 1.0 / 240.0; + +fn app() -> SheetApp { + let mut session = Session::new(); + session.new_workbook(); + SheetApp::new(session, Default::default()) +} + +fn harness() -> Harness { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(DT).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app(), + ); + h.run_steps(4); + h +} + +/// The harness turns egui's animations off (and the glide follows that switch); this turns them on. +fn animated() -> Harness { + let mut h = harness(); + h.ctx.all_styles_mut(|s| s.animation_time = 0.1); + h.step(); + h +} + +/// Where a cell is on screen right now. +fn cell_rect(h: &Harness, a1: &str) -> Rect { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll).cell_rect(sheet, CellRef::parse(a1).unwrap()) +} + +/// Where the outline was drawn in the last frame. +fn outline(h: &Harness) -> Rect { + h.state().grid.glide.drawn() +} + +fn gliding(h: &Harness) -> bool { + h.state().grid.glide.is_gliding() +} + +fn select(h: &mut Harness, a1: &str) { + h.state_mut().run("selection.set", json!({"cell": a1})).unwrap(); +} + +fn press(h: &mut Harness, key: egui::Key) { + for pressed in [true, false] { + h.input_mut().events.push(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE }); + } +} + +fn steps(h: &mut Harness, n: usize) { + for _ in 0..n { + h.step(); + } +} + +fn reply(outcome: Outcome) -> Value { + match outcome { + Outcome::Done(v) => v, + _ => Value::Null, + } +} + +#[test] +fn an_arrow_key_glides_the_outline_to_the_next_cell() { + let mut h = animated(); + let (a1, b1) = (cell_rect(&h, "A1"), cell_rect(&h, "B1")); + assert_eq!(outline(&h), a1, "at rest the outline is on its cells"); + press(&mut h, egui::Key::ArrowRight); + h.step(); + // The selection itself moves at once; only its outline takes its time. + assert_eq!(h.state().session.active().map(|d| d.selection.active.a1()).as_deref(), Some("B1")); + steps(&mut h, 2); + let x = outline(&h).min.x; + assert!(gliding(&h) && x > a1.min.x && x < b1.min.x, "on its way, drawn in between: {x}"); + steps(&mut h, 40); + assert!(!gliding(&h), "and it comes to rest"); + assert_eq!(outline(&h), b1, "exactly on the cell"); + // Zooming moves the cell on screen but doesn't glide the outline. + h.state_mut().run("view.zoom", json!({"percent": 150})).unwrap(); + for _ in 0..3 { + h.step(); + assert!(!gliding(&h)); + } + assert_ne!(outline(&h), b1); + assert_eq!(outline(&h), cell_rect(&h, "B1")); +} + +#[test] +fn with_eguis_animations_off_the_outline_is_always_on_its_cells() { + // The headless harness (so the offscreen snapshot too) never shows an outline in flight. + let mut h = harness(); + select(&mut h, "D6"); + let d6 = cell_rect(&h, "D6"); + for _ in 0..4 { + h.step(); + assert_eq!(outline(&h), d6); + assert!(!gliding(&h)); + } +} + +#[test] +fn the_view_tab_checkbox_turns_the_glide_off_and_on() { + let mut h = animated(); + h.state_mut().ui.ribbon_tab = "View".into(); + h.run_steps(2); + h.get_by_label("Animations").click(); + h.run_steps(2); + assert!(!h.state().ui.animations, "the checkbox turns the setting off"); + select(&mut h, "E9"); + let e9 = cell_rect(&h, "E9"); + for _ in 0..5 { + h.step(); + assert_eq!(outline(&h), e9, "no animations: the outline is on its cells at once"); + assert!(!gliding(&h)); + } + h.get_by_label("Animations").click(); + h.run_steps(2); + assert!(h.state().ui.animations, "and on again"); + select(&mut h, "B1"); + steps(&mut h, 2); + let (x, b1) = (outline(&h).min.x, cell_rect(&h, "B1")); + assert!(gliding(&h) && x > b1.min.x && x < e9.min.x, "turned back on, it glides: {x}"); + steps(&mut h, 40); + assert_eq!(outline(&h), b1); +} + +#[test] +fn a_screenshot_never_catches_the_outline_in_flight() { + let mut h = animated(); + let (a1, b1) = (cell_rect(&h, "A1"), cell_rect(&h, "B1")); + let (tx, rx) = std::sync::mpsc::channel(); + h.state_mut().control_rx = Some(rx); + let (shot, _reply) = ControlRequest::new("ui.screenshot", json!({})); + tx.send(shot).unwrap(); + press(&mut h, egui::Key::ArrowRight); + for _ in 0..20 { + h.step(); + let drawn = outline(&h); + assert!(drawn == a1 || drawn == b1, "{drawn:?}"); + } + assert_eq!(outline(&h), b1); +} + +#[test] +fn animations_are_on_until_turned_off_and_the_choice_is_saved() { + assert!(UiState::default().animations); + // A `ui.json` written before the setting existed leaves them on. + let old: UiState = serde_json::from_str(r#"{"dark": true, "ribbonTab": "View"}"#).unwrap(); + assert!(old.animations && old.dark); + let off: UiState = serde_json::from_value(json!({"animations": false})).unwrap(); + let saved = serde_json::to_value(&off).unwrap(); + assert_eq!(saved["animations"], json!(false)); + assert!(!serde_json::from_value::(saved).unwrap().animations); +} + +#[test] +fn view_animations_sets_and_toggles_without_a_dialog() { + let mut a = app(); + assert_eq!(a.run("view.animations", json!({"on": false})), Ok(json!({"on": false}))); + assert_eq!(a.run("view.animations", json!({"on": false})), Ok(json!({"on": false})), "setting it twice changes nothing"); + // No parameters: it toggles, and never opens a dialog. + assert_eq!(a.run("view.animations", json!({})), Ok(json!({"on": true}))); + assert_eq!(a.run("view.animations", json!({})), Ok(json!({"on": false}))); + assert!(a.dialog.is_none() && a.message.is_none()); + // Parameters that make no sense toggle too, rather than failing or panicking. + assert_eq!(a.run("view.animations", json!({"on": "yes"})), Ok(json!({"on": true}))); + assert_eq!(a.run("view.animations", Value::Null), Ok(json!({"on": false}))); +} + +#[test] +fn agents_find_run_and_read_the_setting_over_the_control_channel() { + let mut a = app(); + let ctx = egui::Context::default(); + let found = reply(control::handle(&mut a, &ctx, "engine.commands", &json!({"search": "animations"}))); + let entry = found["result"].as_array().and_then(|all| all.iter().find(|c| c["id"] == "view.animations")).cloned().unwrap(); + assert_eq!((&entry["label"], &entry["ui"]), (&json!("Animations"), &json!(true))); + assert!(entry["params"].as_str().is_some_and(|doc| doc.contains("on")), "{entry}"); + let ran = reply(control::handle(&mut a, &ctx, "engine.execute", &json!({"command": "view.animations", "params": {"on": false}}))); + assert_eq!(ran, json!({"ok": true, "result": {"on": false}})); + assert_eq!(reply(control::handle(&mut a, &ctx, "ui.inspect", &json!({})))["result"]["ui"]["animations"], json!(false)); + let set = reply(control::handle(&mut a, &ctx, "ui.set", &json!({"animations": true}))); + assert_eq!(set["result"]["animations"], json!(true)); + assert!(a.ui.animations); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 783d1a903..c142ebffd 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -33,8 +33,8 @@ Errors come back as `{"id": …, "ok": false, "error": "…"}`. | `ui.ribbon` | `{tab}` | Switch ribbon tab | | `ui.dialog` / `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{name}` / `{field, value}` | Open, fill in and confirm dialogs | | `ui.backstage` (via `engine.execute`) | `{page?: home\|new\|open\|info\|saveAs\|print\|export\|options\|close}` | Open the File tab's Backstage view on a page, or close it (`ui.inspect` reports the open page as `backstage`) | -| `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path) | -| `ui.set` | `{dark?, formulaBar?, ribbonCollapsed?}` | UI preferences (`formulaBar` runs `view.formulaBar`) | +| `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path). Animations are settled first: the picture shows the final state | +| `ui.set` | `{dark?, formulaBar?, ribbonCollapsed?, animations?}` | UI preferences (`formulaBar` runs `view.formulaBar`) | | `ui.resize` / `ui.focus` | `{width, height}` | Window control | | `app.open` / `app.save` / `app.quit` | `{path}` / `{path?}` | Files and lifetime | @@ -46,6 +46,10 @@ System follows live OS appearance changes and falls back to Light when the platf The saved choice remains System; new users still start in Light. The legacy `view.darkMode` command and `ui.set {dark}` select a fixed Light or Dark theme. +`{command: "view.animations", params: {on: false}}` turns motion in the UI off (the selection +outline then jumps to a new selection instead of gliding there); without `on` it toggles. It is +the View ▸ Animations checkbox, saved with the other UI preferences and on by default. + `{command: "app.language.set", params: {language: "ja"}}` switches the interface language (`code` is accepted as an alias for `language`); `app.language.english` and `app.language.japanese` are shortcuts. Only interface chrome is translated, never command ids or diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2af6715a9..d764b75f6 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -34,6 +34,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Behaviour | Status | |---|---| | Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) | +| Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet | | Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | @@ -151,5 +152,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | 2026-10-11 | trivial | Theme: title-bar light/dark switch, File › Display theme, system appearance on first run (#194, #239) | | 2026-10-11 | minor | Keyboard shortcut audit against Microsoft's Windows and Mac lists; missing shortcuts for existing features added (#110) | | 2026-10-11 | minor | File tab Backstage view and File › Options (#174) | +| 2026-10-11 | minor | Selection outline glide and the Animations setting | | 2026-10-10 | minor | Chart resize bug (#190) | | 2026-10-10 | major | Created: chrome, grid, keyboard, dialogs, accessibility; key tips, Page Layout view and accessibility tree flagged |