diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index e55f575..5bd5456 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -40,6 +40,15 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { // 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); + // 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())); + let reveal_id = ui.id().with("worksheet_tabs_reveal"); + let reveal_active = ui.ctx().data_mut(|data| { + let changed = data.get_temp::(reveal_id) != Some(reveal_key); + data.insert_temp(reveal_id, reveal_key); + changed + }); ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { // Translate vertical wheel movement into horizontal // worksheet-tab scrolling. @@ -68,6 +77,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let tw = ui.painter().layout_no_wrap(name.clone(), font.clone(), t.text).size().x; let w = (tw + 32.0).max(72.0); let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag()); + if active && reveal_active { + resp.scroll_to_me(None); + } let r = r.shrink2(vec2(0.0, 1.0)); if active { ui.painter().rect_filled(Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), 4.0, t.tab_active); diff --git a/crates/ui-egui/tests/sheet_tabs.rs b/crates/ui-egui/tests/sheet_tabs.rs new file mode 100644 index 0000000..966ea60 --- /dev/null +++ b/crates/ui-egui/tests/sheet_tabs.rs @@ -0,0 +1,176 @@ +//! Overflowing worksheet tabs, exercised through their actual painted geometry and input. +use egui::{Color32, Event, Modifiers, PointerButton, Rect, Shape, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(width: f32, active: usize) -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("sheet.rename", json!({"sheet":0,"name":"Tab00"})).unwrap(); + for i in 1..30 { + s.execute("home.insertSheet", json!({"name":format!("Tab{i:02}")})).unwrap(); + } + s.execute("sheet.activate", json!({"sheet":active})).unwrap(); + let mut fonts_ready = false; + let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(width, 120.0)).build_ui_state( + move |ui, app: &mut SheetApp| { + if !fonts_ready { + ui.ctx().set_fonts(gridcraft_ui_egui::theme::font_definitions()); + fonts_ready = true; + return; + } + app.grid.cells_rect = Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(ui.max_rect().width(), 80.0))); + gridcraft_ui_egui::tabs::sheet_tabs(app, ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(20); + h +} + +fn tab(h: &Harness, name: &str) -> Option<(Rect, Rect)> { + h.output().shapes.iter().find_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == name => Some((t.visual_bounding_rect(), s.clip_rect)), + _ => None, + }) +} + +fn assert_visible(h: &Harness, name: &str) { + let (text, clip) = tab(h, name).unwrap_or_else(|| panic!("{name} was not painted in the tab viewport")); + assert!(clip.contains_rect(text), "{name} is clipped: text={text:?}, viewport={clip:?}"); +} + +fn click(h: &mut Harness, at: egui::Pos2) { + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(20); +} + +#[test] +fn opening_and_activating_late_sheets_reveals_their_tabs() { + for width in [500.0, 1000.0] { + let mut h = harness(width, 29); + assert_visible(&h, "Tab29"); + h.state_mut().run("sheet.activate", json!({"sheet":0})).unwrap(); + h.run_steps(20); + assert_visible(&h, "Tab00"); + h.state_mut().run("sheet.activate", json!({"sheet":20})).unwrap(); + h.run_steps(20); + assert_visible(&h, "Tab20"); + } +} + +#[test] +fn navigation_arrows_reveal_the_selected_tab() { + let mut h = harness(700.0, 0); + let (_, clip) = tab(&h, "Tab00").unwrap(); + let next = pos2(clip.left() - 20.0, clip.center().y); + for _ in 0..29 { + click(&mut h, next); + } + assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 29); + assert_visible(&h, "Tab29"); +} + +#[test] +fn manual_wheel_and_trackpad_browsing_does_not_change_selection_or_snap_back() { + for delta in [vec2(0.0, -350.0), vec2(-350.0, 0.0)] { + let mut h = harness(700.0, 0); + let before = h.state().session.doc().unwrap().wb.clone(); + let (_, clip) = tab(&h, "Tab00").unwrap(); + h.input_mut().events.extend([ + Event::PointerMoved(clip.center()), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + phase: egui::TouchPhase::Start, + delta: vec2(0.0, 0.0), + modifiers: Modifiers::NONE, + }, + ]); + h.step(); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + phase: egui::TouchPhase::Move, + delta, + modifiers: Modifiers::NONE, + }); + h.step(); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + phase: egui::TouchPhase::End, + delta: vec2(0.0, 0.0), + modifiers: Modifiers::NONE, + }); + h.run_steps(40); + assert!(!tab(&h, "Tab00").is_some_and(|(r, c)| c.contains_rect(r)), "gesture should browse away from the selected tab: {delta:?}"); + assert_eq!(h.state().session.doc().unwrap().wb, before, "browsing tabs must not edit the workbook"); + h.run_steps(40); + assert!(!tab(&h, "Tab00").is_some_and(|(r, c)| c.contains_rect(r)), "idle frames must not snap back"); + h.state_mut().run("sheet.activate", json!({"sheet":29})).unwrap(); + h.run_steps(20); + assert_visible(&h, "Tab29"); + } +} +#[test] +fn hidden_sheets_long_names_and_window_resize_keep_active_tab_visible() { + let mut h = harness(1000.0, 0); + for sheet in 1..10 { + h.state_mut().run("sheet.hide", json!({"sheet":sheet})).unwrap(); + } + let name = "Quarterly results and forecasts"; + h.state_mut().run("sheet.rename", json!({"sheet":29,"name":name})).unwrap(); + h.state_mut().run("sheet.activate", json!({"sheet":29})).unwrap(); + h.run_steps(20); + assert_visible(&h, name); + h.set_size(vec2(500.0, 120.0)); + h.run_steps(20); + assert_visible(&h, name); + h.set_size(vec2(1000.0, 120.0)); + h.run_steps(20); + assert_visible(&h, name); + assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 29); +} + +#[test] +fn new_sheet_button_stays_reachable_and_reveals_the_inserted_tab() { + let mut h = harness(700.0, 29); + let (_, clip) = tab(&h, "Tab29").unwrap(); + click(&mut h, pos2(clip.right() + 18.0, clip.center().y)); + let d = h.state().session.doc().unwrap(); + assert_eq!(d.wb.sheets.len(), 31); + let name = d.wb.active().unwrap().name.clone(); + assert_visible(&h, &name); +} + +#[test] +fn overflow_leaves_the_worksheet_scrollbar_reachable() { + for width in [500.0, 700.0, 1200.0] { + let mut h = harness(width, 0); + let (bar, clip) = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + Shape::Rect(r) if r.fill == Color32::from_gray(190) && r.rect.height() == 8.0 => Some((r.rect, s.clip_rect)), + _ => None, + }) + .expect("worksheet scrollbar thumb must be painted"); + assert!(clip.contains_rect(bar), "worksheet scrollbar is offscreen: {bar:?}, {clip:?}"); + let at = bar.center(); + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.step(); + h.input_mut().events.push(Event::PointerMoved(at + vec2(12.0, 0.0))); + h.step(); + assert!(h.state().view().scroll.x > 0.0, "worksheet scrollbar must respond to dragging"); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8b82092..484de9e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (stability: command-sequence fuzzing against undo/redo, XLSX round-trip and full-recalc invariants) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (sheet-tab navigation reveals the selected tab without interrupting manual scrolling) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Sheet tabs: reveal the selected tab on navigation and resize while preserving manual scrolling (#681); no score change | | 2026-10-11 | minor | Stability: command-sequence fuzz harness (`crates/engine/tests/command_sequence_fuzz.rs`) checks random command sequences against undo/redo, XLSX round-trip, full-recalc and structure invariants; the sequence bugs it found are fixed (no score change) | | 2026-10-11 | minor | Password-encrypted workbooks open (#11): Essentials file-opening work 24–36 h | | 2026-10-11 | minor | Worksheet functions 505 / 523 (96.6%; 496 working): ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 10abcbc..37fd5b2 100644 --- 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-11 · **Change:** minor (AutoFit reserves filter dropdown space, observed against Windows Excel) · **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 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 @@ -21,7 +21,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | | Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | | Formula bar with Name Box, expand, function hints | yes | yes | done | -| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | rename focus bug on macOS (#145) | +| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial | | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing |