collective

Browse / github.com/storytold/gridcraft / patch

UI: workbook tabs when several workbooks are open (#2377, request 4)

UI: workbook tabs when several workbooks are open (#2377, request 4) Refs #2377 (request 4 only, so this does not close the issue). ## What GridCraft already keeps several workbooks open in one window (`window.activate`, View › Switch Windows), but there was no visible way to move between them. Once two or more workbooks are showing, a tab strip appears under the title bar: - click a tab to switch workbooks (a cell being edited is committed first); - × or middle click closes the workbook through `file.close`, so the Save Changes question still comes first; a dot replaces the × while there are unsaved changes; - **+** opens a new workbook; - windows hidden wit

Status
open
Score
0.00
Adopters
0 other operators
Author
SofiaAFigueredo (imported from pull request #2599, open)
Base
45f5ab18e4e57bf50ee88d2526deb4b87c5b24cb
Size
+329 −0 in 4 files
Review
pending
Shared
2026-10-28
Id
5e7d38b720755ade4412e5da

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/app.rs crates/ui-egui/src/tabs.rs crates/ui-egui/tests/it/document_tabs.rs crates/ui-egui/tests/it/main.rs

diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs--- a/crates/ui-egui/src/app.rs+++ b/crates/ui-egui/src/app.rs@@ -209,6 +209,7 @@ impl SheetApp {             // File tab: the Backstage view replaces everything below the title bar.             backstage::show(self, ui);         } else {+            tabs::document_tabs(self, ui);             ribbon::show(self, ui);             widgets::notice_bar(self, ui);             if self.session.prefs.formula_bar {diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs--- a/crates/ui-egui/src/tabs.rs+++ b/crates/ui-egui/src/tabs.rs@@ -319,6 +319,163 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {     }); } +/// The tab strip appears once at least this many workbooks are showing.+const MIN_DOCUMENT_TABS: usize = 2;+const DOC_TAB_H: f32 = 30.0;+const DOC_TAB_MIN_W: f32 = 110.0;+const DOC_TAB_MAX_W: f32 = 240.0;+const DOC_TAB_CLOSE: f32 = 18.0;++/// The windows to show as tabs, as `(window id, title, unsaved changes)`: one tab per window, so a+/// workbook with several windows (View › New Window) has several, titled `Name:1`, `Name:2`.+/// Windows hidden with View › Hide stay off the strip; View › Unhide brings them back.+fn document_tab_items(session: &gridcraft_engine::Session) -> Vec<(u64, String, bool)> {+    let dirty = |uid: u64| session.documents().iter().any(|d| d.uid == uid && d.is_dirty());+    session.windows().iter().filter(|w| !w.hidden).filter_map(|w| Some((w.id, session.window_title(w.id)?, dirty(w.doc_uid)))).collect()+}++/// Switches to window `id` from its tab. The cell being edited belongs to the window it was typed+/// in, so it is committed first; an entry that doesn't commit keeps the user where they are.+fn activate_document(app: &mut SheetApp, id: u64) {+    if app.session.active_window() == Some(id) {+        return;+    }+    if app.editor.is_some() && !app.commit_edit(0, 0, false, false) {+        return;+    }+    app.run_or_alert("window.activate", json!({"window": id}));+}++/// Closes window `id` the way its close button does (`window.close`): a workbook's last window+/// asks about unsaved changes (the Save Changes dialog) before the workbook goes.+fn close_document(app: &mut SheetApp, id: u64) {+    if app.editor.is_some() && !app.commit_edit(0, 0, false, false) {+        return;+    }+    app.run_or_alert("window.close", json!({"window": id}));+}++/// Draws the window tab strip under the title bar when several windows are open: click a tab to+/// switch, its × (or a middle click) to close it, + for a new workbook. A workbook with unsaved+/// changes shows a dot in place of the ×. With native windows (the desktop) every window has its+/// own OS window and there is no strip.+pub fn document_tabs(app: &mut SheetApp, ui: &mut Ui) {+    if app.native_windows {+        return;+    }+    let items = document_tab_items(&app.session);+    if items.len() < MIN_DOCUMENT_TABS {+        return;+    }+    let t = Tokens::get(ui.ctx());+    let active = app.session.active_window();+    // The active tab is brought into view when the active window or the set of tabs changes, not on+    // every frame, so the strip can still be scrolled to the tabs beyond it.+    let reveal_key = egui::Id::unique((active, items.iter().map(|(id, ..)| *id).collect::<Vec<_>>()));+    let reveal_id = ui.scope_id().with("document_tabs_reveal");+    let reveal_active = ui.ctx().data_mut(|d| {+        let changed = d.get_temp::<egui::Id>(reveal_id) != Some(reveal_key);+        d.insert_temp(reveal_id, reveal_key);+        changed+    });+    let (mut activate, mut close, mut new) = (None, None, false);+    egui::Panel::top("document_tabs").exact_size(DOC_TAB_H + 2.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| {+        let rect = ui.max_rect();+        ui.painter().line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.ribbon_border));+        ui.horizontal_centered(|ui| {+            ui.spacing_mut().item_spacing.x = 0.0;+            ui.add_space(8.0);+            let new_w = 32.0;+            let strip_w = (ui.available_width() - new_w - 8.0).max(DOC_TAB_MIN_W);+            ui.allocate_ui_with_layout(vec2(strip_w, DOC_TAB_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {+                egui::ScrollArea::horizontal()+                    .id_salt("document_tabs_horizontal")+                    .auto_shrink([true, false])+                    .min_scrolled_width(0.0)+                    .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)+                    .show(ui, |ui| {+                        ui.horizontal(|ui| {+                            ui.spacing_mut().item_spacing.x = 2.0;+                            for (i, title, dirty) in &items {+                                let (i, dirty, is_active) = (*i, *dirty, Some(*i) == active);+                                let font = if is_active { theme::ui_bold(13.0) } else { theme::ui_font(13.0) };+                                let text_w = ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text).size().x;+                                let w = (text_w + 20.0 + DOC_TAB_CLOSE + 8.0).clamp(DOC_TAB_MIN_W, DOC_TAB_MAX_W);+                                let (r, resp) = ui.allocate_exact_size(vec2(w, DOC_TAB_H), Sense::click());+                                if is_active && reveal_active {+                                    resp.scroll_to_me(None);+                                }+                                let tab = Rect::from_min_max(pos2(r.left(), r.top() + 3.0), r.max);+                                if is_active {+                                    ui.painter().rect_filled(tab, 4.0, t.tab_active);+                                    ui.painter().rect_stroke(tab, 4.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside);+                                    let band = Rect::from_min_size(pos2(tab.left() + 6.0, tab.top()), vec2(tab.width() - 12.0, 2.5));+                                    ui.painter().rect_filled(band, 1.0, t.accent);+                                } else if resp.hovered() {+                                    ui.painter().rect_filled(tab, 4.0, t.hover);+                                }+                                resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Tab, true, is_active, title.as_str()));+                                // The title leaves room for the close button and is cut with an ellipsis.+                                let mut job =+                                    egui::text::LayoutJob::simple_singleline(title.clone(), font, if is_active { t.accent } else { t.text });+                                job.wrap.max_width = w - 20.0 - DOC_TAB_CLOSE;+                                job.wrap.max_rows = 1;+                                job.wrap.break_anywhere = true;+                                job.wrap.overflow_character = Some('…');+                                let galley = ui.painter().layout_job(job);+                                let at = pos2(tab.left() + 10.0, tab.center().y + 1.0 - galley.size().y / 2.0);+                                ui.painter().galley(at, galley, t.text);+                                // The close button sits on top of the tab, so it takes its own clicks.+                                let close_rect = Rect::from_center_size(+                                    pos2(tab.right() - 6.0 - DOC_TAB_CLOSE / 2.0, tab.center().y + 1.0),+                                    vec2(DOC_TAB_CLOSE, DOC_TAB_CLOSE),+                                );+                                let close_name = app.ui.language.tr("Close");+                                let close_resp = ui.interact(close_rect, resp.id.with("close"), Sense::click());+                                close_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, close_name));+                                if dirty && !resp.hovered() && !close_resp.hovered() {+                                    ui.painter().circle_filled(close_rect.center(), 4.0, t.text_dim);+                                } else {+                                    if close_resp.hovered() {+                                        ui.painter().rect_filled(close_rect, 3.0, t.pressed);+                                    }+                                    icons::paint(ui.painter(), close_rect.shrink(3.0), Icon::Close, t.text_dim);+                                }+                                let close_resp = close_resp.on_hover_text(close_name);+                                if close_resp.clicked() || resp.middle_clicked() {+                                    close = Some(i);+                                } else if resp.clicked() || (resp.secondary_clicked() && !is_active) {+                                    activate = Some(i);+                                }+                                resp.context_menu(|ui| {+                                    if ui.button(app.ui.language.tr("Close")).clicked() {+                                        close = Some(i);+                                        ui.close();+                                    }+                                });+                            }+                        });+                    });+            });+            let new_name = app.ui.language.tr("New Workbook");+            let plus = crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, new_name, vec2(24.0, 24.0));+            plus.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, new_name));+            if plus.clicked() {+                new = true;+            }+        });+    });+    if let Some(i) = activate {+        activate_document(app, i);+    }+    if let Some(i) = close {+        close_document(app, i);+    }+    if new && (app.editor.is_none() || app.commit_edit(0, 0, false, false)) {+        app.run_or_alert("file.new", json!({}));+    }+}+ /// Draws status mode/messages, zoom controls/view-mode buttons and selection statistics. pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {     let t = Tokens::get(ui.ctx());diff --git a/crates/ui-egui/tests/it/document_tabs.rs b/crates/ui-egui/tests/it/document_tabs.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/document_tabs.rs@@ -0,0 +1,170 @@+//! The workbook tab strip: shown from two open workbooks on, clicks switch, close and add.+use egui::{Event, PointerButton, Pos2, accesskit::Role};+use egui_kittest::kittest::{NodeT as _, Queryable as _};+use gridcraft_engine::Session;+use serde_json::json;++use crate::common::Harness;+use crate::support;++fn books(n: usize) -> Harness {+    let mut s = Session::new();+    for _ in 0..n {+        s.new_workbook();+    }+    support::harness(s, 6)+}++fn tab_titles(h: &Harness) -> Vec<String> {+    h.query_all_by_role(Role::Tab).filter_map(|n| n.accesskit_node().label()).collect()+}++/// The × of a tab: 15 px in from the tab's right edge, on its row (see `document_tabs`).+fn close_button(h: &Harness, title: &str) -> Pos2 {+    let r = h.get_by_role_and_label(Role::Tab, title).rect();+    Pos2::new(r.right() - 15.0, r.center().y + 1.0)+}++fn move_to(h: &mut Harness, at: Pos2) {+    h.input_mut().events.push(Event::PointerMoved(at));+    h.run_steps(4);+}++fn click_at(h: &mut Harness, at: Pos2) {+    move_to(h, at);+    h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() });+    h.step();+    h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Default::default() });+    h.run_steps(8);+}++#[test]+fn a_single_workbook_shows_no_tab_strip() {+    assert!(tab_titles(&books(1)).is_empty());+}++#[test]+fn every_open_workbook_gets_a_tab() {+    assert_eq!(tab_titles(&books(3)), ["Book1", "Book2", "Book3"]);+}++#[test]+fn clicking_a_tab_switches_workbooks() {+    let mut h = books(2);+    assert_eq!(h.state().session.active_index(), 1, "the newest workbook is active");+    h.get_by_role_and_label(Role::Tab, "Book1").click();+    h.run_steps(8);+    assert_eq!(h.state().session.active_index(), 0);+    h.get_by_role_and_label(Role::Tab, "Book2").click();+    h.run_steps(8);+    assert_eq!(h.state().session.active_index(), 1);+}++#[test]+fn the_close_button_closes_that_workbook_and_the_strip_goes_with_the_last_sibling() {+    let mut h = books(2);+    // Book1 is not the active tab: it becomes active, then closes.+    let x = close_button(&h, "Book1");+    click_at(&mut h, x);+    assert_eq!(h.state().session.documents().len(), 1);+    assert_eq!(h.state().session.documents()[0].title, "Book2");+    assert!(tab_titles(&h).is_empty(), "one workbook left: no strip");+}++#[test]+fn a_middle_click_closes_a_tab() {+    let mut h = books(2);+    h.get_by_role_and_label(Role::Tab, "Book2").click_button(PointerButton::Middle);+    h.run_steps(8);+    assert_eq!(h.state().session.documents().len(), 1);+    assert_eq!(h.state().session.documents()[0].title, "Book1");+}++#[test]+fn closing_a_workbook_with_unsaved_changes_asks_first() {+    let mut h = books(2);+    h.state_mut().run("cell.set", json!({"cell": "A1", "input": "7"})).unwrap();+    h.run_steps(4);+    let x = close_button(&h, "Book2");+    click_at(&mut h, x);+    assert_eq!(h.state().session.documents().len(), 2, "still open");+    assert!(h.state().dialog.as_ref().is_some_and(|d| d.name() == "saveChanges"), "asks about the changes");+}++#[test]+fn the_new_button_adds_a_workbook_tab() {+    let mut h = books(2);+    let plus = h.get_by_role_and_label(Role::Button, "New Workbook").rect().center();+    click_at(&mut h, plus);+    assert_eq!(tab_titles(&h), ["Book1", "Book2", "Book3"]);+    assert_eq!(h.state().session.active_index(), 2);+}++#[test]+fn hidden_windows_have_no_tab() {+    let mut h = books(3);+    h.state_mut().run("view.hideWindow", json!({})).unwrap();+    h.run_steps(6);+    assert_eq!(tab_titles(&h), ["Book1", "Book2"]);+}++#[test]+fn closing_an_earlier_workbook_keeps_the_hidden_one_hidden() {+    let mut h = books(4);+    h.state_mut().run("view.hideWindow", json!({})).unwrap();+    h.run_steps(6);+    assert_eq!(tab_titles(&h), ["Book1", "Book2", "Book3"]);+    let x = close_button(&h, "Book1");+    click_at(&mut h, x);+    assert_eq!(tab_titles(&h), ["Book2", "Book3"]);+    let session = &h.state().session;+    let hidden: Vec<_> = session.windows().iter().filter(|w| w.hidden).filter_map(|w| session.window_title(w.id)).collect();+    assert_eq!(hidden, ["Book4"], "the window that was hidden is still the hidden one");+}++#[test]+fn a_workbook_with_two_windows_has_two_tabs() {+    let mut h = books(1);+    h.state_mut().run("view.newWindow", json!({})).unwrap();+    h.run_steps(6);+    assert_eq!(tab_titles(&h), ["Book1:1", "Book1:2"]);+    // Closing one window leaves the workbook open, without asking.+    let x = close_button(&h, "Book1:2");+    click_at(&mut h, x);+    assert_eq!(h.state().session.documents().len(), 1);+    assert!(tab_titles(&h).is_empty());+}++#[test]+fn native_windows_have_no_tab_strip() {+    let mut s = Session::new();+    s.new_workbook();+    s.new_workbook();+    let mut app = crate::common::app(s);+    app.native_windows = true;+    let h = support::app_harness(app, 6);+    assert!(tab_titles(&h).is_empty());+}++#[test]+fn the_strip_scrolls_away_from_the_active_tab_and_stays_there() {+    // Twelve tabs do not fit in 1200 px; the newest (active) one is revealed first.+    let mut h = books(12);+    let first = |h: &Harness| h.get_by_role_and_label(Role::Tab, "Book1").rect();+    assert!(first(&h).left() < 0.0, "the first tab starts out of view");+    let over_strip = Pos2::new(600.0, first(&h).center().y);+    h.input_mut().events.extend([+        Event::PointerMoved(over_strip),+        Event::MouseWheel {+            unit: egui::MouseWheelUnit::Point,+            delta: egui::vec2(2000.0, 0.0),+            phase: egui::TouchPhase::Move,+            source: egui::MouseWheelSource::Wheel,+            modifiers: Default::default(),+        },+    ]);+    h.run_steps(30);+    assert!(first(&h).left() >= 0.0, "scrolling brought the first tab into view");+    h.run_steps(30);+    assert!(first(&h).left() >= 0.0, "the strip is not snapped back to the active tab");+}diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs--- a/crates/ui-egui/tests/it/main.rs+++ b/crates/ui-egui/tests/it/main.rs@@ -26,6 +26,7 @@ mod dialog_button_row; mod dialog_confirm; mod dialog_focus; mod dialog_move;+mod document_tabs; mod drag_cursor; mod error_checking_menu; mod file_menu;