mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-13 23:17:33 +00:00
Merge pull request #690 from vedran-dobos:zoom-wheel-ladder
This commit is contained in:
commit
cba08d1b4b
4 files changed
+383
-2
No files matched your search
+171
-2
@@ -20,6 +20,42 @@ pub const COL_HEADER_H: f32 = 21.0;
|
||||
/// which matches desktop spreadsheet density (a default 20 px row is 16 pt tall).
|
||||
pub const DISPLAY_SCALE: f32 = 0.8;
|
||||
|
||||
/// The zoom percentages a Ctrl+wheel notch steps between: four per doubling (about 20% apart),
|
||||
/// so 25, 50, 100, 200 and 400 are on it. Excel steps 15 points a notch instead; the status bar's
|
||||
/// Zoom In / Zoom Out buttons keep their 10% steps.
|
||||
pub const WHEEL_ZOOM_LADDER: [u16; 22] = [10, 12, 15, 18, 21, 25, 30, 35, 42, 50, 60, 70, 85, 100, 120, 140, 170, 200, 240, 280, 340, 400];
|
||||
|
||||
/// Seconds without Ctrl+wheel input after which part of a notch turned so far is forgotten.
|
||||
const WHEEL_ZOOM_IDLE: f64 = 0.75;
|
||||
|
||||
/// The zoom one Ctrl+wheel notch goes to from `zoom`: the next step on [`WHEEL_ZOOM_LADDER`] in
|
||||
/// that direction, skipping a step less than 5% away (121% out goes to 100%, not 120%), but never
|
||||
/// 100% or the 10% / 400% limits (99% or 101% towards 100% goes to 100%).
|
||||
pub fn wheel_zoom_step(zoom: u16, zoom_in: bool) -> u16 {
|
||||
let zoom = zoom.clamp(10, 400);
|
||||
let z = u32::from(zoom);
|
||||
let far = |l: u32| if zoom_in { l * 100 >= z * 105 } else { l * 105 <= z * 100 };
|
||||
let mut ahead = WHEEL_ZOOM_LADDER.iter().copied().filter(|&l| if zoom_in { l > zoom } else { l < zoom }).collect::<Vec<_>>();
|
||||
if !zoom_in {
|
||||
ahead.reverse();
|
||||
}
|
||||
let limit = ahead.last().copied().unwrap_or(zoom);
|
||||
ahead.into_iter().find(|&l| l == 100 || far(u32::from(l))).unwrap_or(limit)
|
||||
}
|
||||
|
||||
/// One frame of the zoom gliding from `from` to `to` percent: the share `t` (0–1) of the way,
|
||||
/// measured as a ratio so zooming in and out feel alike, and at least one percent, so it arrives.
|
||||
pub fn zoom_glide_step(from: u16, to: u16, t: f32) -> u16 {
|
||||
let (f, g) = (f32::from(from.max(1)), f32::from(to.max(1)));
|
||||
let next = (f * (g / f).powf(t.clamp(0.0, 1.0))).round();
|
||||
let next = if next.is_finite() { next.clamp(f32::from(from.min(to)), f32::from(from.max(to))) as u16 } else { to };
|
||||
match (next == from, from.cmp(&to)) {
|
||||
(true, std::cmp::Ordering::Less) => from + 1,
|
||||
(true, std::cmp::Ordering::Greater) => from - 1,
|
||||
_ => next,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub enum Drag {
|
||||
#[default]
|
||||
@@ -109,6 +145,34 @@ pub struct GridState {
|
||||
pub hf_start: bool,
|
||||
/// The grid geometry of the last frame (hit-testing for agents and tests).
|
||||
pub geo: Option<Geo>,
|
||||
/// Ctrl+wheel zoom in progress (see [`WheelZoom`]).
|
||||
pub wheel_zoom: Option<WheelZoom>,
|
||||
}
|
||||
|
||||
/// Ctrl+wheel zoom for one (doc uid, sheet). egui eases a notch in over several frames and
|
||||
/// fine-grained wheels send parts of one, so the turning is counted in notches, and each whole
|
||||
/// notch steps [`WHEEL_ZOOM_LADDER`]. With animations on the zoom then glides to that step.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct WheelZoom {
|
||||
pub place: (u64, usize),
|
||||
/// Turned so far towards the next step, in notches (+ in, − out).
|
||||
pub notches: f32,
|
||||
/// When `notches` last changed.
|
||||
pub time: f64,
|
||||
/// The step the zoom is gliding to, and the percentage set last on the way.
|
||||
pub glide: Option<(u16, u16)>,
|
||||
}
|
||||
|
||||
impl WheelZoom {
|
||||
/// The state for `place` showing `zoom` percent: fresh for another document or sheet, and
|
||||
/// without its glide if the zoom was changed some other way since the glide set it.
|
||||
fn at(app: &SheetApp, place: (u64, usize), zoom: u16, now: f64) -> WheelZoom {
|
||||
let mut w = app.grid.wheel_zoom.filter(|w| w.place == place).unwrap_or(WheelZoom { place, notches: 0.0, time: now, glide: None });
|
||||
if w.glide.is_some_and(|(_, shown)| shown != zoom) {
|
||||
w.glide = None;
|
||||
}
|
||||
w
|
||||
}
|
||||
}
|
||||
|
||||
impl GridState {
|
||||
@@ -522,8 +586,34 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
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();
|
||||
let _ = app.session.run("view.zoom", json!({"percent": z}));
|
||||
// egui's zoom factor is e^(speed × points); one notch is `line_scroll_speed` points.
|
||||
let notch = ui.ctx().options(|o| o.input_options.scroll_zoom_speed * o.input_options.line_scroll_speed);
|
||||
let turned = zoom_delta.ln() / notch;
|
||||
let mut zooming = WheelZoom::at(app, place, sh.zoom, now);
|
||||
if now - zooming.time > WHEEL_ZOOM_IDLE {
|
||||
zooming.notches = 0.0;
|
||||
}
|
||||
if turned.is_finite() {
|
||||
zooming.notches = (zooming.notches + turned).clamp(-32.0, 32.0);
|
||||
}
|
||||
zooming.time = now;
|
||||
// A notch steps once 60% of it has turned, and the rest of it brings `notches` back to
|
||||
// about 0. (Past half, so what's left after a step can't step back.) Steps count from
|
||||
// where a glide is going, not from where it has got to.
|
||||
let from = zooming.glide.map_or(sh.zoom, |(to, _)| to);
|
||||
let mut z = from;
|
||||
while zooming.notches >= 0.6 {
|
||||
z = wheel_zoom_step(z, true);
|
||||
zooming.notches -= 1.0;
|
||||
}
|
||||
while zooming.notches <= -0.6 {
|
||||
z = wheel_zoom_step(z, false);
|
||||
zooming.notches += 1.0;
|
||||
}
|
||||
if z != from {
|
||||
zooming.glide = Some((z, sh.zoom));
|
||||
}
|
||||
app.grid.wheel_zoom = Some(zooming);
|
||||
} else if scroll_delta != egui::Vec2::ZERO {
|
||||
let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE };
|
||||
let mut d = scroll_delta;
|
||||
@@ -538,6 +628,22 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
view.scroll -= d / z;
|
||||
}
|
||||
}
|
||||
// The zoom glides to its step like egui eases a wheel notch (90% of the way in 0.1 s), or goes
|
||||
// there at once without animations (as for the selection outline below: View › Animations
|
||||
// off, egui's off in the headless harness, or a screenshot on its way).
|
||||
let mut zooming = WheelZoom::at(app, place, sh.zoom, now);
|
||||
if let Some((to, shown)) = zooming.glide {
|
||||
let glides = app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight();
|
||||
let share = egui::emath::exponential_smooth_factor(0.9, 0.1, ui.input(|i| i.stable_dt).min(0.1));
|
||||
let z = if glides { zoom_glide_step(shown, to, share) } else { to };
|
||||
let _ = app.session.run("view.zoom", json!({"percent": z}));
|
||||
zooming.glide = (z != to).then_some((to, z));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
// Store a glide's progress, or that it stopped.
|
||||
if app.grid.wheel_zoom.is_some_and(|w| w.glide.is_some()) {
|
||||
app.grid.wheel_zoom = Some(zooming);
|
||||
}
|
||||
view.scroll = view.scroll.max(egui::Vec2::ZERO);
|
||||
let (mut max_x, mut max_y) = (sh.col_left(MAX_COLS - 20) as f32, sh.row_top(MAX_ROWS - 40) as f32);
|
||||
if paper {
|
||||
@@ -3147,6 +3253,69 @@ mod selection_fill_tests {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wheel_zoom_steps_along_the_ladder_and_stops_at_100() {
|
||||
for (from, zoom_in, to) in [
|
||||
(100, true, 120),
|
||||
(120, false, 100),
|
||||
(100, false, 85),
|
||||
// Between steps, the next one each way (Excel: 112% goes to 115% or 100%).
|
||||
(112, true, 120),
|
||||
(112, false, 100),
|
||||
// A step less than 5% away is skipped…
|
||||
(121, false, 100),
|
||||
(86, false, 70),
|
||||
(139, true, 170),
|
||||
// …but never 100% or a limit.
|
||||
(99, true, 100),
|
||||
(101, false, 100),
|
||||
(96, true, 100),
|
||||
(385, true, 400),
|
||||
(11, false, 10),
|
||||
(400, true, 400),
|
||||
(10, false, 10),
|
||||
// Out-of-range zooms (from a file) come into range first.
|
||||
(5, true, 12),
|
||||
(1000, false, 340),
|
||||
(0, false, 10),
|
||||
(u16::MAX, true, 400),
|
||||
] {
|
||||
assert_eq!(wheel_zoom_step(from, zoom_in), to, "{from}% {}", if zoom_in { "in" } else { "out" });
|
||||
}
|
||||
// Every step on the ladder is 15–25% from the next, and a notch in then out returns.
|
||||
// (The glide between steps is `zoom_glide_moves_by_ratio_and_always_arrives`.)
|
||||
for w in WHEEL_ZOOM_LADDER.windows(2) {
|
||||
let ratio = f32::from(w[1]) / f32::from(w[0]);
|
||||
assert!((1.15..=1.25).contains(&ratio), "{w:?}");
|
||||
assert_eq!(wheel_zoom_step(w[0], true), w[1]);
|
||||
assert_eq!(wheel_zoom_step(w[1], false), w[0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_glide_moves_by_ratio_and_always_arrives() {
|
||||
// A third of the way per frame, as at 60 frames a second.
|
||||
for (from, to) in [(100, 120), (120, 100), (10, 12), (340, 400), (400, 10), (85, 100)] {
|
||||
let (mut z, mut frames) = (from, 0);
|
||||
while z != to {
|
||||
let next = zoom_glide_step(z, to, 0.32);
|
||||
assert!(next != z && next.abs_diff(to) < z.abs_diff(to), "{from} → {to}: {z} then {next}");
|
||||
z = next;
|
||||
frames += 1;
|
||||
assert!(frames < 40, "{from} → {to} arrives");
|
||||
}
|
||||
assert!(frames >= 2, "{from} → {to} glides over more than one frame ({frames})");
|
||||
}
|
||||
assert_eq!(zoom_glide_step(100, 120, 0.32), 106, "a third of the way by ratio");
|
||||
assert_eq!(zoom_glide_step(120, 100, 0.32), 113);
|
||||
// No progress asked or nonsense: still a percent closer, or straight there.
|
||||
assert_eq!(zoom_glide_step(100, 120, 0.0), 101);
|
||||
assert_eq!(zoom_glide_step(120, 100, f32::NAN), 100);
|
||||
assert_eq!(zoom_glide_step(100, 120, 5.0), 120);
|
||||
assert_eq!(zoom_glide_step(120, 120, 0.5), 120);
|
||||
assert_eq!(zoom_glide_step(0, 10, 0.5), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_across_run_stops_at_first_non_continuing_column() {
|
||||
assert_eq!(center_across_end(0, 20, |c| c <= 2), 2);
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Ctrl+wheel zoom steps along a ladder of percentages that always comes back to 100%.
|
||||
|
||||
use egui::{Event, Modifiers, MouseWheelUnit, TouchPhase, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
let over_grid = h.state().grid.rect.unwrap().center();
|
||||
h.input_mut().events.push(Event::PointerMoved(over_grid));
|
||||
h.run_steps(2);
|
||||
h
|
||||
}
|
||||
|
||||
fn zoom(h: &Harness) -> u16 {
|
||||
h.state().session.active().unwrap().wb.active().unwrap().zoom
|
||||
}
|
||||
|
||||
fn set_zoom(h: &mut Harness, percent: u16) {
|
||||
h.state_mut().run("view.zoom", json!({"percent": percent})).unwrap();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Turns the wheel `turns` notches at a time with Ctrl held (up zooms in), each turn `frames`
|
||||
/// frames after the last, then lets egui's easing play out.
|
||||
fn wheel_turns(h: &mut Harness, turns: &[f32], frames: usize) {
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND));
|
||||
for &t in turns {
|
||||
let delta = vec2(0.0, t);
|
||||
h.input_mut().events.push(Event::MouseWheel { unit: MouseWheelUnit::Line, delta, phase: TouchPhase::Move, modifiers: Modifiers::COMMAND });
|
||||
h.run_steps(frames);
|
||||
}
|
||||
h.run_steps(60);
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn wheel(h: &mut Harness, notches: f32) {
|
||||
wheel_turns(h, &[notches], 1);
|
||||
}
|
||||
|
||||
/// The harness turns egui's animations off (and the zoom glide follows that); 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
|
||||
}
|
||||
|
||||
/// Turns the wheel `notches` with Ctrl held and records the zoom over the next `frames` frames.
|
||||
fn turn_and_watch(h: &mut Harness, notches: f32, frames: usize) -> Vec<u16> {
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND));
|
||||
let delta = vec2(0.0, notches);
|
||||
h.input_mut().events.push(Event::MouseWheel { unit: MouseWheelUnit::Line, delta, phase: TouchPhase::Move, modifiers: Modifiers::COMMAND });
|
||||
(0..frames)
|
||||
.map(|_| {
|
||||
h.step();
|
||||
zoom(h)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_animations_the_zoom_glides_to_the_step() {
|
||||
let mut h = animated();
|
||||
let seen = turn_and_watch(&mut h, 1.0, 40);
|
||||
assert!(seen.windows(2).all(|w| w[0] <= w[1]), "only ever towards the step: {seen:?}");
|
||||
let between = seen.iter().filter(|&&z| z > 100 && z < 120).count();
|
||||
assert!(between >= 3, "on its way over several frames: {seen:?}");
|
||||
assert_eq!(seen.last(), Some(&120), "and exactly on the step: {seen:?}");
|
||||
let seen = turn_and_watch(&mut h, -1.0, 40);
|
||||
assert!(seen.windows(2).all(|w| w[0] >= w[1]) && seen.iter().any(|&z| z > 100 && z < 120), "{seen:?}");
|
||||
assert_eq!(seen.last(), Some(&100), "{seen:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notch_during_a_glide_steps_on_from_where_it_is_going() {
|
||||
let mut h = animated();
|
||||
let seen = turn_and_watch(&mut h, 1.0, 6);
|
||||
assert!(seen.last().is_some_and(|&z| z > 100 && z < 120), "still gliding: {seen:?}");
|
||||
turn_and_watch(&mut h, 1.0, 60);
|
||||
assert_eq!(zoom(&h), 140);
|
||||
let seen = turn_and_watch(&mut h, -1.0, 6);
|
||||
assert!(seen.last().is_some_and(|&z| z > 120 && z < 140), "{seen:?}");
|
||||
turn_and_watch(&mut h, -1.0, 60);
|
||||
assert_eq!(zoom(&h), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zoom_set_another_way_stops_a_glide() {
|
||||
let mut h = animated();
|
||||
let seen = turn_and_watch(&mut h, 1.0, 6);
|
||||
assert!(seen.last().is_some_and(|&z| z > 100 && z < 120), "{seen:?}");
|
||||
h.state_mut().run("view.zoom", json!({"percent": 150})).unwrap();
|
||||
h.run_steps(60);
|
||||
assert_eq!(zoom(&h), 150);
|
||||
// Setting the percentage the glide had got to doesn't start it again.
|
||||
let stopped_at = seen.last().copied().unwrap();
|
||||
set_zoom(&mut h, stopped_at);
|
||||
h.run_steps(60);
|
||||
assert_eq!(zoom(&h), stopped_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_view_animations_off_a_notch_steps_at_once() {
|
||||
let mut h = animated();
|
||||
h.state_mut().run("view.animations", json!({"on": false})).unwrap();
|
||||
let seen = turn_and_watch(&mut h, 1.0, 40);
|
||||
assert!(seen.iter().all(|&z| z == 100 || z == 120), "no percentages in between: {seen:?}");
|
||||
assert_eq!(seen.last(), Some(&120));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notch_out_returns_to_the_zoom_before_the_notch_in() {
|
||||
let mut h = harness();
|
||||
assert_eq!(zoom(&h), 100);
|
||||
// Multiplying the rounded zoom by egui's eased factor every frame drifted: 100 → 122 → 101 → 123 → 102.
|
||||
for _ in 0..4 {
|
||||
wheel(&mut h, 1.0);
|
||||
assert_eq!(zoom(&h), 120);
|
||||
wheel(&mut h, -1.0);
|
||||
assert_eq!(zoom(&h), 100);
|
||||
}
|
||||
for expect in [120, 140, 170] {
|
||||
wheel(&mut h, 1.0);
|
||||
assert_eq!(zoom(&h), expect);
|
||||
}
|
||||
wheel(&mut h, -3.0);
|
||||
assert_eq!(zoom(&h), 100);
|
||||
wheel(&mut h, -2.0);
|
||||
assert_eq!(zoom(&h), 70);
|
||||
// Notches turned quickly, each before the last one has eased in.
|
||||
wheel_turns(&mut h, &[1.0, 1.0, 1.0, 1.0], 3);
|
||||
assert_eq!(zoom(&h), 140);
|
||||
wheel_turns(&mut h, &[-1.0, -1.0], 3);
|
||||
assert_eq!(zoom(&h), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parts_of_a_notch_add_up_to_whole_steps() {
|
||||
// A fine-grained wheel (or a Windows touchpad pinch) sends a notch in pieces, not always evenly.
|
||||
let mut h = harness();
|
||||
wheel_turns(&mut h, &[0.25, 0.25, 0.25], 4);
|
||||
assert_eq!(zoom(&h), 120, "three quarters of a notch step");
|
||||
wheel_turns(&mut h, &[-0.3, -0.4, -0.3], 4);
|
||||
assert_eq!(zoom(&h), 100);
|
||||
wheel_turns(&mut h, &[0.25, 0.25, 0.25, 0.25, 0.3, 0.4, 0.3], 4);
|
||||
assert_eq!(zoom(&h), 140, "one step per notch, however it's divided");
|
||||
wheel_turns(&mut h, &[-0.5, -0.25, -0.25, -0.25, -0.25, -0.5], 4);
|
||||
assert_eq!(zoom(&h), 100);
|
||||
wheel_turns(&mut h, &[0.5], 4);
|
||||
assert_eq!(zoom(&h), 100, "half a notch doesn't step");
|
||||
// What's left of a notch is forgotten after a pause (`wheel_turns` waits a second) rather
|
||||
// than adding up with the next turn.
|
||||
wheel_turns(&mut h, &[0.4], 4);
|
||||
assert_eq!(zoom(&h), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_a_zoom_set_another_way_a_notch_goes_to_the_next_step_or_100() {
|
||||
let mut h = harness();
|
||||
for (from, notches, to) in
|
||||
[(112, 1.0, 120), (112, -1.0, 100), (121, -1.0, 100), (99, 1.0, 100), (101, -1.0, 100), (385, 1.0, 400), (400, 1.0, 400), (11, -1.0, 10)]
|
||||
{
|
||||
set_zoom(&mut h, from);
|
||||
wheel(&mut h, notches);
|
||||
assert_eq!(zoom(&h), to, "{from}% by {notches}");
|
||||
}
|
||||
// Two notches out from 121%: 100%, then 85%.
|
||||
set_zoom(&mut h, 121);
|
||||
wheel(&mut h, -2.0);
|
||||
assert_eq!(zoom(&h), 85);
|
||||
}
|
||||
@@ -47,3 +47,27 @@ current or preceding frame are protected from eviction. If the visible set excee
|
||||
new images show the existing placeholder until capacity is available; header-based admission
|
||||
avoids repeatedly decoding overflow images. More than 128 small visible pictures now keep stable
|
||||
texture handles instead of cycling through the cache every frame.
|
||||
|
||||
## Ctrl+wheel zoom steps along its own ladder, not Excel's 15-point steps
|
||||
|
||||
A Ctrl+wheel notch moves the zoom to the next step on `grid::WHEEL_ZOOM_LADDER`: 10, 12, 15, 18,
|
||||
21, 25, 30, 35, 42, 50, 60, 70, 85, 100, 120, 140, 170, 200, 240, 280, 340, 400 (four steps per
|
||||
doubling, 17–25% apart). A step less than 5% away is skipped, except 100% and the 10% / 400%
|
||||
limits, which are never skipped: 121% out goes to 100%, and 99% or 101% towards 100% goes to 100%.
|
||||
The wheel's turning is counted in notches (egui eases each notch in over several frames, and
|
||||
fine-grained wheels send parts of one); a notch steps once 60% of it has turned. With animations
|
||||
on, the zoom then glides to the step over about a tenth of a second (as egui eases a scroll notch),
|
||||
and a notch turned during a glide steps on from the step it's going to. With View › Animations off
|
||||
it goes to the step at once. Whether the zoom glide should have a setting of its own is left to the
|
||||
maintainers.
|
||||
|
||||
Before, each frame multiplied the whole-number zoom by egui's eased factor and rounded, so a notch
|
||||
out never undid a notch in (100 → 122 → 101 → 123 → 102). Keeping the zoom unrounded fixed that
|
||||
for a wheel with exact notches, but not for one that sends uneven parts of a notch.
|
||||
|
||||
Excel (observed: Microsoft 365, Windows) steps 15 points a notch: 100 → 115 → 130, and from 112%
|
||||
to 115% or 100%. That's a large relative step at low zoom and a small one near 400%; equal
|
||||
relative steps keep each notch feeling the same, and the doublings land on 25, 50, 100, 200 and
|
||||
400%. The status bar's Zoom In / Zoom Out buttons and Ctrl+Alt+= / Ctrl+Alt+- keep their 10%
|
||||
steps, so the wheel and the buttons now step differently. Whether they should share one ladder
|
||||
(this one, Excel's, or another) is left to the maintainers.
|
||||
@@ -1,5 +1,6 @@
|
||||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Ctrl+wheel zoom steps along a ladder through 100%) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (sheet-tab navigation reveals the selected tab without interrupting manual scrolling) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (Alt+Down opens AutoFilter header menus) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (ribbon and menu Paste paste the system clipboard like Cmd/Ctrl+V; regression test for the Paste button freeze, #273; Paste button gets an accessible name) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
@@ -163,6 +164,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md |
|
||||
| 2026-10-12 | minor | Page Layout view: left/right/top/bottom margins are dragged on the rulers (#792) |
|
||||
| 2026-10-12 | minor | Alt+Down opens the active filter header's existing popup, with validation priority and mirrored anchoring on right-to-left sheets (part of #441) |
|
||||
| 2026-10-12 | minor | Paste from the ribbon (button and options menu), the cell and header context menus and the macOS menu bar's Edit menu pastes what is on the system clipboard, as Cmd/Ctrl+V does, instead of only GridCraft's own copy (the web build, which can't read the clipboard on request, still pastes GridCraft's copy). Copy and Cut by keytip (H,C / H,X) publish to the system clipboard like the ribbon buttons. After a control-channel or MCP copy, which leaves the system clipboard alone, Paste pastes that copy until other text is copied. Once a cut has been pasted, Paste (ribbon, menus or Cmd/Ctrl+V) doesn't paste the cut's clipboard text again over the moved cells (Excel pastes a cut once), and the Paste keytip (H,V,P) pastes the system clipboard like the button. Regression test for the Paste button click freeze in 0.5.0 (#273, fixed by #272); the Paste button gets an accessible "Paste" name for screen readers |
|
||||
|
||||
Reference in new issue
Block a user