diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index c508890c3..3631d0e56 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -40,6 +40,27 @@ pub fn specs() -> Vec { cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")), cmd!("view.showOutlineSymbols", "Show Outline Symbols", [], Some("Ctrl+8"), "{on?}", has_doc, |s, p| sheet_flag(s, p, "outlineSymbols")), cmd!("view.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects), + cmd!( + "view.showSheetTabs", + "Show Sheet Tabs", + [], + None, + "{on?} (File › Options › Advanced, this workbook; toggles without `on`)", + has_doc, + |s, p| { workbook_view_flag(s, p, "sheetTabs") } + ), + cmd!( + "view.showHorizontalScrollBar", + "Show Horizontal Scroll Bar", + [], + None, + "{on?} (this workbook; toggles without `on`)", + has_doc, + |s, p| { workbook_view_flag(s, p, "horizontal") } + ), + cmd!("view.showVerticalScrollBar", "Show Vertical Scroll Bar", [], None, "{on?} (this workbook; toggles without `on`)", has_doc, |s, p| { + workbook_view_flag(s, p, "vertical") + }), cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")), cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom), cmd!("view.zoom100", "100%", ["View", "Zoom"], None, "{}", has_doc, |s, _| zoom(s, &json!({"percent": 100}))), @@ -189,6 +210,21 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result { }) } +/// File › Options › Advanced › Display options for this workbook: the sheet tabs and the two +/// scroll bars, saved with the workbook (``). +fn workbook_view_flag(s: &mut Session, p: &Json, which: &str) -> Result { + edit(s, |cx| { + let view = &mut cx.wb.view; + let slot = match which { + "sheetTabs" => &mut view.show_sheet_tabs, + "horizontal" => &mut view.show_horizontal_scroll, + _ => &mut view.show_vertical_scroll, + }; + *slot = bool_param(p, "on").unwrap_or(!*slot); + Ok(json!({"on": *slot})) + }) +} + /// Ctrl+6: hides every object, or shows them again. Placeholders (from a file) count as shown. fn show_objects(s: &mut Session, p: &Json) -> Result { use gridcraft_model::ShowObjects; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5f92718ba..4d0cabd17 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5457,3 +5457,22 @@ fn a_view_kept_through_redo_doesnt_make_the_saved_workbook_dirty() { assert_eq!(d.wb.active().unwrap().view, gridcraft_model::SheetViewMode::PageBreakPreview); assert!(!d.is_dirty(), "back at the saved edits, the view alone isn't a change"); } + +#[test] +fn workbook_display_options_are_commands() { + // #3101: Show sheet tabs / horizontal / vertical scroll bar, toggling without `on`, saved with + // the workbook and undoable. + let mut s = Session::new(); + s.new_workbook(); + let view = |s: &Session| s.doc().unwrap().wb.view; + assert_eq!(s.execute("view.showSheetTabs", json!({})).unwrap()["on"], json!(false)); + assert_eq!(s.execute("view.showHorizontalScrollBar", json!({"on": false})).unwrap()["on"], json!(false)); + assert_eq!(s.execute("view.showVerticalScrollBar", json!({"on": false})).unwrap()["on"], json!(false)); + let v = view(&s); + assert!(!v.show_sheet_tabs && !v.show_horizontal_scroll && !v.show_vertical_scroll); + let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&s.doc().unwrap().wb).unwrap()).unwrap(); + assert_eq!(back.view, v, "saved with the workbook"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(view(&s).show_vertical_scroll); + assert_eq!(s.execute("view.showSheetTabs", json!({})).unwrap()["on"], json!(true), "toggles back"); +} diff --git a/crates/ui-egui/src/backstage/options.rs b/crates/ui-egui/src/backstage/options.rs index 6b9f70eb7..afe976733 100644 --- a/crates/ui-egui/src/backstage/options.rs +++ b/crates/ui-egui/src/backstage/options.rs @@ -2,7 +2,8 @@ use super::{Key, Language, Modifiers, SheetApp, Ui, dim, i18n, json, section, ve /// Excel's File › Options page: General (display theme, interface language and Show formula bar), /// Editing (AutoComplete) and Save (the autosave checkbox toggling `app.ui.autosave`), plus Formulas -/// (calculation mode and iterative calculation) when a document is active. Theme, language, formula +/// (calculation mode and iterative calculation) and the display options for this workbook (sheet +/// tabs and scroll bars) when a document is active. Theme, language, formula /// bar, AutoComplete and calculation changes run their commands through `run_or_alert`; calculation /// settings are saved with the workbook. pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) { @@ -26,7 +27,20 @@ pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) { if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() { app.toggle_autosave(); } - let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return }; + let Some((calc, view)) = app.session.active().map(|d| (d.wb.calc.clone(), d.wb.view)) else { return }; + ui.add_space(18.0); + // Excel's Advanced › Display options for this workbook: saved with the workbook (#3101). + section(ui, lang, "Display options for this workbook"); + for (on, label, id) in [ + (view.show_horizontal_scroll, "Show horizontal scroll bar", "view.showHorizontalScrollBar"), + (view.show_vertical_scroll, "Show vertical scroll bar", "view.showVerticalScrollBar"), + (view.show_sheet_tabs, "Show sheet tabs", "view.showSheetTabs"), + ] { + let mut on = on; + if ui.checkbox(&mut on, lang.tr(label)).changed() { + app.run_or_alert(id, json!({"on": on})); + } + } ui.add_space(18.0); section(ui, lang, "Formulas"); ui.label(lang.tr("Workbook calculation")); diff --git a/crates/ui-egui/src/grid/show.rs b/crates/ui-egui/src/grid/show.rs index f1416c2ec..287bd0bcd 100644 --- a/crates/ui-egui/src/grid/show.rs +++ b/crates/ui-egui/src/grid/show.rs @@ -20,7 +20,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let available = ui.available_rect_before_wrap(); // A right-to-left sheet has its vertical scrollbar on the left. let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); - let bar = 14.0_f32.min(available.width()); + let bar = vertical_bar_width(app, available); let rect = if rtl { Rect::from_min_max(pos2(available.left() + bar, available.top()), available.max) } else { @@ -213,6 +213,12 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { update_clipboard_repaint(app, ui, now); } +/// The vertical scroll bar's width: none when the workbook hides it (File › Options › +/// Display options for this workbook), and the grid takes its room. +fn vertical_bar_width(app: &SheetApp, available: Rect) -> f32 { + if app.session.active().is_none_or(|d| d.wb.view.show_vertical_scroll) { 14.0_f32.min(available.width()) } else { 0.0 } +} + fn vertical_scroll_track(available: Rect, grid: Rect, rtl: bool) -> (f32, f32) { if rtl { (available.left(), grid.left()) } else { (grid.right(), available.right()) } } @@ -420,6 +426,10 @@ fn paint_panes(input: PanesInput<'_>, cf: &mut CfCache) -> Vec { /// The worksheet is painted virtually, so it needs its own scrollbar rather than a /// ScrollArea containing all million rows. Keep the scroll extent stable during a drag. fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, track_x: (f32, f32), limit: f32, t: &Tokens) { + // A workbook that hides its vertical scroll bar leaves it no width. + if track_x.1 <= track_x.0 { + return; + } let track = Rect::from_min_max(pos2(track_x.0, geo.cells.top() + geo.frozen_h), pos2(track_x.1, geo.cells.bottom())); if track.height() <= 0.0 || track.width() <= 0.0 { return; diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 6702f7cb8..6147b2e14 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -100,29 +100,37 @@ fn tab_split_handle(app: &mut SheetApp, ui: &mut Ui, split: Rect, bar: (f32, f32 /// Draws the worksheet tab strip: navigation, scroll/reveal, rename/drag/move with optional copy, context menu, new-sheet button, split handle and grid scrollbar. pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + // File › Options › Advanced › Display options for this workbook (#3101): with neither the + // sheet tabs nor the horizontal scroll bar, the bar isn't there at all. + let view = app.session.active().map(|d| d.wb.view).unwrap_or_default(); + if !view.show_sheet_tabs && !view.show_horizontal_scroll { + return; + } egui::Panel::bottom("sheet_tabs").exact_size(32.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { let rect = ui.max_rect(); ui.painter().line_segment([rect.left_top(), rect.right_top()], Stroke::new(1.0, t.ribbon_border)); ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.add_space(8.0); - if crate::widgets::icon_button(ui, Icon::Left, t.text_dim, app.ui.language.tr("Previous sheet"), vec2(24.0, 24.0)).clicked() { - app.run_or_alert("sheet.previous", json!({})); + if view.show_sheet_tabs { + if crate::widgets::icon_button(ui, Icon::Left, t.text_dim, app.ui.language.tr("Previous sheet"), vec2(24.0, 24.0)).clicked() { + app.run_or_alert("sheet.previous", json!({})); + } + if crate::widgets::icon_button(ui, Icon::Right, t.text_dim, app.ui.language.tr("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, app.ui.language.tr("All sheets"), vec2(24.0, 24.0)); + all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, app.ui.language.tr("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); } - if crate::widgets::icon_button(ui, Icon::Right, t.text_dim, app.ui.language.tr("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, app.ui.language.tr("All sheets"), vec2(24.0, 24.0)); - all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, app.ui.language.tr("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 (bar_left, bar_width) = (ui.cursor().left(), ui.available_width()); let uid = d.uid; @@ -143,6 +151,8 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { // 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 { + // Without the scroll bar the tabs have the whole bar. + _ if !view.show_horizontal_scroll => (bar_width - NEW_SHEET_WIDTH).max(0.0), Some(share) => (bar_width * share - NEW_SHEET_WIDTH).max(0.0), None => (bar_width * 0.6).max(100.0), }; @@ -155,154 +165,159 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { 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. - let wheel_scroll = if ui.rect_contains_pointer(ui.max_rect()) { - ui.input(|i| { - let d = i.smooth_scroll_delta; - if d.x.abs() > d.y.abs() { d.x } else { d.y } - }) - } else { - 0.0 - }; + if view.show_sheet_tabs { + 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. + let wheel_scroll = if ui.rect_contains_pointer(ui.max_rect()) { + ui.input(|i| { + let d = i.smooth_scroll_delta; + if d.x.abs() > d.y.abs() { d.x } else { d.y } + }) + } else { + 0.0 + }; - 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 { - ui.scroll_with_delta(vec2(wheel_scroll, 0.0)); - } - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - for (i, name, color, active) in sheets { - let renaming = app.grid.renaming_tab == Some(i); - let font = if active { theme::ui_bold(13.0) } else { theme::ui_font(13.0) }; - 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); - ui.painter().rect_stroke( - Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), - 4.0, - Stroke::new(1.0, t.ribbon_border), - StrokeKind::Inside, - ); - } else if resp.hovered() { - ui.painter().rect_filled(r, 4.0, t.hover); - } - if let Some(c) = color { - let band = Rect::from_min_size(pos2(r.left() + 6.0, r.bottom() - 4.0), vec2(r.width() - 12.0, 3.0)); - ui.painter().rect_filled(band, 1.0, theme::color32(c)); - } else if active { - let band = Rect::from_center_size(pos2(r.center().x, r.bottom() - 3.0), vec2(tw.min(48.0), 2.5)); - ui.painter().rect_filled(band, 1.0, t.accent); - } - if renaming { - let mut text = app.grid.rename_text.clone(); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(6.0, 5.0)))); - let ed = child - .add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0)) - .accessible_name(app.ui.language.tr("Sheet Name")); - app.grid.rename_text = text.clone(); - if rename_needs_focus(ed.has_focus(), ed.lost_focus()) { - ed.request_focus(); + 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 { + ui.scroll_with_delta(vec2(wheel_scroll, 0.0)); + } + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + for (i, name, color, active) in sheets { + let renaming = app.grid.renaming_tab == Some(i); + let font = if active { theme::ui_bold(13.0) } else { theme::ui_font(13.0) }; + 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); } - if ed.lost_focus() { - if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && text != name { - app.run_or_alert("sheet.rename", json!({"sheet": i, "name": text})); + 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); + ui.painter().rect_stroke( + Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), + 4.0, + Stroke::new(1.0, t.ribbon_border), + StrokeKind::Inside, + ); + } else if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover); + } + if let Some(c) = color { + let band = Rect::from_min_size(pos2(r.left() + 6.0, r.bottom() - 4.0), vec2(r.width() - 12.0, 3.0)); + ui.painter().rect_filled(band, 1.0, theme::color32(c)); + } else if active { + let band = Rect::from_center_size(pos2(r.center().x, r.bottom() - 3.0), vec2(tw.min(48.0), 2.5)); + ui.painter().rect_filled(band, 1.0, t.accent); + } + if renaming { + let mut text = app.grid.rename_text.clone(); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(6.0, 5.0)))); + let ed = child + .add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0)) + .accessible_name(app.ui.language.tr("Sheet Name")); + app.grid.rename_text = text.clone(); + if rename_needs_focus(ed.has_focus(), ed.lost_focus()) { + ed.request_focus(); } - app.grid.renaming_tab = None; + if ed.lost_focus() { + if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && text != name { + app.run_or_alert("sheet.rename", json!({"sheet": i, "name": text})); + } + app.grid.renaming_tab = None; + } + } else { + ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text }); } - } else { - ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text }); - } - // A right-click activates the tab too, as in Excel, so the menu's sheet commands (Protect - // Sheet, Select All Sheets) act on the sheet that was clicked rather than the one that was active. - if resp.clicked() || (resp.secondary_clicked() && !active) { - activate_sheet(app, i); - } - if resp.double_clicked() { - app.grid.renaming_tab = Some(i); - app.grid.rename_text = name.clone(); - } - // Drag to reorder; with the copy modifier held, drop a copy instead. - let copy = drop_copies_sheet(ui.input(|inp| inp.modifiers), ui.ctx().os() == egui::os::OperatingSystem::Mac); - if resp.dragged() && copy { - ui.ctx().set_cursor_icon(egui::CursorIcon::Copy); - } - if resp.drag_stopped() - && let Some(p) = ui.input(|inp| inp.pointer.interact_pos()) - { - let dx = p.x - r.center().x; - let steps = (dx / w).round() as i64; - if let Some(params) = tab_drop(i, steps, copy) { - app.run_or_alert("sheet.move", params); + // A right-click activates the tab too, as in Excel, so the menu's sheet commands (Protect + // Sheet, Select All Sheets) act on the sheet that was clicked rather than the one that was active. + if resp.clicked() || (resp.secondary_clicked() && !active) { + activate_sheet(app, i); } - } - resp.context_menu(|ui| { - ui.set_min_width(180.0); - if ui.button(app.ui.language.tr("Insert Sheet")).clicked() { - app.run_or_alert("home.insertSheet", json!({"before": i})); - } - if ui.button(app.ui.language.tr("Delete")).clicked() { - app.run_or_alert("home.deleteSheet", json!({"sheet": i})); - } - if ui.button(app.ui.language.tr("Rename")).clicked() { + if resp.double_clicked() { app.grid.renaming_tab = Some(i); app.grid.rename_text = name.clone(); } - if ui.button(app.ui.language.tr("Move or Copy…")).clicked() { - app.open_dialog("moveSheet", json!({"sheet": i})); + // Drag to reorder; with the copy modifier held, drop a copy instead. + let copy = drop_copies_sheet(ui.input(|inp| inp.modifiers), ui.ctx().os() == egui::os::OperatingSystem::Mac); + if resp.dragged() && copy { + ui.ctx().set_cursor_icon(egui::CursorIcon::Copy); } - if ui.button(app.ui.language.tr("Duplicate")).clicked() { - app.run_or_alert("sheet.move", json!({"sheet": i, "to": i + 1, "copy": true})); + if resp.drag_stopped() + && let Some(p) = ui.input(|inp| inp.pointer.interact_pos()) + { + let dx = p.x - r.center().x; + let steps = (dx / w).round() as i64; + if let Some(params) = tab_drop(i, steps, copy) { + app.run_or_alert("sheet.move", params); + } } - ui.menu_button(app.ui.language.tr("Tab Color"), |ui| { - let colors = app.session.active().map(|d| d.wb.theme.colors).unwrap_or_default(); - if let Some(c) = crate::widgets::color_palette_fixed(ui, &colors, app.ui.language.tr("No Color")) { - app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c})); + resp.context_menu(|ui| { + ui.set_min_width(180.0); + if ui.button(app.ui.language.tr("Insert Sheet")).clicked() { + app.run_or_alert("home.insertSheet", json!({"before": i})); + } + if ui.button(app.ui.language.tr("Delete")).clicked() { + app.run_or_alert("home.deleteSheet", json!({"sheet": i})); + } + if ui.button(app.ui.language.tr("Rename")).clicked() { + app.grid.renaming_tab = Some(i); + app.grid.rename_text = name.clone(); + } + if ui.button(app.ui.language.tr("Move or Copy…")).clicked() { + app.open_dialog("moveSheet", json!({"sheet": i})); + } + if ui.button(app.ui.language.tr("Duplicate")).clicked() { + app.run_or_alert("sheet.move", json!({"sheet": i, "to": i + 1, "copy": true})); + } + ui.menu_button(app.ui.language.tr("Tab Color"), |ui| { + let colors = app.session.active().map(|d| d.wb.theme.colors).unwrap_or_default(); + if let Some(c) = crate::widgets::color_palette_fixed(ui, &colors, app.ui.language.tr("No Color")) { + app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c})); + ui.close(); + } + }); + ui.separator(); + if ui.button(app.ui.language.tr("Hide")).clicked() { + app.run_or_alert("sheet.hide", json!({"sheet": i})); + } + if ui.button(app.ui.language.tr("Unhide…")).clicked() { + app.run_or_alert("sheet.unhide", json!({})); + } + ui.separator(); + let protected = app.active_sheet_protected(); + if ui + .button(if protected { + app.ui.language.tr("Unprotect Sheet…") + } else { + app.ui.language.tr("Protect Sheet…") + }) + .clicked() + { + app.open_dialog("protectSheet", json!({})); + } + if ui.button(app.ui.language.tr("Select All Sheets")).clicked() { ui.close(); } }); - ui.separator(); - if ui.button(app.ui.language.tr("Hide")).clicked() { - app.run_or_alert("sheet.hide", json!({"sheet": i})); - } - if ui.button(app.ui.language.tr("Unhide…")).clicked() { - app.run_or_alert("sheet.unhide", json!({})); - } - ui.separator(); - let protected = app.active_sheet_protected(); - if ui - .button(if protected { - app.ui.language.tr("Unprotect Sheet…") - } else { - app.ui.language.tr("Protect Sheet…") - }) - .clicked() - { - app.open_dialog("protectSheet", json!({})); - } - if ui.button(app.ui.language.tr("Select All Sheets")).clicked() { - ui.close(); - } - }); - } + } + }); }); - }); - }); - ui.add_space(6.0); - if crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, app.ui.language.tr("New sheet (⇧F11)"), vec2(26.0, 26.0)).clicked() { - app.run_or_alert("home.insertSheet", json!({})); + }); + ui.add_space(6.0); + if crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, app.ui.language.tr("New sheet (⇧F11)"), vec2(26.0, 26.0)).clicked() { + app.run_or_alert("home.insertSheet", json!({})); + } + } + if !view.show_horizontal_scroll { + return; } // The track ends where the grid does, so it stops under the vertical scrollbar. let avail = ui.available_rect_before_wrap(); @@ -312,8 +327,14 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { 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)); + // Without sheet tabs there is nothing to split: the scroll bar takes the bar. + let track_left = if view.show_sheet_tabs { + tab_split_handle(app, ui, split, (bar_left, bar_width), right, uid, &t); + split.right() + } else { + avail.left() + }; + let track = Rect::from_min_max(pos2(track_left, avail.center().y - 7.0), pos2(right, avail.center().y + 7.0)); crate::grid::horizontal_scrollbar(app, ui, track, &t); }); }); diff --git a/crates/ui-egui/tests/it/tab_split.rs b/crates/ui-egui/tests/it/tab_split.rs index 03ca5e9c5..7189358ac 100644 --- a/crates/ui-egui/tests/it/tab_split.rs +++ b/crates/ui-egui/tests/it/tab_split.rs @@ -151,3 +151,49 @@ fn each_workbook_keeps_its_own_tab_share() { 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"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..503a77179 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Display options for this workbook: sheet tabs and scroll bars; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **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 @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | File › Options › Display options for this workbook: Show horizontal scroll bar, Show vertical scroll bar and Show sheet tabs (`view.showHorizontalScrollBar`, `view.showVerticalScrollBar`, `view.showSheetTabs`), saved with the workbook; with the sheet tabs off the scroll bar takes the bar, with the scroll bar off the tabs do, with both off the bar goes, and without the vertical scroll bar the grid takes its width (#3101) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it |