Files
gridcraft/crates/ui-egui/tests/it/tab_split.rs
T
Hector ViovandClaude Opus 5.5 fe6ab7c5c0 #3101: clear the tab bar's cached rectangles when a control is hidden
Addresses review comment 4453674677: after a frame drew both controls,
turning off the horizontal scroll bar or the sheet tabs left
app.grid.hscroll / app.grid.tab_split holding the old rectangles.
sheet_tabs now clears both before any visibility return.

Test: tab_split::hiding_the_controls_clears_their_cached_rectangles
toggles both options on one live harness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 05:02:44 -06:00

221 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The handle between the sheet tabs and the horizontal scrollbar: dragging it gives the tabs more
//! or less of the tab bar, as in Excel.
use egui::{Event, Modifiers, PointerButton, Pos2, 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, sheets: usize) -> Harness {
let mut s = Session::new();
s.new_workbook();
s.execute("sheet.rename", json!({"sheet":0,"name":"Tab00"})).unwrap();
for i in 1..sheets {
s.execute("home.insertSheet", json!({"name":format!("Tab{i:02}")})).unwrap();
}
s.execute("sheet.activate", json!({"sheet":0})).unwrap();
let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(width, 120.0)).build_ui_state(
move |ui, app: &mut SheetApp| {
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);
},
crate::common::app(s),
);
h.run_steps(20);
h
}
fn split(h: &Harness) -> Rect {
h.state().grid.tab_split.expect("the tab bar has a split handle")
}
fn track(h: &Harness) -> Rect {
h.state().grid.hscroll.expect("the tab bar has a horizontal scrollbar")
}
/// The clip rectangle of the tab strip: the viewport the tab names are painted in.
fn strip(h: &Harness) -> Rect {
h.output()
.shapes
.iter()
.find_map(|s| match &s.shape {
Shape::Text(t) if t.galley.job.text == "Tab00" => Some(s.clip_rect),
_ => None,
})
.expect("Tab00 was painted")
}
fn drag(h: &mut Harness, from: Pos2, to: Pos2) {
h.input_mut().events.extend([
Event::PointerMoved(from),
Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
]);
h.step();
for i in 1..=6 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 6.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(20);
}
#[test]
fn dragging_the_handle_right_gives_overflowing_tabs_more_room() {
let mut h = harness(1200.0, 30);
let (before, handle) = (strip(&h), split(&h));
assert!(before.width() <= 1200.0 * 0.6, "the tabs start at their default share: {before:?}");
drag(&mut h, handle.center(), pos2(1000.0, handle.center().y));
let after = strip(&h);
assert!(after.width() > before.width() + 200.0, "the strip grew: {before:?} -> {after:?}");
assert!((split(&h).center().x - 1000.0).abs() <= 2.0, "the handle follows the pointer: {:?}", split(&h));
assert_eq!(track(&h).left(), split(&h).right(), "the scrollbar starts at the handle");
assert!(track(&h).width() >= 100.0, "the scrollbar keeps a usable width: {:?}", track(&h));
assert_eq!(h.state().session.doc().unwrap().wb.sheets.len(), 30, "dragging the handle does not edit the workbook");
}
#[test]
fn dragging_the_handle_left_gives_the_scrollbar_more_room() {
let mut h = harness(1200.0, 30);
let (before, handle) = (track(&h), split(&h));
drag(&mut h, handle.center(), pos2(handle.center().x - 300.0, handle.center().y));
let after = track(&h);
assert!(after.width() > before.width() + 280.0, "the scrollbar grew: {before:?} -> {after:?}");
assert!(strip(&h).width() < 1200.0 * 0.6 - 280.0, "the strip shrank: {:?}", strip(&h));
}
#[test]
fn the_handle_stops_before_the_scrollbar_gets_too_narrow_and_keeps_new_sheet_shown() {
let mut h = harness(1200.0, 30);
let handle = split(&h);
drag(&mut h, handle.center(), pos2(1195.0, handle.center().y));
assert!(track(&h).width() >= 100.0 - 0.5, "the scrollbar keeps 100 px: {:?}", track(&h));
let (handle, bar_left) = (split(&h), strip(&h).left());
drag(&mut h, handle.center(), pos2(0.0, handle.center().y));
// The tab strip has no width left, and the New sheet button (26 px after a 6 px gap) still
// fits before the handle.
assert!((split(&h).left() - bar_left - 32.0).abs() <= 1.0, "the handle is 32 px after the strip's start {bar_left}: {:?}", split(&h));
}
#[test]
fn a_narrower_window_keeps_the_scrollbar_after_the_handle_was_dragged_right() {
let mut h = harness(1200.0, 30);
let handle = split(&h);
drag(&mut h, handle.center(), pos2(1195.0, handle.center().y));
h.set_size(vec2(700.0, 120.0));
h.run_steps(20);
let track = track(&h);
assert!(track.width() >= 100.0 - 0.5 && track.right() <= 700.5, "the scrollbar keeps 100 px in the bar: {track:?}");
assert_eq!(track.left(), split(&h).right(), "the scrollbar starts at the handle");
assert!(strip(&h).right() <= split(&h).left() - 32.0 + 0.5, "the tabs end before New sheet: {:?}", strip(&h));
}
#[test]
fn room_the_tabs_do_not_fill_stays_empty_and_new_sheet_follows_the_last_tab() {
let mut h = harness(1200.0, 2);
let plus_before = track(&h).left() - 8.0 - 26.0;
let handle = split(&h);
drag(&mut h, handle.center(), pos2(900.0, handle.center().y));
assert!((split(&h).center().x - 900.0).abs() <= 2.0, "the handle stays where it was dragged: {:?}", split(&h));
assert_eq!(track(&h).left(), split(&h).right());
// New sheet (+) is still right after the last tab: clicking there adds a sheet.
let at = pos2(plus_before + 13.0, handle.center().y);
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);
assert_eq!(h.state().session.doc().unwrap().wb.sheets.len(), 3, "New sheet stayed after the last tab");
}
#[test]
fn the_handle_shows_the_resize_cursor() {
let mut h = harness(1200.0, 30);
let at = split(&h).center();
h.input_mut().events.push(Event::PointerMoved(at));
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal);
}
#[test]
fn each_workbook_keeps_its_own_tab_share() {
let mut h = harness(1200.0, 30);
let handle = split(&h);
drag(&mut h, handle.center(), pos2(1000.0, handle.center().y));
assert!(track(&h).left() > 990.0, "the first workbook's scrollbar starts at the handle: {:?}", track(&h));
h.state_mut().session.new_workbook();
h.run_steps(20);
// The new workbook's one tab takes its default room, so its scrollbar starts right after New sheet.
assert!(track(&h).left() < 400.0, "a new workbook starts at the default share: {:?}", track(&h));
assert_eq!(h.state().grid.tab_shares.len(), 1, "only the dragged workbook has a share");
}
/// The tab bar of a workbook whose window has `command` run with `{on: false}` first.
fn harness_without(command: &str) -> Harness {
let mut s = Session::new();
s.new_workbook();
s.execute("sheet.rename", json!({"sheet": 0, "name": "Tab00"})).unwrap();
s.execute(command, json!({"on": false})).unwrap();
let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(900.0, 120.0)).build_ui_state(
move |ui, app: &mut SheetApp| {
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);
},
crate::common::app(s),
);
h.run_steps(20);
h
}
fn shows_tab_name(h: &Harness) -> bool {
h.output().shapes.iter().any(|s| matches!(&s.shape, Shape::Text(t) if t.galley.job.text == "Tab00"))
}
#[test]
fn display_options_hide_the_sheet_tabs_and_the_horizontal_scroll_bar() {
// #3101: File › Options › Advanced, Display options for this workbook.
let h = harness_without("view.showSheetTabs");
assert!(!shows_tab_name(&h), "no sheet tabs");
assert!(h.state().grid.tab_split.is_none(), "nothing to split without tabs");
let bar = h.state().grid.hscroll.expect("the scroll bar stays");
assert!(bar.left() < 40.0, "the scroll bar takes the bar from its left edge: {bar:?}");
let h = harness_without("view.showHorizontalScrollBar");
assert!(shows_tab_name(&h), "the tabs stay");
assert!(h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no scroll bar and no split handle");
// Both off: no bar at all.
let mut s = Session::new();
s.new_workbook();
s.execute("view.showSheetTabs", json!({"on": false})).unwrap();
s.execute("view.showHorizontalScrollBar", json!({"on": false})).unwrap();
let mut h = crate::common::builder()
.with_size(vec2(900.0, 120.0))
.build_ui_state(|ui, app: &mut SheetApp| gridcraft_ui_egui::tabs::sheet_tabs(app, ui), crate::common::app(s));
h.run_steps(5);
assert!(!shows_tab_name(&h) && h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no bar at all");
}
#[test]
fn hiding_the_controls_clears_their_cached_rectangles() {
// Turned off on a live window, the controls drop the rectangles of the frame that drew them.
let mut h = harness(900.0, 1);
assert!(h.state().grid.tab_split.is_some() && h.state().grid.hscroll.is_some(), "both controls drawn first");
h.state_mut().run("view.showSheetTabs", json!({"on": false})).unwrap();
h.run_steps(5);
assert!(h.state().grid.tab_split.is_none(), "no split handle once the tabs are hidden");
assert!(h.state().grid.hscroll.is_some(), "the scroll bar stays");
h.state_mut().run("view.showSheetTabs", json!({"on": true})).unwrap();
h.state_mut().run("view.showHorizontalScrollBar", json!({"on": false})).unwrap();
h.run_steps(5);
assert!(h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no scroll bar and no split handle");
h.state_mut().run("view.showSheetTabs", json!({"on": false})).unwrap();
h.run_steps(5);
assert!(h.state().grid.hscroll.is_none() && h.state().grid.tab_split.is_none(), "no bar at all");
}