#681: Reveal the selected worksheet tab after navigation

This commit is contained in:
derechopiano 2026-10-12 13:55:18 +01:00
parent b3acae6b77
commit f7f04df4d7
4 changed files with 192 additions and 3 deletions

View File

@ -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::<egui::Id>(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);

View File

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

View File

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

View File

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