diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 0a298af..e4de246 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -312,7 +312,8 @@ fn display_text( pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let rect = ui.available_rect_before_wrap(); + let available = ui.available_rect_before_wrap(); + let rect = Rect::from_min_max(available.min, pos2((available.right() - 14.0).max(available.left()), available.bottom())); let resp = ui.allocate_rect(rect, Sense::click_and_drag()); app.grid.rect = Some(rect); let Some(d) = app.session.active() else { return }; @@ -423,12 +424,62 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { list_picker(app, ui, &geo); header_menu(app, ui); context_menu(app, ui); + vertical_scrollbar(app, ui, sh, &geo, available.right(), max_y, &t); if app.session.clipboard.is_some() { app.grid.marching_phase = (app.grid.marching_phase + 0.5) % 8.0; ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); } } +/// 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, right: f32, limit: f32, t: &Tokens) { + let track = Rect::from_min_max(pos2(geo.rect.right(), geo.cells.top() + geo.frozen_h), pos2(right, geo.cells.bottom())); + if track.height() <= 0.0 || track.width() <= 0.0 { + return; + } + let visible = track.height() / geo.z; + let scroll = app.view().scroll.y; + let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1); + let content = (sh.row_top(last) - sh.row_top(geo.fr)) as f32; + let extent = (content - visible).max(scroll).max(0.0).min(limit); + let id = ui.id().with("vscroll"); + 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_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 }); + let mut next = scroll; + if resp.dragged() && travel > 0.0 { + next += resp.drag_delta().y / travel * extent; + } else if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + if p.y < thumb.top() { + next -= visible; + } else if p.y > thumb.bottom() { + next += visible; + } + } + if resp.hovered() { + next -= ui.input(|i| i.smooth_scroll_delta.y) / geo.z; + } + next = next.clamp(0.0, limit); + if next != scroll { + if let Some(v) = app.view_mut() { + v.scroll.y = next; + } + ui.ctx().request_repaint(); + } +} + #[allow(clippy::too_many_arguments)] fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet, rows: (u32, u32), cols: (u32, u32), cf: &mut CfCache, t: &Tokens) { let z = geo.z; diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 762cdd5..1cd0a8a 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -25,6 +25,38 @@ fn blank() -> Session { s } +#[test] +fn vertical_scrollbar_pages_and_drags_without_changing_selection() { + let mut h = harness(blank()); + let grid = h.state().grid.rect.unwrap(); + let cells = h.state().grid.cells_rect.unwrap(); + let p = egui::pos2(grid.right() + 7.0, cells.bottom() - 20.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(2); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(h.state().view().scroll.y > 0.0, "clicking the scrollbar track scrolls down"); + assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0)); + + h.state_mut().view_mut().unwrap().scroll.y = 0.0; + h.run_steps(2); + let start = egui::pos2(grid.right() + 7.0, cells.top() + 8.0); + h.input_mut().events.push(Event::PointerMoved(start)); + h.input_mut().events.push(Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(2); + let end = start + egui::vec2(0.0, 80.0); + h.input_mut().events.push(Event::PointerMoved(end)); + h.run_steps(2); + let dragged = h.state().view().scroll.y; + assert!(dragged > 0.0, "dragging the thumb scrolls down"); + h.run_steps(3); + assert_eq!(h.state().view().scroll.y, dragged, "holding the thumb still must not keep scrolling"); + h.input_mut().events.push(Event::PointerButton { pos: end, button: egui::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 });