Merge pull request #266 from arcanii/selection-glide

Glide the selection outline to a new selection; View ▸ Animations turns it off (includes #265)
This commit is contained in:
Brandon Thomas 2026-10-11 13:27:43 -04:00
commit e800368c91
18 changed files with 889 additions and 22 deletions

View File

@ -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

View File

@ -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<String>, Sender<ControlResponse>, 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));

View File

@ -70,6 +70,8 @@ pub struct GridState {
pub filter_menu: Option<(u32, Pos2)>,
pub context_menu: Option<Pos2>,
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<CellRef>,
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<Rect> {
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<Rect> {
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<Item = Rect> {
// `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> = 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<_>>(), vec![sane]);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -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", "ダーク"),

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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" => "어둡게",

View File

@ -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",

View File

@ -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" => "Тёмная",

View File

@ -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" => "深色",

View File

@ -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<String>,
/// 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") {

View File

@ -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.

View File

@ -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<Rect> {
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}");
}
}
}

View File

@ -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<Rect> {
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");
}

View File

@ -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::<UiState>(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);
}

View File

@ -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

View File

@ -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 |