Merge pull request #849 from marcoarnulfo:feat/horizontal-scrollbar

This commit is contained in:
Brandon Thomas 2026-10-13 01:07:18 -04:00
commit 2b36221890
4 changed files with 142 additions and 35 deletions

View File

@ -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<Geo>,
/// The horizontal scrollbar's track in the sheet tab bar (hit-testing for tests).
pub hscroll: Option<Rect>,
/// Ctrl+wheel zoom in progress (see [`WheelZoom`]).
pub wheel_zoom: Option<WheelZoom>,
}
@ -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::<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 };
// 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;

View File

@ -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);
});
});
}

View File

@ -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 });

View File

@ -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) |