diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index e52a0fc..c607d97 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -145,6 +145,8 @@ pub struct GridState { pub hf_start: bool, /// The grid geometry of the last frame (hit-testing for agents and tests). pub geo: Option, + /// The horizontal scrollbar's track in the sheet tab bar (hit-testing for tests). + pub hscroll: Option, /// Ctrl+wheel zoom in progress (see [`WheelZoom`]). pub wheel_zoom: Option, } @@ -299,6 +301,13 @@ impl Geo { None => (sh.row_top(row) - sh.row_top(self.fr)) as f32, } } + /// Scroll offset (in `scroll` units) at which `col` reaches the left of the scrolled area. + pub fn scroll_left_of(&self, sh: &Sheet, col: u32) -> f32 { + match &self.paper { + Some(pg) => pg.col_x(col) as f32, + None => (sh.col_left(col) - sh.col_left(self.fc)) as f32, + } + } /// Screen x for a logical x (and back): identity unless the sheet is right-to-left. pub fn mirror_x(&self, x: f32) -> f32 { if self.rtl { self.rect.left() + self.rect.right() - x } else { x } @@ -857,6 +866,63 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G } } +/// The horizontal counterpart of `vertical_scrollbar`, drawn in the sheet tab bar as in Excel. The tab +/// bar is laid out before the grid, so this reads the last frame's geometry and leaves clamping the +/// far end to the grid, which runs later in the same frame. +pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track: Rect, t: &Tokens) { + app.grid.hscroll = Some(track); + if track.height() <= 0.0 || track.width() <= 0.0 { + return; + } + let Some(geo) = app.grid.geo.clone() else { return }; + let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return }; + let Some(sh) = wb.active() else { return }; + let visible = ((geo.cells.width() - geo.frozen_w) / geo.z).max(0.0); + let scroll = app.view().scroll.x; + let last = sh.used_range().map(|r| r.end.col.saturating_add(10)).unwrap_or(26).clamp(26, MAX_COLS - 1); + let content = geo.scroll_left_of(sh, last); + let extent = (content - visible).max(scroll).max(0.0); + let id = ui.id().with("hscroll"); + let resp = ui.interact(track, id, Sense::click_and_drag()); + 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::(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 }; + // A right-to-left sheet scrolls towards the left, so its thumb starts at the right end. + let dir = if geo.rtl { -1.0 } else { 1.0 }; + 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 }); + let mut next = scroll; + if resp.dragged() && travel > 0.0 { + next += dir * resp.drag_delta().x / travel * extent; + } else if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + if p.x < thumb.left() { + next -= dir * visible; + } else if p.x > thumb.right() { + next += dir * visible; + } + } + if resp.hovered() { + let d = ui.input(|i| i.smooth_scroll_delta); + next -= (if d.x != 0.0 { dir * d.x } else { d.y }) / geo.z; + } + next = next.max(0.0); + if next != scroll { + if let Some(v) = app.view_mut() { + v.scroll.x = next; + } + ui.ctx().request_repaint(); + } +} + pub(crate) fn wrapped_cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { let pad_v = 1.5 * z; let y_top = rect.top() + pad_v; diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index f9669d6..a8c33a4 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -68,9 +68,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { .filter(|(_, s)| s.visibility == Visibility::Visible) .map(|(i, s)| (i, s.name.clone(), s.tab_color.and_then(|c| c.resolve(&d.wb.theme)), i == d.wb.active_sheet)) .collect(); - // Keep worksheet tabs in their own horizontally scrollable - // viewport, leaving room for the New Sheet button and grid scrollbar. - let tabs_width = (ui.available_width() - 180.0).max(100.0); + // Keep worksheet tabs in their own horizontally scrollable viewport. As in Excel, the + // tabs get 60% of the bar and the grid's horizontal scrollbar the rest. + let tabs_width = (ui.available_width() * 0.6).max(100.0); // Reveal the selected sheet after navigation or layout changes, but // allow browsing other tabs without snapping back on every frame. let reveal_key = egui::Id::new((d.uid, d.wb.active_sheet, &sheets, tabs_width.to_bits())); @@ -215,38 +215,11 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { if crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, "New sheet (⇧F11)", vec2(26.0, 26.0)).clicked() { app.run_or_alert("home.insertSheet", json!({})); } - // Horizontal position indicator (a slim scrollbar). + // The track ends where the grid does, so it stops under the vertical scrollbar. let avail = ui.available_rect_before_wrap(); - if avail.width() > 120.0 { - let bar = Rect::from_min_max(pos2(avail.left() + 40.0, avail.center().y - 4.0), pos2(avail.right() - 16.0, avail.center().y + 4.0)); - let used_w = app - .session - .active() - .and_then(|d| d.wb.active().and_then(|s| s.used_range().map(|u| s.col_left(u.end.col + 10) as f32))) - .unwrap_or(2000.0) - .max(2000.0); - let view = app.view(); - let vis = app.grid.cells_rect.map(|r| r.width()).unwrap_or(1000.0); - let total = used_w.max(view.scroll.x + vis); - let frac0 = (view.scroll.x / total).clamp(0.0, 1.0); - let frac1 = ((view.scroll.x + vis) / total).clamp(0.0, 1.0); - let thumb = Rect::from_min_max( - pos2(bar.left() + bar.width() * frac0, bar.top()), - pos2(bar.left() + bar.width() * frac1.max(frac0 + 0.03), bar.bottom()), - ); - let resp = ui.interact(bar, ui.id().with("hscroll"), Sense::click_and_drag()); - ui.painter().rect_filled( - thumb, - 4.0, - if resp.hovered() || resp.dragged() { Color32::from_gray(150) } else { Color32::from_gray(190) }, - ); - if resp.dragged() { - let dx = resp.drag_delta().x / bar.width() * total; - if let Some(v) = app.view_mut() { - v.scroll.x = (v.scroll.x + dx).max(0.0); - } - } - } + let right = app.grid.rect.map_or(avail.right(), |r| r.right()).min(avail.right()); + let track = Rect::from_min_max(pos2(avail.left() + 8.0, avail.center().y - 7.0), pos2(right, avail.center().y + 7.0)); + crate::grid::horizontal_scrollbar(app, ui, track, &t); }); }); } diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index b7b66bd..0958c8c 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -58,6 +58,72 @@ fn vertical_scrollbar_pages_and_drags_without_changing_selection() { assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0)); } +/// The first column painted right of the frozen ones. +fn first_scrolled_col(h: &egui_kittest::Harness<'static, SheetApp>) -> u32 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = app.grid.geo.as_ref().unwrap(); + geo.col_at(sheet, geo.cells.left() + geo.frozen_w + 0.5) +} + +fn click(h: &mut egui_kittest::Harness<'static, SheetApp>, p: egui::Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(2); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +#[test] +fn horizontal_scrollbar_pages_and_drags_across_a_wide_sheet() { + let mut wb = gridcraft_engine::model::Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + for c in 0..26 { + sh.set_value(CellRef::new(0, c), Value::text(format!("col {}", c + 1))); + } + // Column A frozen: a page is the scrolling part only. + sh.freeze = Some((0, 1)); + let mut s = Session::new(); + s.add_document(gridcraft_engine::DocState::new(wb, None, "Book1".into())); + let mut h = harness(s); + let track = h.state().grid.hscroll.expect("the tab bar has a horizontal scrollbar"); + assert!(track.width() >= 200.0, "the scrollbar gets a usable share of the tab bar: {track:?}"); + assert_eq!(first_scrolled_col(&h), 1); + + let page = { + let geo = h.state().grid.geo.as_ref().unwrap(); + (geo.cells.width() - geo.frozen_w) / geo.z + }; + click(&mut h, egui::pos2(track.right() - 6.0, track.center().y)); + let scrolled = h.state().view().scroll.x; + assert!((scrolled - page).abs() < 0.5, "clicking the track right of the thumb scrolls one page: {scrolled} vs {page}"); + assert!(first_scrolled_col(&h) > 1); + let geo = h.state().grid.geo.clone().unwrap(); + let sheet = h.state().session.active().unwrap().wb.active().unwrap().clone(); + assert_eq!(geo.x(&sheet, 0), geo.cells.left(), "the frozen column stays put"); + click(&mut h, egui::pos2(track.right() - 6.0, track.center().y)); + let further = h.state().view().scroll.x; + click(&mut h, egui::pos2(track.left() + 2.0, track.center().y)); + assert!(h.state().view().scroll.x < further, "clicking left of the thumb scrolls back"); + + h.state_mut().view_mut().unwrap().scroll.x = 0.0; + h.run_steps(2); + let start = egui::pos2(track.left() + 6.0, track.center().y); + h.input_mut().events.push(Event::PointerMoved(start)); + h.input_mut().events.push(Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(2); + let end = start + egui::vec2(120.0, 0.0); + h.input_mut().events.push(Event::PointerMoved(end)); + h.run_steps(2); + let dragged = h.state().view().scroll.x; + assert!(first_scrolled_col(&h) > 1, "dragging the thumb right changes the first visible column"); + h.run_steps(3); + assert_eq!(h.state().view().scroll.x, dragged, "holding the thumb still must not keep scrolling"); + h.input_mut().events.push(Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0)); +} + fn key(h: &mut egui_kittest::Harness<'static, SheetApp>, k: Key, m: Modifiers) { h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 747cb92..dfac4c5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,5 +1,6 @@ # UI parity +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 > **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 @@ -62,7 +63,7 @@ this logic, with one undo step. | Move and resize charts, pictures and shapes | charts can't be resized (#190) | | 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 reported missing (#176) | +| 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); the tabs/scrollbar split can't be dragged yet | | Text overflow into empty neighbours, wrap, rotation, shrink to fit | done | ## Keyboard @@ -164,6 +165,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | | 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) |