Merge pull request #849 from marcoarnulfo:feat/horizontal-scrollbar
This commit is contained in:
commit
2b36221890
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue