From 68a3eabe2090684ee92ed489590861bd5b4c9d85 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 00:25:32 +0200 Subject: [PATCH] Sheet tabs: All Sheets navigator with a filter field (fixes #839) --- crates/ui-egui/src/icons.rs | 8 ++++ crates/ui-egui/src/ribbon.rs | 4 +- crates/ui-egui/src/tabs.rs | 57 ++++++++++++++++++++----- crates/ui-egui/tests/sheet_navigator.rs | 44 +++++++++++++++++++ 4 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/tests/sheet_navigator.rs diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index fe9f7bf51..3e5cdf379 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -113,6 +113,8 @@ pub enum Icon { /// Light/dark appearance: a circle, its right half filled. Appearance, Sheet, + /// All Sheets navigator next to the sheet tabs: a list of rows with a bullet each. + SheetList, Book, Folder, /// Header & Footer: a page with its top / bottom band marked. @@ -419,6 +421,12 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { g.line(&[(2.0, 8.0), (8.0, 2.5), (14.0, 8.0)], c, 1.2); g.line(&[(4.0, 6.5), (4.0, 13.5), (12.0, 13.5), (12.0, 6.5)], c, 1.2); } + Icon::SheetList => { + for y in [4.0, 8.0, 12.0] { + g.rect(2.5, y - 1.0, 2.0, 2.0, Some(c), None); + g.stroke(&[(6.5, y), (13.5, y)], c); + } + } Icon::More => { for x in [4.0, 8.0, 12.0] { g.circle(x, 8.0, 1.0, Some(c), None); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e8291accd..a4f0c6be0 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -367,7 +367,7 @@ struct ComboState { } /// What the user did with an [`editable_combo`]. -enum ComboResult { +pub(crate) enum ComboResult { None, /// A row was applied: clicked or Enter on the highlighted row. Picked(String), @@ -378,7 +378,7 @@ enum ComboResult { /// Excel-style dropdown: a filter field (type to narrow the list) plus arrow/Enter selection. /// `items` are `(value, display)`; the value is what comes back. When `forced` (keytip invoke) /// or clicked, the popup opens, focuses the filter field, and selects its text. -fn editable_combo( +pub(crate) fn editable_combo( ui: &mut Ui, anchor: &egui::Response, salt: &'static str, diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index e55f575c6..17b7f1c58 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -1,7 +1,9 @@ //! Sheet tabs and the status bar. +use std::sync::Arc; + use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; -use gridcraft_engine::model::Visibility; +use gridcraft_engine::model::{Sheet, Visibility}; use serde_json::json; use crate::SheetApp; @@ -14,6 +16,25 @@ fn rename_needs_focus(has_focus: bool, lost_focus: bool) -> bool { !has_focus && !lost_focus } +/// Rows of the All Sheets navigator as `(workbook index, name)`. Hidden sheets stay out, like on the tab strip: +/// Unhide is the way to reach them. +fn navigator_items(sheets: &[Arc]) -> Vec<(String, String)> { + sheets.iter().enumerate().filter(|(_, s)| s.visibility == Visibility::Visible).map(|(i, s)| (i.to_string(), s.name.clone())).collect() +} + +/// Switch to sheet `i` from the tab strip or the navigator. In point mode the formula being typed keeps going on the +/// other sheet; otherwise the edit is committed first. +fn activate_sheet(app: &mut SheetApp, i: usize) { + if app.editor.as_ref().is_some_and(|e| e.can_point()) { + let _ = app.session.run("sheet.activate", json!({"sheet": i})); + } else { + if app.editor.is_some() { + app.commit_edit(0, 0, false, false); + } + app.run_or_alert("sheet.activate", json!({"sheet": i})); + } +} + 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| { @@ -28,6 +49,16 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { if crate::widgets::icon_button(ui, Icon::Right, t.text_dim, "Next sheet", vec2(24.0, 24.0)).clicked() { app.run_or_alert("sheet.next", json!({})); } + let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, "All sheets", vec2(24.0, 24.0)); + all.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "All sheets")); + let items = app.session.active().map(|d| navigator_items(&d.wb.sheets)).unwrap_or_default(); + let active = app.session.active().map(|d| d.wb.active_sheet.to_string()).unwrap_or_default(); + let rows: Vec<(&str, String)> = items.iter().map(|(i, name)| (i.as_str(), name.clone())).collect(); + if let crate::ribbon::ComboResult::Picked(i) = crate::ribbon::editable_combo(ui, &all, "sheet_navigator", &active, &rows, false) + && let Ok(i) = i.parse::() + { + activate_sheet(app, i); + } ui.add_space(8.0); let Some(d) = app.session.active() else { return }; let sheets: Vec<(usize, String, Option<[u8; 3]>, bool)> = @@ -106,15 +137,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text }); } if resp.clicked() { - if app.editor.as_ref().is_some_and(|e| e.can_point()) { - // Point mode across sheets: switch without committing. - let _ = app.session.run("sheet.activate", json!({"sheet": i})); - } else { - if app.editor.is_some() { - app.commit_edit(0, 0, false, false); - } - app.run_or_alert("sheet.activate", json!({"sheet": i})); - } + activate_sheet(app, i); } if resp.double_clicked() { app.grid.renaming_tab = Some(i); @@ -332,7 +355,9 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { #[cfg(test)] mod tests { - use super::rename_needs_focus; + use super::{navigator_items, rename_needs_focus}; + use gridcraft_engine::model::{Sheet, Visibility}; + use std::sync::Arc; #[test] fn rename_field_takes_focus_once_and_lets_go_on_commit() { @@ -340,4 +365,14 @@ mod tests { assert!(!rename_needs_focus(true, false), "already focused: do not ask again"); assert!(!rename_needs_focus(false, true), "Enter or click-away: must not take focus back"); } + + #[test] + fn navigator_lists_visible_sheets_by_workbook_index() { + let mut hidden = Sheet::new("Hidden"); + hidden.visibility = Visibility::Hidden; + let mut very_hidden = Sheet::new("VeryHidden"); + very_hidden.visibility = Visibility::VeryHidden; + let sheets = [Sheet::new("Data"), hidden, very_hidden, Sheet::new("Summary")].map(Arc::new); + assert_eq!(navigator_items(&sheets), vec![("0".to_string(), "Data".to_string()), ("3".to_string(), "Summary".to_string())]); + } } diff --git a/crates/ui-egui/tests/sheet_navigator.rs b/crates/ui-egui/tests/sheet_navigator.rs new file mode 100644 index 000000000..a16e306ed --- /dev/null +++ b/crates/ui-egui/tests/sheet_navigator.rs @@ -0,0 +1,44 @@ +//! The All Sheets navigator next to the sheet tabs: type part of a name, Enter, and that sheet is active (#839). + +use egui::{Event, Key, Modifiers}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut s = Session::new(); + s.new_workbook(); + for _ in 0..3 { + s.run("home.insertSheet", json!({})).unwrap(); + } + for (i, name) in ["Data", "Hidden", "Notes", "Summary"].iter().enumerate() { + s.run("sheet.rename", json!({"sheet": i, "name": name})).unwrap(); + } + s.run("sheet.hide", json!({"sheet": 1})).unwrap(); + s.run("sheet.activate", json!({"sheet": 0})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +#[test] +fn typing_a_name_and_enter_activates_that_sheet() { + let mut h = harness(); + assert_eq!(h.state().session.active().unwrap().wb.active_sheet, 0); + h.get_by_label("All sheets").click(); + h.run_steps(4); + h.input_mut().events.push(Event::Text("summ".into())); + h.run_steps(3); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(4); + assert_eq!(h.state().session.active().unwrap().wb.active_sheet, 3, "Enter on the filtered row must activate Summary"); +}