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:
commit
e800368c91
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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", "ダーク"),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" => "어둡게",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" => "Тёмная",
|
||||
|
|
|
|||
|
|
@ -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" => "深色",
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue