diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e6da0f0..5489723 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.zoom100", "100%", ""), ("ui.dialog", "Open Dialog", ""), ("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"} (alias: code)"), @@ -201,6 +210,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; + } ok(serde_json::to_value(&app.ui).unwrap_or_default()) } "ui.dialog" => { diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 6200291..cbb7e58 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -65,6 +65,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, @@ -321,9 +323,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(); @@ -371,6 +374,20 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { *v = view; } app.grid.cells_rect = Some(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); let mut cf = CfCache::new(); @@ -396,7 +413,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); + paint_selection(&p, &geo, sh, &sel, outline, &t, app); } // Freeze lines. if geo.fr > 0 { @@ -428,7 +445,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { context_menu(app, ui); vertical_scrollbar(app, ui, sh, &geo, available.right(), 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)); } } @@ -910,13 +928,15 @@ fn active_rect(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection) -> Rect } /// 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. -pub fn selection_fill(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection) -> Vec { +/// 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 { if sel.is_single_cell() { return vec![]; } let active = active_rect(geo, sh, sel); - sel.ranges.iter().flat_map(|r| rect_around(geo.range_rect(sh, *r), active)).collect() + let area = |r: &RangeRef| if *r == sel.current() { outline } else { geo.range_rect(sh, *r) }; + sel.ranges.iter().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 @@ -939,13 +959,13 @@ fn rect_around(area: Rect, hole: Rect) -> impl Iterator { parts.into_iter().filter(|r| r.is_positive() && r.is_finite()) } -fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, t: &Tokens, app: &SheetApp) { +/// `outline` is where the current area's border is drawn this frame (see `selection_glide`). +fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect, t: &Tokens, app: &SheetApp) { // Fill all areas except the active cell. - for rect in selection_fill(geo, sh, sel) { + for rect in selection_fill(geo, sh, sel, outline) { p.rect_filled(rect, 0.0, t.sel_fill); } let active = active_rect(geo, sh, sel); - 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() @@ -963,13 +983,13 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S } } } - 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 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() { - 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); } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b212..7010767 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -341,6 +341,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/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c37ae3c..ec3733e 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -263,6 +263,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 1bdc2c3..adfd13f 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -264,6 +264,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 8d54856..02e7b29 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -263,6 +263,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 9f04ada..dc3af99 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -263,6 +263,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 1225eec..0b0bfbc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,6 +22,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; @@ -48,6 +49,8 @@ pub struct UiState { pub formula_bar: 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 @@ -67,6 +70,7 @@ impl Default for UiState { formula_bar: true, formula_bar_expanded: false, status_bar: true, + animations: true, recent: vec![], language: i18n::Language::En, } @@ -319,6 +323,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.zoom100" => return Some(self.session.run("view.zoom", json!({"percent": 100})).inspect(|_| self.after_engine())), "app.language.set" => { // `{"language": "ja"}`; `code` is accepted as an alias. diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a..066ff3e 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1689,6 +1689,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 language: settings live in the View tab, like Excel's Options. The label follows diff --git a/crates/ui-egui/src/selection_glide.rs b/crates/ui-egui/src/selection_glide.rs new file mode 100644 index 0000000..dbd8bfa --- /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 index bada97d..446ccdd 100644 --- a/crates/ui-egui/tests/selection_fill.rs +++ b/crates/ui-egui/tests/selection_fill.rs @@ -23,10 +23,12 @@ fn cells(session: &Session, a1: &str) -> Rect { 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(); - selection_fill(&geo(session), sheet, &doc.selection) + let geo = geo(session); + selection_fill(&geo, sheet, &doc.selection, geo.range_rect(sheet, doc.selection.current())) } fn shaded(fill: &[Rect], cell: Rect) -> bool { @@ -81,3 +83,17 @@ fn a_merged_active_cell_is_left_clear_as_a_whole() { 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 0000000..fd8bf4e --- /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 aeb540d..deb6030 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -32,8 +32,8 @@ Errors come back as `{"id": …, "ok": false, "error": "…"}`. | `ui.edit.begin` / `ui.edit.text` / `ui.edit.commit` / `ui.edit.cancel` | `{text?}` / `{move?: down\|right\|up\|left\|none}` | Drive the cell editor | | `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.screenshot` | `{path?}` | PNG of the window (base64 when no path) | -| `ui.set` | `{dark?, formulaBar?, ribbonCollapsed?}` | UI preferences | +| `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 | | `ui.resize` / `ui.focus` | `{width, height}` | Window control | | `app.open` / `app.save` / `app.quit` | `{path}` / `{path?}` | Files and lifetime | @@ -45,6 +45,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 e85bca1..5cce1d7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from a source audit and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** major (created from a source audit and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -32,6 +32,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; 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 | @@ -91,5 +92,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 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 |