Glide the selection outline to a new selection; View > Animations turns it off
Excel's cell cursor doesn't jump: press an arrow key or click a cell and the
border slides over; extend the selection and it grows. The outline of the
current area now does the same, in 0.12 s with a decelerating curve, from
wherever it is drawn (a held arrow key reads as one motion), with its fill
and fill handle. The timing is an estimate, not yet measured against Excel.
The same cells moving on screen (scrolling, zoom, a resized column), another
sheet or workbook, an outline that wasn't drawn the frame before, and moves
that start or end off screen don't glide. Only painting waits: the
selection, hit testing and the cell editor use the real cells at once.
View > Animations (under Display theme) turns it off; view.animations {on?}
and ui.set {animations?} do the same for agents. Saved with the UI
preferences, on by default; translated in the five catalogs.
Scripted pictures never catch an outline in flight: it stays on its cells
while a ui.screenshot is on its way and when egui's animations are off (the
headless harness, so the offscreen snapshot). The marching ants around a
copied range now advance by the clock instead of once per painted frame,
since a glide repaints every frame and made them lurch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a721b087a1
commit
5967b5656f
|
|
@ -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.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" => {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,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,
|
||||
|
|
@ -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<Rect> {
|
||||
/// 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> {
|
||||
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<Item = Rect> {
|
|||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", "ダーク"),
|
||||
|
|
|
|||
|
|
@ -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" => "어둡게",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" => "Тёмная",
|
||||
|
|
|
|||
|
|
@ -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" => "深色",
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Rect> {
|
||||
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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue