mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-05 01:58:58 +00:00
#3101: show or hide the sheet tabs and scroll bars of a workbook
Excel can hide the sheet tabs, the horizontal scroll bar and the vertical scroll bar of a workbook window (File › Options › Advanced › Display options for this workbook), saved with the workbook. GridCraft already read and wrote these settings (#3057) but always showed all three, and had no command or option for them. New commands view.showSheetTabs, view.showHorizontalScrollBar and view.showVerticalScrollBar {on?} (toggling without `on`, undoable, saved with the workbook), and a "Display options for this workbook" section in File › Options. The tab bar follows them: without the tabs the scroll bar takes the bar, without the scroll bar the tabs do, and with neither the bar isn't shown; without the vertical scroll bar the grid takes its width. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
178f3eb745
commit
846cc09d67
7 files changed
+303
-156
No files matched your search
@@ -40,6 +40,27 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// File › Options › Advanced › Display options for this workbook: the sheet tabs and the two
|
||||
/// scroll bars, saved with the workbook (`<workbookView showSheetTabs … />`).
|
||||
fn workbook_view_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
|
||||
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<Json> {
|
||||
use gridcraft_model::ShowObjects;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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<PaneQuad> {
|
||||
/// 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;
|
||||
|
||||
+173
-152
@@ -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::<usize>()
|
||||
{
|
||||
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::<usize>()
|
||||
{
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+2
-1
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user