collective

Browse / github.com/storytold/gridcraft / patch

fix(ui): release scrollbar capture after missed mouse-up (#2748)

vertical scrolling bug on ubuntu 26.04 ### Steps to Reproduce 1. Upload any document with enough data to make a vertical scrollbar appear. 2. Click and hold the left mouse button on the scrollbar thumb (the draggable part). 3. While holding the left mouse button, move the cursor to the right, to the very edge of the window or outside of it. 4. Release the left mouse button. ### Expected Result The page should stop scrolling, and the scrollbar should become inactive. ### Actual Result The scrollbar remains active, and the page continues to scroll if you move the mouse up and down anywhere on the screen. --- gridcraft-cli 0.8

Status
open
Score
0.00
Adopters
0 other operators
Author
TheRenegadeDev (imported from pull request #2768, open)
Base
89ac453efa7ea03a3ab0b0d24fd85ca51172f5cc
Size
+743 −8 in 9 files
Review
pending
Shared
2026-10-28
Id
2a280e300adbffe51fb8a3ce

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md apps/gridcraft/src/main.rs crates/ui-egui/src/app.rs crates/ui-egui/src/grid.rs crates/ui-egui/src/grid/show.rs crates/ui-egui/src/services.rs crates/ui-egui/tests/it/main.rs crates/ui-egui/tests/it/scrollbar_release.rs docs/ui-parity.md

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -2,7 +2,7 @@  **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-27 · **Change:** minor (worksheet scrollbar capture cleanup, #2748) · **Target:** Microsoft Excel (Microsoft 365)  GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in  | Date | What landed | |---|---|+| 2026-10-27 | Both worksheet scrollbars release capture on button-up or focus loss, including logic-only frames. Native Wayland also cancels on pointer exit because the pinned event bridge can lose an outside release; other hosts keep held dragging across window exit (#2748) | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |@@ -242,6 +243,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Progress log: worksheet scrollbar capture cleanup (#2748) | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | | 2026-10-23 | minor | Progress log: accounting negatives in number text | | 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs--- a/apps/gridcraft/src/main.rs+++ b/apps/gridcraft/src/main.rs@@ -381,6 +381,12 @@ fn main() -> eframe::Result<()> {                 let Some(mut app) = pending.take() else {                     return Err("the app was already created".into());                 };+                #[cfg(all(unix, not(target_os = "macos")))]+                {+                    use winit::raw_window_handle::{HasDisplayHandle as _, RawDisplayHandle};+                    app.services.scrollbar_release_on_pointer_gone =+                        cc.display_handle().is_ok_and(|h| matches!(h.as_raw(), RawDisplayHandle::Wayland(_)));+                }                 SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language);                 if let Some(port) = control_port {                     match control_server::start(port, control_token.clone(), cc.egui_ctx.clone()) {diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs--- a/crates/ui-egui/src/app.rs+++ b/crates/ui-egui/src/app.rs@@ -93,6 +93,7 @@ impl SheetApp {      /// Per-frame logic (control channel, screenshots). Call before `ui`.     pub fn logic(&mut self, ctx: &egui::Context) {+        grid::cancel_scrollbar_drag(ctx);         self.ensure_text_width(ctx);         // A keytip control request lives one frame: the ribbon consumes it during `ui`, and         // anything that never reached a control (collapsed ribbon, tab switch) is dropped.diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs--- a/crates/ui-egui/src/grid.rs+++ b/crates/ui-egui/src/grid.rs@@ -40,6 +40,7 @@ pub use interact::{follow_link, freeze_handles, point_move}; pub(crate) use quadrant::paint_quadrant; pub(crate) use selection::paint_selection; pub use selection::selection_fill;+pub(crate) use show::cancel_scrollbar_drag; pub(crate) use show::horizontal_scrollbar; pub use show::show; #[cfg(test)]diff --git a/crates/ui-egui/src/grid/show.rs b/crates/ui-egui/src/grid/show.rs--- a/crates/ui-egui/src/grid/show.rs+++ b/crates/ui-egui/src/grid/show.rs@@ -334,6 +334,143 @@ fn paint_panes(     quads } +#[derive(Clone, Copy)]+struct ScrollbarCapture {+    id: egui::Id,+    started: u64,+    release_on_pointer_gone: bool,+    cancelled: bool,+    was_focused: bool,+}++struct ScrollbarInput;++impl egui::plugin::Plugin for ScrollbarInput {+    fn debug_name(&self) -> &'static str {+        "worksheet scrollbar capture"+    }++    fn input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {+        let key = egui::Id::unique(("worksheet_scrollbar_capture", raw.viewport_id));+        let Some(mut capture) = ctx.data(|d| d.get_temp::<ScrollbarCapture>(key)) else { return };+        // A delivered release completes the old gesture. Only an interruption that+        // comes first cancels it; later events must not erase a completed track click.+        let ending = raw.events.iter().find_map(|event| match event {+            egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, .. } => Some(false),+            egui::Event::WindowFocused(false) => Some(true),+            egui::Event::PointerGone if capture.release_on_pointer_gone => Some(true),+            _ => None,+        });+        if !capture.cancelled && !ending.unwrap_or(capture.was_focused && !raw.focused) {+            if ending == Some(false) {+                ctx.data_mut(|d| d.remove::<ScrollbarCapture>(key));+            } else if raw.focused && !capture.was_focused {+                capture.was_focused = true;+                ctx.data_mut(|d| d.insert_temp(key, capture));+            }+            return;+        }+        // Use the affected viewport's input for root and immediate windows alike,+        // before egui assigns any later press. Never alter another widget's drag.+        let released = ctx.viewport_for(raw.viewport_id, |v| {+            (v.interact_widgets.dragged.is_none_or(|id| id == capture.id) && v.input.pointer.primary_down()).then(|| {+                let pos = v.input.pointer.interact_pos().or_else(|| v.input.pointer.press_origin()).unwrap_or_default();+                let cancel_click = v.input.pointer.could_any_button_be_click().then(|| v.input.pointer.press_origin().unwrap_or(pos));+                (pos, v.input.modifiers, cancel_click)+            })+        });+        if let Some((pos, modifiers, cancel_click)) = released {+            let release = egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers };+            if let Some(origin) = cancel_click {+                // egui has no cancelled release. Invalidate the old click without net+                // pointer movement before releasing; a later real press resets this state.+                let distance = ctx.options(|o| o.input_options.max_click_dist) + 1.0;+                raw.events.splice(0..0, [egui::Event::PointerMoved(origin + vec2(distance, 0.0)), egui::Event::PointerMoved(pos), release]);+            } else {+                raw.events.insert(0, release);+            }+        }+        ctx.data_mut(|d| d.remove::<ScrollbarCapture>(key));+    }+}++/// Both worksheet scrollbars share capture cleanup. `PointerGone` alone is not a release on+/// hosts with working pointer capture. The native shell enables that fallback only on Wayland:+/// egui-winit drops MouseInput without a pointer position, leaving primary_down stale on return.+fn stop_scrollbar_drag(ctx: &egui::Context) {+    let capture = egui::Id::unique(("worksheet_scrollbar_capture", ctx.viewport_id()));+    let Some(mut state) = ctx.data(|d| d.get_temp::<ScrollbarCapture>(capture)) else { return };+    if ctx.dragged_id().is_none_or(|dragged| dragged == state.id) {+        if ctx.input(|i| i.pointer.primary_down()) {+            // A logic-only tick cannot process a release. Remember the cancellation+            // until the next full pass also clears the old pending click target.+            state.cancelled = true;+            ctx.data_mut(|d| d.insert_temp(capture, state));+        } else {+            ctx.data_mut(|d| d.remove::<ScrollbarCapture>(capture));+        }+        ctx.stop_dragging();+    } else {+        ctx.data_mut(|d| d.remove::<ScrollbarCapture>(capture));+    }+}++/// Logic-only ticks refresh raw focus but retain the previous full pass's UI events.+pub(crate) fn cancel_scrollbar_drag(ctx: &egui::Context) {+    let key = egui::Id::unique(("worksheet_scrollbar_capture", ctx.viewport_id()));+    let Some(mut state) = ctx.data(|d| d.get_temp::<ScrollbarCapture>(key)) else { return };+    if ctx.input(|i| i.raw.focused) {+        if !state.was_focused {+            state.was_focused = true;+            ctx.data_mut(|d| d.insert_temp(key, state));+        }+    } else if state.was_focused {+        stop_scrollbar_drag(ctx);+    }+}++fn cancel_scrollbar_capture(ctx: &egui::Context, release_on_pointer_gone: bool) {+    let capture = egui::Id::unique(("worksheet_scrollbar_capture", ctx.viewport_id()));+    let Some(state) = ctx.data(|d| d.get_temp::<ScrollbarCapture>(capture)) else { return };+    let frame = ctx.cumulative_frame_nr();+    let cancelled = ctx.input(|i| {+        // Web focus polling can append Focused(false) after pointerdown before+        // the canvas ever gains focus. That is not a loss of this new gesture's focus.+        let focus_seen = state.was_focused || i.events.iter().any(|event| matches!(event, egui::Event::WindowFocused(true)));+        let interrupted = i.events.iter().rev().find_map(|event| match event {+            // Only a capture born this frame can belong to a press after an interruption.+            egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: true, .. } if state.started == frame => Some(false),+            egui::Event::WindowFocused(false) if focus_seen => Some(true),+            egui::Event::PointerGone if release_on_pointer_gone => Some(true),+            _ => None,+        });+        (state.was_focused && (!i.raw.focused || !i.focused)) || !i.pointer.primary_down() || interrupted.unwrap_or(false)+    });+    if cancelled {+        stop_scrollbar_drag(ctx);+    }+}++fn scrollbar_dragging(ui: &egui::Ui, resp: &egui::Response, release_on_pointer_gone: bool) -> bool {+    if ui.ctx().plugin_opt::<ScrollbarInput>().is_none() {+        ui.ctx().add_plugin(ScrollbarInput);+    }+    if (resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down())) || resp.dragged_by(egui::PointerButton::Primary) {+        let capture = egui::Id::unique(("worksheet_scrollbar_capture", ui.ctx().viewport_id()));+        let frame = ui.ctx().cumulative_frame_nr();+        // Web pointerdown can precede canvas focus. Only a subsequent loss of+        // observed focus cancels that gesture.+        let was_focused = ui.input(|i| i.raw.focused && i.focused);+        ui.ctx().data_mut(|d| {+            if d.get_temp::<ScrollbarCapture>(capture).is_none_or(|state| state.id != resp.id) {+                d.insert_temp(capture, ScrollbarCapture { id: resp.id, started: frame, release_on_pointer_gone, cancelled: false, was_focused });+            }+        });+    }+    cancel_scrollbar_capture(ui.ctx(), release_on_pointer_gone);+    resp.dragged() && ui.ctx().dragged_id() == Some(resp.id)+}+ /// The worksheet is painted virtually, so it needs its own scrollbar rather than a /// ScrollArea containing all million rows. Keep the scroll extent stable during a drag. fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, track_x: (f32, f32), limit: f32, t: &Tokens) {@@ -348,19 +485,20 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G     let extent = (content - visible).max(scroll).max(0.0).min(limit);     let id = ui.scope_id().with("vscroll");     let resp = ui.interact(track, id, Sense::click_and_drag());+    let dragging = scrollbar_dragging(ui, &resp, app.services.scrollbar_release_on_pointer_gone);     let drag_id = id.with("extent");     if resp.drag_started() {         ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent));     }-    let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::<f32>(drag_id)).unwrap_or(extent) } else { extent };+    let extent = if dragging { ui.ctx().data(|d| d.get_temp::<f32>(drag_id)).unwrap_or(extent) } else { extent };     let thumb_h = (track.height() * visible / (extent + visible)).clamp(24.0_f32.min(track.height()), track.height());     let travel = track.height() - thumb_h;     let thumb_top = track.top() + if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 };     let thumb = Rect::from_min_size(pos2(track.left() + 3.0, thumb_top), vec2((track.width() - 6.0).max(0.0), thumb_h));     ui.painter().rect_filled(track, 0.0, t.tab_bar);-    ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled });+    ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || dragging { t.text_dim } else { t.text_disabled });     let mut next = scroll;-    if resp.dragged() && travel > 0.0 {+    if dragging && travel > 0.0 {         next += resp.drag_delta().y / travel * extent;     } else if resp.clicked()         && let Some(p) = resp.interact_pointer_pos()@@ -401,11 +539,12 @@ pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track:     let extent = (content - visible).max(scroll).max(0.0);     let id = ui.scope_id().with("hscroll");     let resp = ui.interact(track, id, Sense::click_and_drag());+    let dragging = scrollbar_dragging(ui, &resp, app.services.scrollbar_release_on_pointer_gone);     let drag_id = id.with("extent");     if resp.drag_started() {         ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent));     }-    let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::<f32>(drag_id)).unwrap_or(extent) } else { extent };+    let extent = if dragging { ui.ctx().data(|d| d.get_temp::<f32>(drag_id)).unwrap_or(extent) } else { extent };     let thumb_w = (track.width() * visible / (extent + visible).max(1.0)).clamp(24.0_f32.min(track.width()), track.width());     let travel = track.width() - thumb_w;     let offset = if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 };@@ -414,9 +553,9 @@ pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track:     let thumb_left = if geo.rtl { track.right() - thumb_w - offset } else { track.left() + offset };     let thumb = Rect::from_min_size(pos2(thumb_left, track.top() + 3.0), vec2(thumb_w, (track.height() - 6.0).max(0.0)));     ui.painter().rect_filled(track, 0.0, t.tab_bar);-    ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled });+    ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || dragging { t.text_dim } else { t.text_disabled });     let mut next = scroll;-    if resp.dragged() && travel > 0.0 {+    if dragging && travel > 0.0 {         next += dir * resp.drag_delta().x / travel * extent;     } else if resp.clicked()         && let Some(p) = resp.interact_pointer_pos()diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs--- a/crates/ui-egui/src/services.rs+++ b/crates/ui-egui/src/services.rs@@ -3,6 +3,9 @@ use super::*; /// Host services the platform shell provides (file pickers, opening URLs). #[derive(Default)] pub struct Services {+    /// Native Wayland only: its event bridge can lose button releases after `PointerGone`.+    /// Cancel worksheet scrollbar capture there; other hosts keep held drags across window exit.+    pub scrollbar_release_on_pointer_gone: bool,     /// Desktop: discover configured printers on a worker.     pub printers: Option<printing::Discover>,     /// Desktop: submit the PDF with the chosen settings on a worker.diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs--- a/crates/ui-egui/tests/it/main.rs+++ b/crates/ui-egui/tests/it/main.rs@@ -79,6 +79,7 @@ mod ribbon_layout; mod right_to_left; mod scroll_position; mod scrollbar_column_fill;+mod scrollbar_release; mod selection; mod selection_fill; mod selection_glide;diff --git a/crates/ui-egui/tests/it/scrollbar_release.rs b/crates/ui-egui/tests/it/scrollbar_release.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/scrollbar_release.rs@@ -0,0 +1,580 @@+//! Scrollbar capture must not outlive the pointer gesture that started it.++use crate::common::Harness;+use egui::{Event, Modifiers, PointerButton, Pos2, Vec2, pos2, vec2};+use gridcraft_engine::core::CellRef;++#[derive(Clone, Copy, Debug)]+enum Axis {+    Vertical,+    Horizontal,+}++impl Axis {+    fn start(self, h: &Harness) -> Pos2 {+        match self {+            Self::Vertical => pos2(h.state().grid.rect.unwrap().right() + 7.0, h.state().grid.cells_rect.unwrap().top() + 8.0),+            Self::Horizontal => {+                let track = h.state().grid.hscroll.unwrap();+                pos2(track.left() + 6.0, track.center().y)+            }+        }+    }++    fn movement(self) -> Vec2 {+        match self {+            Self::Vertical => vec2(0.0, 40.0),+            Self::Horizontal => vec2(40.0, 0.0),+        }+    }++    fn outside_track(self, p: Pos2) -> Pos2 {+        match self {+            Self::Vertical => pos2(p.x - 150.0, p.y),+            Self::Horizontal => pos2(p.x, p.y - 150.0),+        }+    }++    fn scroll(self, h: &Harness) -> f32 {+        match self {+            Self::Vertical => h.state().view().scroll.y,+            Self::Horizontal => h.state().view().scroll.x,+        }+    }+}++fn events(h: &mut Harness, events: impl IntoIterator<Item = Event>) {+    h.input_mut().events.extend(events);+    h.run_steps(2);+}++fn button(pos: Pos2, pressed: bool) -> Event {+    Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }+}++fn begin_drag(h: &mut Harness, axis: Axis) -> Pos2 {+    let start = axis.start(h);+    events(h, [Event::PointerMoved(start), button(start, true)]);+    let p = start + axis.movement();+    events(h, [Event::PointerMoved(p)]);+    assert!(axis.scroll(h) > 0.0, "{axis:?}: the thumb drag started");+    p+}++#[test]+fn a_press_before_canvas_focus_can_start_a_scrollbar_drag() {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let mut h = crate::support::blank_harness(4);+        let p = axis.start(&h);+        h.input_mut().focused = false;+        events(&mut h, [Event::PointerMoved(p), button(p, true)]);+        // Web pointerdown can run the app before mousedown focuses the canvas.+        h.input_mut().focused = true;+        events(&mut h, [Event::WindowFocused(true), Event::PointerMoved(p + axis.movement())]);+        assert!(axis.scroll(&h) > 0.0, "{axis:?}: gaining focus must preserve the activating drag");+        let before = axis.scroll(&h);+        h.input_mut().focused = false;+        events(&mut h, []);+        h.input_mut().focused = true;+        events(&mut h, [Event::PointerMoved(p + axis.movement() * 2.0)]);+        assert_eq!(axis.scroll(&h), before, "{axis:?}: later focus loss still cancels the gesture");+    }+}++#[test]+fn a_track_press_before_canvas_focus_still_pages_on_release() {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let mut h = crate::support::blank_harness(4);+        let p = track_end(axis, &h);+        h.input_mut().focused = false;+        events(&mut h, [Event::PointerMoved(p), button(p, true)]);+        h.input_mut().focused = true;+        events(&mut h, [Event::WindowFocused(true), button(p, false)]);+        assert!(axis.scroll(&h) > 0.0, "{axis:?}: gaining focus must preserve the activating track click");+    }+}++#[test]+fn polling_canvas_focus_after_pointerdown_preserves_the_activating_press() {+    for track_click in [false, true] {+        for axis in [Axis::Vertical, Axis::Horizontal] {+            let mut h = crate::support::blank_harness(4);+            let p = if track_click { track_end(axis, &h) } else { axis.start(&h) };+            // eframe starts with raw.focused=true. Its pointerdown handler queues+            // the press, then update_focus discovers the canvas is not focused.+            h.input_mut().focused = false;+            events(&mut h, [Event::PointerMoved(p), button(p, true), Event::WindowFocused(false)]);+            h.input_mut().focused = true;+            let next = if track_click { button(p, false) } else { Event::PointerMoved(p + axis.movement()) };+            events(&mut h, [Event::WindowFocused(true), next]);+            assert!(axis.scroll(&h) > 0.0, "{axis:?}: canvas focus polling must preserve activating click={track_click}");+        }+    }+}++#[test]+fn focus_acquired_and_lost_after_a_new_press_in_one_batch_still_cancels_it() {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let mut h = crate::support::blank_harness(4);+        let p = axis.start(&h);+        h.input_mut().focused = false;+        events(&mut h, [Event::PointerMoved(p), button(p, true), Event::WindowFocused(true), Event::WindowFocused(false)]);+        h.input_mut().focused = true;+        events(&mut h, [Event::WindowFocused(true), Event::PointerMoved(p + axis.movement())]);+        assert_eq!(axis.scroll(&h), 0.0, "{axis:?}: observed acquisition followed by loss must cancel even in the press batch");+    }+}++#[test]+fn focus_acquired_during_hidden_ticks_is_tracked_before_a_later_loss() {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let mut h = crate::support::blank_harness(4);+        let p = axis.start(&h);+        h.input_mut().focused = false;+        events(&mut h, [Event::PointerMoved(p), button(p, true)]);+        events(&mut h, [Event::PointerMoved(p + axis.movement())]);+        assert!(axis.scroll(&h) > 0.0, "{axis:?}: an unfocused activating drag must continue");+        let ctx = h.ctx.clone();+        let _ = ctx.run_logic(&egui::RawInput { focused: true, ..Default::default() }, |ctx| h.state_mut().logic(ctx));+        let _ = ctx.run_logic(&egui::RawInput { focused: false, ..Default::default() }, |ctx| h.state_mut().logic(ctx));+        assert!(ctx.dragged_id().is_none(), "{axis:?}: the later hidden focus loss ends capture");+        let stopped = axis.scroll(&h);+        h.input_mut().focused = true;+        events(&mut h, [Event::PointerMoved(p + axis.movement() * 2.0)]);+        assert_eq!(axis.scroll(&h), stopped, "{axis:?}: the hidden cancellation survives until input is processed");+    }+}++#[test]+fn non_primary_thumb_drags_keep_their_existing_behavior() {+    for button_kind in [PointerButton::Secondary, PointerButton::Middle] {+        for axis in [Axis::Vertical, Axis::Horizontal] {+            let mut h = crate::support::blank_harness(4);+            let p = axis.start(&h);+            events(&mut h, [Event::PointerMoved(p), Event::PointerButton { pos: p, button: button_kind, pressed: true, modifiers: Modifiers::NONE }]);+            events(&mut h, [Event::PointerMoved(p + axis.movement())]);+            assert!(axis.scroll(&h) > 0.0, "{axis:?}: {button_kind:?} dragging must retain baseline behavior");+        }+    }+}++fn dragging(axis: Axis) -> (Harness, Pos2) {+    dragging_with_policy(axis, false)+}++fn dragging_with_policy(axis: Axis, release_on_pointer_gone: bool) -> (Harness, Pos2) {+    let mut h = crate::support::blank_harness(4);+    h.state_mut().services.scrollbar_release_on_pointer_gone = release_on_pointer_gone;+    let p = begin_drag(&mut h, axis);+    (h, p)+}++#[test]+fn releasing_outside_the_window_ends_both_scrollbar_drags() {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let (mut h, p) = dragging(axis);+        let outside = match axis {+            Axis::Vertical => pos2(1250.0, p.y),+            Axis::Horizontal => pos2(p.x, 850.0),+        };+        events(&mut h, [Event::PointerMoved(outside), button(outside, false)]);+        let stopped = axis.scroll(&h);+        events(&mut h, [Event::PointerMoved(outside + axis.movement())]);+        assert_eq!(axis.scroll(&h), stopped, "{axis:?}: mouse movement after release must not scroll");+    }+}++#[test]+fn a_held_drag_continues_outside_the_track() {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let (mut h, p) = dragging(axis);+        let before = axis.scroll(&h);+        let outside = axis.outside_track(p) + axis.movement();+        events(&mut h, [Event::PointerMoved(outside)]);+        assert!(axis.scroll(&h) > before, "{axis:?}: leaving the track while held must keep dragging");+        events(&mut h, [button(outside, false)]);+        assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0));+    }+}++#[test]+fn losing_focus_ends_both_scrollbar_drags_without_a_release_event() {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let (mut h, p) = dragging(axis);+        events(&mut h, [Event::WindowFocused(false)]);+        let stopped = axis.scroll(&h);+        events(&mut h, [Event::WindowFocused(true), Event::PointerMoved(p + axis.movement())]);+        assert_eq!(axis.scroll(&h), stopped, "{axis:?}: focus loss must release scrollbar capture");+    }+}++#[test]+fn a_release_after_pointer_gone_ends_both_scrollbar_drags() {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let (mut h, p) = dragging(axis);+        events(&mut h, [Event::PointerGone]);+        events(&mut h, [button(p, false)]);+        let stopped = axis.scroll(&h);+        events(&mut h, [Event::PointerMoved(p + axis.movement())]);+        assert_eq!(axis.scroll(&h), stopped, "{axis:?}: returning with the button up must not scroll");+    }+}++#[test]+fn the_wayland_fallback_does_not_leave_either_scrollbar_captured_after_a_lost_release() {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let (mut h, p) = dragging_with_policy(axis, true);+        // egui-winit can discard MouseInput after CursorLeft clears its pointer position.+        events(&mut h, [Event::PointerGone]);+        let stopped = axis.scroll(&h);+        events(&mut h, [Event::PointerMoved(p + axis.movement())]);+        events(&mut h, [Event::PointerMoved(p + axis.movement() * 2.0)]);+        assert!(!h.ctx.input(|i| i.pointer.primary_down()), "the app releases the owned gesture even when the bridge missed its release");+        assert_eq!(axis.scroll(&h), stopped, "{axis:?}: a missing outside release must not leave the thumb captured");+    }+}++#[test]+fn focus_loss_cancels_capture_even_when_the_window_does_not_paint() {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let (mut h, p) = dragging(axis);+        let ctx = h.ctx.clone();+        let hidden = egui::RawInput { focused: false, events: vec![Event::WindowFocused(false)], ..Default::default() };+        assert!(ctx.dragged_id().is_some());+        // eframe's hidden tick updates raw.focused without interpreting the UI events.+        let _ = ctx.run_logic(&hidden, |ctx| h.state_mut().logic(ctx));+        assert!(ctx.dragged_id().is_none(), "{axis:?}: the logic-only tick must release capture immediately");+        let stopped = axis.scroll(&h);+        // eframe retains the unconsumed events for the next full pass.+        h.input_mut().events.extend(hidden.events);+        events(&mut h, [Event::WindowFocused(true), Event::PointerMoved(p + axis.movement())]);+        assert_eq!(axis.scroll(&h), stopped, "{axis:?}: logic-only focus loss must release capture");+    }+}++#[test]+fn hosts_with_pointer_capture_keep_a_held_drag_across_window_exit() {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let (mut h, p) = dragging(axis);+        let before = axis.scroll(&h);+        events(&mut h, [Event::PointerGone]);+        events(&mut h, [Event::PointerMoved(p + axis.movement())]);+        events(&mut h, [Event::PointerMoved(p + axis.movement() * 2.0)]);+        assert!(axis.scroll(&h) > before, "{axis:?}: a held drag must survive window exit on capture-capable hosts");+        events(&mut h, [button(p + axis.movement() * 2.0, false)]);+    }+}++#[test]+fn focus_loss_also_cancels_a_press_before_it_becomes_a_drag() {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let mut h = crate::support::blank_harness(4);+        let p = axis.start(&h);+        events(&mut h, [Event::PointerMoved(p), button(p, true)]);+        events(&mut h, [Event::WindowFocused(false)]);+        events(&mut h, [Event::WindowFocused(true), Event::PointerMoved(p + axis.movement())]);+        assert_eq!(axis.scroll(&h), 0.0, "{axis:?}: an interrupted press must not start dragging later");+    }+}++#[test]+fn a_new_press_after_pointer_loss_in_the_same_frame_still_starts_a_drag() {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let mut h = crate::support::blank_harness(4);+        h.state_mut().services.scrollbar_release_on_pointer_gone = true;+        let p = axis.start(&h);+        events(&mut h, [Event::PointerGone, Event::PointerMoved(p), button(p, true)]);+        events(&mut h, [Event::PointerMoved(p + axis.movement())]);+        assert!(axis.scroll(&h) > 0.0, "{axis:?}: an earlier pointer exit must not cancel a new press");+        events(&mut h, [button(p + axis.movement(), false)]);+    }+}++fn interrupted_capture_cannot_steal_a_new_press(interruption: &[Event]) {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let (mut h, p) = dragging_with_policy(axis, true);+        let next_press = axis.outside_track(p);+        let mut batch = interruption.to_vec();+        batch.extend([Event::PointerMoved(next_press), button(next_press, true)]);+        let ctx = h.ctx.clone();+        let mut raw = egui::RawInput { events: batch, ..Default::default() };+        h.state_mut().raw_input_hook(&ctx, &mut raw);+        events(&mut h, raw.events);+        let stopped = axis.scroll(&h);+        events(&mut h, [Event::PointerMoved(next_press + axis.movement())]);+        assert_eq!(axis.scroll(&h), stopped, "{axis:?}: the old capture must not steal a new press after interruption");+        assert_ne!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0), "{axis:?}: the new cell gesture must still work");+    }+}++#[test]+fn pointer_loss_ends_the_old_capture_before_a_new_press_in_the_same_frame() {+    interrupted_capture_cannot_steal_a_new_press(&[Event::PointerGone]);+}++#[test]+fn focus_loss_ends_the_old_capture_before_a_new_press_in_the_same_frame() {+    interrupted_capture_cannot_steal_a_new_press(&[Event::WindowFocused(false), Event::WindowFocused(true)]);+}++#[test]+fn a_focused_logic_tick_does_not_replay_old_interruption_events() {+    for interruption in [vec![Event::PointerGone], vec![Event::WindowFocused(false), Event::WindowFocused(true)]] {+        for axis in [Axis::Vertical, Axis::Horizontal] {+            let mut h = crate::support::blank_harness(4);+            h.state_mut().services.scrollbar_release_on_pointer_gone = true;+            let p = axis.start(&h);+            h.input_mut().events.extend(interruption.iter().cloned());+            h.input_mut().events.extend([Event::PointerMoved(p), button(p, true)]);+            h.run_steps(1);+            let ctx = h.ctx.clone();+            let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| h.state_mut().logic(ctx));+            events(&mut h, [Event::PointerMoved(p + axis.movement())]);+            assert!(axis.scroll(&h) > 0.0, "{axis:?}: stale events in a focused hidden tick must not cancel a valid held press");+        }+    }+}++fn child_harness(release_on_pointer_gone: bool) -> (Harness, std::sync::Arc<std::sync::Mutex<Vec<Event>>>) {+    let queued = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));+    let input = queued.clone();+    egui::Context::set_immediate_viewport_renderer(move |ctx, viewport| {+        let mut raw = egui::RawInput {+            viewport_id: viewport.ids.this,+            screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))),+            events: std::mem::take(&mut *input.lock().unwrap()),+            ..Default::default()+        };+        raw.viewports.entry(viewport.ids.this).or_default().parent = Some(viewport.ids.parent);+        let mut draw = viewport.viewport_ui_cb;+        // As in eframe's immediate renderer, there is no App::raw_input_hook here.+        let mut out = ctx.run_ui(raw, |ui| draw(ui));+        out.textures_delta.clear(); // no renderer in this input test+    });+    let mut app = crate::common::app(crate::support::blank_session());+    app.services.scrollbar_release_on_pointer_gone = release_on_pointer_gone;+    let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(+        |ui, app: &mut gridcraft_ui_egui::SheetApp| {+            let ctx = ui.ctx().clone();+            app.logic(&ctx);+            ctx.show_viewport_immediate(egui::ViewportId::from_hash_of("scrollbar child"), Default::default(), |child, _| app.ui(child));+        },+        app,+    );+    h.ctx.set_embed_viewports(false);+    h.run_steps(4);+    (h, queued)+}++fn child_events(h: &mut Harness, queued: &std::sync::Mutex<Vec<Event>>, events: impl IntoIterator<Item = Event>) {+    queued.lock().unwrap().extend(events);+    h.run_steps(2);+}++#[test]+fn immediate_child_windows_keep_the_new_gesture_after_scrollbar_interruption() {+    for interruption in [vec![Event::PointerGone], vec![Event::WindowFocused(false), Event::WindowFocused(true)]] {+        for axis in [Axis::Vertical, Axis::Horizontal] {+            let (mut h, queued) = child_harness(true);+            let start = axis.start(&h);+            child_events(&mut h, &queued, [Event::PointerMoved(start), button(start, true)]);+            let p = start + axis.movement();+            child_events(&mut h, &queued, [Event::PointerMoved(p)]);+            assert!(axis.scroll(&h) > 0.0, "{axis:?}: the child scrollbar started dragging");+            let next_press = axis.outside_track(p);+            let mut batch = interruption.clone();+            batch.extend([Event::PointerMoved(next_press), button(next_press, true)]);+            child_events(&mut h, &queued, batch);+            let stopped = axis.scroll(&h);+            child_events(&mut h, &queued, [Event::PointerMoved(next_press + axis.movement())]);+            assert_eq!(axis.scroll(&h), stopped, "{axis:?}: the child scrollbar stopped");+            assert_ne!(+                h.state().session.active().unwrap().selection.active,+                CellRef::new(0, 0),+                "{axis:?}: the child's new cell gesture still works"+            );+        }+    }+}++#[test]+fn immediate_child_windows_preserve_held_drags_without_the_wayland_fallback() {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let (mut h, queued) = child_harness(false);+        let start = axis.start(&h);+        child_events(&mut h, &queued, [Event::PointerMoved(start), button(start, true)]);+        let p = start + axis.movement();+        child_events(&mut h, &queued, [Event::PointerMoved(p)]);+        let before = axis.scroll(&h);+        child_events(&mut h, &queued, [Event::PointerGone]);+        child_events(&mut h, &queued, [Event::PointerMoved(p + axis.movement())]);+        child_events(&mut h, &queued, [Event::PointerMoved(p + axis.movement() * 2.0)]);+        assert!(axis.scroll(&h) > before, "{axis:?}: a held child drag must survive window exit on capture-capable hosts");+    }+}++#[test]+fn a_child_release_and_repress_on_the_same_thumb_starts_a_fresh_capture() {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let (mut h, queued) = child_harness(true);+        let p = axis.start(&h);+        child_events(&mut h, &queued, [Event::PointerMoved(p), button(p, true)]);+        child_events(&mut h, &queued, [button(p, false), Event::PointerGone, Event::PointerMoved(p), button(p, true)]);+        child_events(&mut h, &queued, [Event::PointerMoved(p + axis.movement())]);+        assert!(axis.scroll(&h) > 0.0, "{axis:?}: a delivered release must give the next child press a fresh capture");+    }+}++#[test]+fn a_child_pending_thumb_press_cannot_steal_a_new_gesture_after_interruption() {+    for interruption in [vec![Event::PointerGone], vec![Event::WindowFocused(false), Event::WindowFocused(true)]] {+        for axis in [Axis::Vertical, Axis::Horizontal] {+            let (mut h, queued) = child_harness(true);+            let p = axis.start(&h);+            child_events(&mut h, &queued, [Event::PointerMoved(p), button(p, true)]);+            let next_press = axis.outside_track(p) + axis.movement() * 2.0;+            let mut batch = interruption.clone();+            batch.extend([Event::PointerMoved(next_press), button(next_press, true)]);+            child_events(&mut h, &queued, batch);+            child_events(&mut h, &queued, [Event::PointerMoved(next_press + axis.movement())]);+            assert_eq!(axis.scroll(&h), 0.0, "{axis:?}: the interrupted pending thumb must stay stopped");+            assert_ne!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0), "{axis:?}: the new child gesture must work");+        }+    }+}++fn track_end(axis: Axis, h: &Harness) -> Pos2 {+    match axis {+        Axis::Vertical => pos2(h.state().grid.rect.unwrap().right() + 7.0, h.state().grid.cells_rect.unwrap().bottom() - 12.0),+        Axis::Horizontal => {+            let track = h.state().grid.hscroll.unwrap();+            pos2(track.right() - 6.0, track.center().y)+        }+    }+}++#[test]+fn cancelling_a_child_pending_track_press_does_not_page() {+    for interruption in [Event::PointerGone, Event::WindowFocused(false)] {+        for axis in [Axis::Vertical, Axis::Horizontal] {+            let (mut h, queued) = child_harness(true);+            let p = track_end(axis, &h);+            child_events(&mut h, &queued, [Event::PointerMoved(p), button(p, true)]);+            queued.lock().unwrap().push(interruption.clone());+            h.run_steps(1);+            assert_eq!(axis.scroll(&h), 0.0, "{axis:?}: cancelling must not turn the old press into a track click");+            h.ctx.input_for(egui::ViewportId::from_hash_of("scrollbar child"), |input| {+                assert!(!input.pointer.primary_clicked(), "cancellation must not generate a click anywhere in the app");+                assert_eq!(input.pointer.delta(), Vec2::ZERO, "cancellation must not expose pointer movement");+            });+        }+    }+}++#[test]+fn a_new_child_click_after_cancellation_is_not_a_double_click() {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let (mut h, queued) = child_harness(true);+        let p = axis.start(&h);+        child_events(&mut h, &queued, [Event::PointerMoved(p), button(p, true)]);+        let next_press = p + axis.movement().normalized() * 2.0;+        queued.lock().unwrap().extend([+            Event::WindowFocused(false),+            Event::WindowFocused(true),+            Event::PointerMoved(next_press),+            button(next_press, true),+            button(next_press, false),+        ]);+        h.run_steps(1);+        h.ctx.input_for(egui::ViewportId::from_hash_of("scrollbar child"), |input| {+            assert!(input.pointer.primary_clicked(), "the new click must work");+            assert!(!input.pointer.button_double_clicked(PointerButton::Primary), "the cancelled press must not count towards a double-click");+        });+    }+}++#[test]+fn ordinary_child_track_clicks_page_when_released_in_a_later_frame() {+    for fallback in [false, true] {+        for axis in [Axis::Vertical, Axis::Horizontal] {+            let (mut h, queued) = child_harness(fallback);+            let p = track_end(axis, &h);+            child_events(&mut h, &queued, [Event::PointerMoved(p), button(p, true)]);+            assert_eq!(axis.scroll(&h), 0.0, "the track waits for the release");+            child_events(&mut h, &queued, [button(p, false)]);+            assert!(axis.scroll(&h) > 0.0, "{axis:?}: an ordinary child track click must page");+        }+    }+}++#[test]+fn a_child_track_click_completed_before_pointer_exit_still_pages() {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let (mut h, queued) = child_harness(true);+        let p = track_end(axis, &h);+        child_events(&mut h, &queued, [Event::PointerMoved(p), button(p, true)]);+        child_events(&mut h, &queued, [button(p, false), Event::PointerGone]);+        assert!(axis.scroll(&h) > 0.0, "{axis:?}: a release before exit is a completed click");+    }+}++#[test]+fn a_root_pending_thumb_press_does_not_steal_a_later_cell_click() {+    for interruption in [vec![Event::PointerGone], vec![Event::WindowFocused(false), Event::WindowFocused(true)]] {+        for axis in [Axis::Vertical, Axis::Horizontal] {+            let mut h = crate::support::blank_harness(4);+            h.state_mut().services.scrollbar_release_on_pointer_gone = true;+            let p = axis.start(&h);+            events(&mut h, [Event::PointerMoved(p), button(p, true)]);+            let next_press = axis.outside_track(p) + axis.movement() * 2.0;+            let mut batch = interruption.clone();+            batch.extend([Event::PointerMoved(next_press), button(next_press, true), button(next_press, false)]);+            events(&mut h, batch);+            assert_eq!(axis.scroll(&h), 0.0, "{axis:?}: the old thumb must stay stopped");+            assert_ne!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0), "{axis:?}: the new root click must select its cell");+        }+    }+}++#[test]+fn a_hidden_root_focus_loss_does_not_let_a_pending_press_steal_the_next_click() {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let mut h = crate::support::blank_harness(4);+        let p = axis.start(&h);+        events(&mut h, [Event::PointerMoved(p), button(p, true)]);+        let ctx = h.ctx.clone();+        let hidden = egui::RawInput { focused: false, events: vec![Event::WindowFocused(false)], ..Default::default() };+        let _ = ctx.run_logic(&hidden, |ctx| h.state_mut().logic(ctx));+        h.input_mut().events.extend(hidden.events);+        let next_press = axis.outside_track(p) + axis.movement() * 2.0;+        events(&mut h, [Event::WindowFocused(true), Event::PointerMoved(next_press), button(next_press, true), button(next_press, false)]);+        assert_eq!(axis.scroll(&h), 0.0, "{axis:?}: hidden cancellation must not page the old thumb");+        assert_ne!(+            h.state().session.active().unwrap().selection.active,+            CellRef::new(0, 0),+            "{axis:?}: the next root click must work after a hidden tick"+        );+    }+}++#[test]+fn a_real_child_track_click_after_interruption_in_the_same_batch_still_pages() {+    for axis in [Axis::Vertical, Axis::Horizontal] {+        let (mut h, queued) = child_harness(true);+        let p = axis.start(&h);+        child_events(&mut h, &queued, [Event::PointerMoved(p), button(p, true)]);+        let next_press = track_end(axis, &h);+        child_events(+            &mut h,+            &queued,+            [+                Event::WindowFocused(false),+                Event::WindowFocused(true),+                Event::PointerMoved(next_press),+                button(next_press, true),+                button(next_press, false),+            ],+        );+        assert!(axis.scroll(&h) > 0.0, "{axis:?}: cancellation must not swallow a real later track click");+    }+}diff --git a/docs/ui-parity.md b/docs/ui-parity.md--- a/docs/ui-parity.md+++ b/docs/ui-parity.md@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (worksheet scrollbar capture cleanup, #2748) · **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@@ -71,6 +71,7 @@ this logic, with one undo step. | Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial | | Quick Analysis (Ctrl+Q) | missing | | Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); dragging the handle at the scrollbar's left edge gives the tabs more or less of the bar; each workbook keeps its share while it is open (not saved in the file) |+| Worksheet scrollbar capture | Both axes end a drag on observed button-up or focus loss, including when the window only runs logic. Native Wayland also cancels on `PointerGone`: the pinned bridge can drop an outside release and keep reporting the button down. This fallback can end a held drag at the window edge on Wayland; Windows, macOS, X11 and web keep capture across pointer exit (#2748). Ubuntu/Wayland runtime behavior is unverified. | | Text overflow into empty neighbours, wrap, rotation, shrink to fit | done |  ## Keyboard@@ -187,6 +188,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Worksheet scrollbar capture cleanup and the native Wayland pointer-exit fallback (#2748) | | 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty | | 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified | | 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list |