Sheet tabs: drag the handle at the scrollbar's left edge to give the tabs more or less room

The sheet tabs took up to 60% of the tab bar and the horizontal
scrollbar the rest, and nothing between them could be dragged. The
gap at the scrollbar's left edge is now a handle: it shows the resize
cursor, and dragging it right gives the tabs more of the bar, until the
scrollbar is 100 px wide. After the window is narrowed, the scrollbar
is still 100 px wide. Dragging it left gives the scrollbar more, down
to the New sheet button. Room the tabs do not fill stays empty, and
New sheet stays after the last tab.

Each open workbook keeps its own share while it is open. The share is
not saved in the file, and a workbook starts at the default.
This commit is contained in:
dotCooCoo
2026-10-24 10:01:58 -07:00
parent 3099df934e
commit 3d41bdb956
6 changed files with 243 additions and 5 deletions
+1
View File
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-24 | Sheet tabs: dragging the handle between the tabs and the horizontal scrollbar gives the tabs more or less of the bar, as in Excel; each open workbook keeps its own share |
| 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text |
| 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row |
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
+5
View File
@@ -176,6 +176,11 @@ pub struct GridState {
pub geo: Option<Geo>,
/// The horizontal scrollbar's track in the sheet tab bar (hit-testing for tests).
pub hscroll: Option<Rect>,
/// The handle between the sheet tabs and the horizontal scrollbar (hit-testing for tests).
pub tab_split: Option<Rect>,
/// The share of the tab bar each workbook's tabs (and New sheet) take after the handle was
/// dragged, by document uid. A workbook without one gives its tabs up to 60%.
pub tab_shares: std::collections::HashMap<u64, f32>,
/// Ctrl+wheel zoom in progress (see [`WheelZoom`]).
pub wheel_zoom: Option<WheelZoom>,
/// A one-finger touch drag in progress: it pans the sheet instead of selecting.
+74 -3
View File
@@ -55,6 +55,46 @@ fn tab_drop(i: usize, steps: i64, copy: bool) -> Option<serde_json::Value> {
Some(json!({"sheet": i, "to": to}))
}
/// The New sheet (+) button and the gap before it.
const NEW_SHEET_WIDTH: f32 = 32.0;
/// The handle between the tabs and the horizontal scrollbar.
const SPLIT_WIDTH: f32 = 8.0;
/// The narrowest the scrollbar track gets when the handle is dragged to the right.
const MIN_TRACK: f32 = 100.0;
/// The share of the tab bar (`bar`: its left edge and width) the tabs and New sheet take with
/// the handle dragged to `x`: New sheet stays shown, and the track ending at `right` keeps
/// [`MIN_TRACK`].
fn dragged_share(x: f32, bar: (f32, f32), right: f32) -> f32 {
let (left, width) = bar;
if !width.is_finite() || width <= 0.0 || !x.is_finite() {
return 0.6;
}
let min = NEW_SHEET_WIDTH / width;
let max = ((right - SPLIT_WIDTH - MIN_TRACK - left) / width).max(min);
((x - left) / width).clamp(min, max)
}
/// The handle at the left edge of the horizontal scrollbar: dragging it gives the sheet tabs more
/// or less of the bar, as in Excel, for the workbook `uid`.
fn tab_split_handle(app: &mut SheetApp, ui: &mut Ui, split: Rect, bar: (f32, f32), right: f32, uid: u64, t: &Tokens) {
app.grid.tab_split = Some(split);
let resp = ui.interact(split, ui.id().with("tab_split"), Sense::drag());
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
if resp.dragged()
&& let Some(p) = resp.interact_pointer_pos()
{
app.grid.tab_shares.insert(uid, dragged_share(p.x - SPLIT_WIDTH / 2.0, bar, right));
ui.ctx().request_repaint();
}
let color = if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled };
for dy in [-4.0, 0.0, 4.0] {
ui.painter().circle_filled(pos2(split.center().x, split.center().y + dy), 1.0, color);
}
}
pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::bottom("sheet_tabs").exact_size(32.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| {
@@ -81,6 +121,12 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
}
ui.add_space(8.0);
let Some(d) = app.session.active() else { return };
let (bar_left, bar_width) = (ui.cursor().left(), ui.available_width());
let uid = d.uid;
// The track ends where the grid does, so it stops under the vertical scrollbar. A share
// dragged in a wider window is held to the same limits as a drag at this width.
let bar_right = app.grid.rect.map_or(bar_left + bar_width, |r| r.right()).min(bar_left + bar_width);
let share = app.grid.tab_shares.get(&uid).map(|&share| dragged_share(bar_left + bar_width * share, (bar_left, bar_width), bar_right));
let sheets: Vec<(usize, String, Option<[u8; 3]>, bool)> =
d.wb.sheets
.iter()
@@ -91,7 +137,12 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
// Keep worksheet tabs in their own horizontally scrollable viewport. As in Excel, the
// tabs get up to 60% of the bar and the grid's horizontal scrollbar the rest; a strip
// that needs less shrinks to its tabs, so New sheet (+) sits right after the last one.
let tabs_width = (ui.available_width() * 0.6).max(100.0);
// After the handle at the scrollbar's left edge is dragged, the tabs and New sheet
// take the share it was dragged to.
let tabs_width = match share {
Some(share) => (bar_width * share - NEW_SHEET_WIDTH).max(0.0),
None => (bar_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()));
@@ -116,6 +167,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
egui::ScrollArea::horizontal()
.id_salt("worksheet_tabs_horizontal")
.auto_shrink([true, false])
.min_scrolled_width(0.0)
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
if wheel_scroll.abs() > 0.0 {
@@ -251,7 +303,13 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
// The track ends where the grid does, so it stops under the vertical scrollbar.
let avail = ui.available_rect_before_wrap();
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));
// The handle sits at the track's left edge. With a dragged share, the room the tabs
// don't fill stays empty before it.
let split_left =
share.map_or(avail.left(), |share| (bar_left + bar_width * share).min(right - SPLIT_WIDTH - MIN_TRACK).max(avail.left()));
let split = Rect::from_min_max(pos2(split_left, avail.top()), pos2(split_left + SPLIT_WIDTH, avail.bottom()));
tab_split_handle(app, ui, split, (bar_left, bar_width), right, uid, &t);
let track = Rect::from_min_max(pos2(split.right(), avail.center().y - 7.0), pos2(right, avail.center().y + 7.0));
crate::grid::horizontal_scrollbar(app, ui, track, &t);
});
});
@@ -373,7 +431,9 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
#[cfg(test)]
mod tests {
use super::{activate_sheet, drop_copies_sheet, navigator_items, rename_needs_focus, tab_drop};
use super::{
MIN_TRACK, NEW_SHEET_WIDTH, SPLIT_WIDTH, activate_sheet, dragged_share, drop_copies_sheet, navigator_items, rename_needs_focus, tab_drop,
};
use crate::SheetApp;
use egui::Modifiers;
use gridcraft_engine::model::{Sheet, Visibility};
@@ -459,4 +519,15 @@ mod tests {
let a1 = gridcraft_engine::core::CellRef::parse("A1").unwrap();
assert!(app.session.active().unwrap().wb.sheet(0).unwrap().cell(a1).is_some(), "the value lands on the first sheet");
}
#[test]
fn a_dragged_share_keeps_new_sheet_and_the_scrollbar_shown() {
// A 1,000 px bar from x = 100 whose track ends at x = 1,100.
let bar = (100.0, 1000.0);
assert_eq!(dragged_share(600.0, bar, 1100.0), 0.5);
assert_eq!(dragged_share(0.0, bar, 1100.0), NEW_SHEET_WIDTH / 1000.0, "New sheet stays shown");
assert_eq!(dragged_share(1100.0, bar, 1100.0), (1000.0 - SPLIT_WIDTH - MIN_TRACK) / 1000.0, "the track keeps its minimum");
assert_eq!(dragged_share(f32::NAN, bar, 1100.0), 0.6);
assert_eq!(dragged_share(600.0, (100.0, 0.0), 1100.0), 0.6, "an empty bar has the default share");
}
}
+1
View File
@@ -79,6 +79,7 @@ mod shrink_to_fit;
mod sort_dialog_columns;
mod symbol_glyphs;
mod system_theme;
mod tab_split;
mod table_headings;
mod text_box_editing;
mod text_overflow_scroll;
+159
View File
@@ -0,0 +1,159 @@
//! 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 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 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");
}
+3 -2
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (dragging the handle between the sheet tabs and the horizontal scrollbar resizes the tabs; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **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
@@ -68,7 +68,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 in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); the tabs/scrollbar split can't be dragged yet |
| 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); dragging the handle at the scrollbar's left edge gives the tabs more or less of the bar; each workbook keeps its share while it is open (not saved in the file) |
| Text overflow into empty neighbours, wrap, rotation, shrink to fit | done |
## Keyboard
@@ -178,6 +178,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | Sheet tabs: the handle at the horizontal scrollbar's left edge shows the resize cursor, and dragging it moves the boundary, as in Excel ("Hover the mouse pointer at the edge of the scrollbar until you see the double-headed arrow"). Dragging it right gives the tabs more of the bar, until the scrollbar is 100 px wide (the scrollbar stays 100 px wide after the window is narrowed); dragging it left gives the scrollbar more, down to the New sheet button. Room the tabs don't fill stays empty, with New sheet after the last tab. Each open workbook keeps its own share; a workbook starts at the default (tabs up to 60%) |
| 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) |
| 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is |
| 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) |