diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 86a4a6cc5..4f47cf085 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -163,8 +163,19 @@ impl SheetApp { Ok(Json::Null) } "view.expandFormulaBar" => { - self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded); - Ok(json!({"on": self.ui.formula_bar_expanded})) + // `lines` is where a drag of the bar's bottom edge left it: one line collapses it, + // more expands it to that height, which expanding returns to afterwards. + if let Some(lines) = p.get("lines").and_then(Json::as_u64) { + let lines = usize::try_from(lines).unwrap_or(usize::MAX).clamp(1, crate::formula_bar::MAX_DRAG_LINES); + self.ui.formula_bar_expanded = lines > 1; + if lines > 1 { + self.ui.formula_bar_lines = lines; + } + Ok(json!({"on": self.ui.formula_bar_expanded, "lines": self.ui.formula_bar_lines})) + } else { + self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded); + Ok(json!({"on": self.ui.formula_bar_expanded})) + } } "view.darkMode" => { let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark }; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2d46b21b8..38e86f106 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -263,7 +263,11 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("ui.ribbonTab", "Ribbon Tab", ""), ("app.checkForUpdates", "Check for Updates…", ""), ("view.collapseRibbon", "Collapse Ribbon", ""), - ("view.expandFormulaBar", "Expand Formula Bar", "{on?: bool} (toggles without it) → {on}"), + ( + "view.expandFormulaBar", + "Expand Formula Bar", + "{on?: bool} (toggles without it) → {on}; {lines: int} sets the expanded height (1 collapses) → {on, lines}", + ), ("ui.pythonMode", "Python Mode", "{on?: bool} (toggles without it) → {on}; a refused start is an error"), ("view.darkMode", "Dark Mode", ""), ("view.theme", "Display Theme", ""), diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 25178ce50..9be205a4f 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -12,6 +12,11 @@ use crate::theme::{self, Tokens}; /// Lines the formula bar (and the in-cell editor) grows to before it scrolls. pub const MAX_LINES: usize = 5; +/// The most lines a drag of the bar's bottom edge can give it (the window caps it at half its +/// height first). +pub const MAX_DRAG_LINES: usize = 100; +/// How far the bar's bottom edge reaches up into the bar as a resize handle (its bottom margin). +const HANDLE: f32 = 4.0; /// The bar's height at one line. const ONE_LINE: f32 = 30.0; /// What the bar adds around the field's rows: the panel's margins (3 + 3), the field's (3 + 3) @@ -40,11 +45,22 @@ pub struct BarLayout { pub visible_lines: usize, /// The caret while editing in the bar. pub caret: Option, + /// The bottom edge that resizes the bar. + pub handle: Rect, } impl Default for BarLayout { fn default() -> Self { - BarLayout { name_box: Rect::NOTHING, buttons: Rect::NOTHING, field: Rect::NOTHING, height: 0.0, lines: 1, visible_lines: 1, caret: None } + BarLayout { + name_box: Rect::NOTHING, + buttons: Rect::NOTHING, + field: Rect::NOTHING, + height: 0.0, + lines: 1, + visible_lines: 1, + caret: None, + handle: Rect::NOTHING, + } } } @@ -73,13 +89,15 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let font = theme::ui_font(13.0); let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font)); let last = BarLayout::get(ui.ctx()); - // Height follows the content's line count (last frame's), 1 to MAX_LINES; the chevron pins it - // at MAX_LINES. It eases only when that target changes. - let lines = if app.ui.formula_bar_expanded { MAX_LINES } else { last.lines.clamp(1, MAX_LINES) }; + // Height follows the content's line count (last frame's), 1 to MAX_LINES; expanded (the + // chevron), it is the height the bottom edge was dragged to. It eases only when that target + // changes. + let most = max_lines(ui.ctx().content_rect().height(), row_h); + let lines = if app.ui.formula_bar_expanded { app.ui.formula_bar_lines.clamp(1, most) } else { last.lines.clamp(1, MAX_LINES) }; let target = one_line(row_h) + (lines - 1) as f32 * row_h; let h = ui.ctx().animate_value_with_time(egui::Id::unique("formula_bar.height"), target, EASE_SECONDS); let mut layout = BarLayout { height: h, ..Default::default() }; - egui::Panel::top("formula_bar") + let panel = egui::Panel::top("formula_bar") .exact_size(h) .frame(egui::Frame::NONE.fill(t.window).inner_margin(egui::Margin { left: 8, right: 8, top: 3, bottom: 3 })) .show(ui, |ui| { @@ -156,9 +174,58 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } }); }); + layout.handle = resize_handle(app, ui, panel.response.rect, row_h, most); ui.ctx().data_mut(|d| d.insert_temp(BarLayout::id(), layout)); } +/// The most lines the bar takes in a window `window_h` tall: half the window, as in Excel, and +/// never fewer than the chevron's [`MAX_LINES`]. +fn max_lines(window_h: f32, row_h: f32) -> usize { + let fit = ((window_h * 0.5 - one_line(row_h)) / row_h).floor(); + if fit.is_finite() && fit > 0.0 { (fit as usize + 1).clamp(MAX_LINES, MAX_DRAG_LINES) } else { MAX_LINES } +} + +/// The lines a bar whose top is at `top` shows with its bottom edge dragged to `y`, in whole lines. +fn dragged_lines(y: f32, top: f32, row_h: f32, most: usize) -> usize { + let extra = ((y - top - one_line(row_h)) / row_h).round(); + if extra.is_finite() && extra > 0.0 { (extra as usize + 1).min(most) } else { 1 } +} + +/// The bar's bottom edge: dragging it sets the bar's height in whole lines, as in Excel. The bar +/// follows the pointer; the release runs `view.expandFormulaBar` with the lines, so one drag is +/// one step (one line collapses the bar). +fn resize_handle(app: &mut SheetApp, ui: &mut egui::Ui, bar: Rect, row_h: f32, most: usize) -> Rect { + let handle = Rect::from_x_y_ranges(bar.x_range(), (bar.bottom() - HANDLE)..=bar.bottom()); + let resp = ui.interact(handle, egui::Id::unique("formula_bar.resize"), Sense::drag()); + let name = app.ui.language.tr("Resize formula bar"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name)); + if resp.hovered() || resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + // The state before the drag: the preview changes it, the release puts it back for the command. + let before = resp.id.with("before"); + if resp.drag_started() { + let state = (app.ui.formula_bar_expanded, app.ui.formula_bar_lines); + ui.ctx().data_mut(|d| d.insert_temp(before, state)); + } + if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.drag_stopped()) { + let lines = dragged_lines(p.y, bar.top(), row_h, most); + if resp.drag_stopped() { + if let Some((expanded, kept)) = ui.ctx().data_mut(|d| d.remove_temp::<(bool, usize)>(before)) { + (app.ui.formula_bar_expanded, app.ui.formula_bar_lines) = (expanded, kept); + } + app.run_or_alert("view.expandFormulaBar", json!({"lines": lines})); + } else { + app.ui.formula_bar_expanded = lines > 1; + if lines > 1 { + app.ui.formula_bar_lines = lines; + } + ui.ctx().request_repaint(); + } + } + handle +} + /// Resolve ownership on every frame; never cache a spill range in presentation state. pub(crate) fn active_spill_anchor(app: &SheetApp) -> Option { let d = app.session.active()?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 95f861f14..d76787796 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -88,6 +88,9 @@ pub struct UiState { pub ribbon_tab: String, pub ribbon_collapsed: bool, pub formula_bar_expanded: bool, + /// Lines the expanded formula bar shows: the height its bottom edge was last dragged to. + /// Expanding it again (the chevron, Ctrl+Shift+U) returns to that height. + pub formula_bar_lines: usize, pub status_bar: bool, /// Motion in the UI: the selection outline glides to a new selection (View ▸ Animations). pub animations: bool, @@ -137,6 +140,7 @@ impl Default for UiState { ribbon_tab: "Home".into(), ribbon_collapsed: false, formula_bar_expanded: false, + formula_bar_lines: formula_bar::MAX_LINES, status_bar: true, animations: true, autocomplete: true, diff --git a/crates/ui-egui/tests/it/formula_bar_expand.rs b/crates/ui-egui/tests/it/formula_bar_expand.rs index a71a979cd..cabf7ee70 100644 --- a/crates/ui-egui/tests/it/formula_bar_expand.rs +++ b/crates/ui-egui/tests/it/formula_bar_expand.rs @@ -1,6 +1,6 @@ //! Ctrl+Shift+U (Control+Shift+U in Excel for Mac) expands and collapses the formula bar. -use egui::{Event, Key, Modifiers}; +use egui::{Event, Key, Modifiers, vec2}; use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; use gridcraft_ui_egui::keymap::{Shortcut, lookup}; @@ -84,3 +84,66 @@ fn expand_formula_bar_command_sets_or_toggles_the_state() { assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({})), Ok(json!({"on": false}))); assert!(!expanded(&h)); } + +fn drag(h: &mut Harness, from: egui::Pos2, to: egui::Pos2) { + use egui::PointerButton; + h.input_mut().events.extend([ + Event::PointerMoved(from), + Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.step(); + for i in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 6.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(20); +} + +fn bar(h: &Harness) -> gridcraft_ui_egui::formula_bar::BarLayout { + gridcraft_ui_egui::formula_bar::BarLayout::get(&h.ctx) +} + +#[test] +fn dragging_the_bottom_edge_sets_the_height_that_expanding_returns_to() { + let mut h = harness(); + h.run_steps(20); + let start = bar(&h); + assert_eq!(start.visible_lines, 1, "one line of text shows a one-line bar"); + assert!( + start.handle.height() > 0.0 && (start.handle.bottom() - start.field.bottom()).abs() <= 4.0, + "the handle is the bar's bottom edge: {start:?}" + ); + // Drag the edge down about five and a half rows: the bar snaps to whole lines. + let row = 17.0; + drag(&mut h, start.handle.center(), start.handle.center() + vec2(0.0, 5.5 * row)); + let lines = h.state().ui.formula_bar_lines; + assert!(expanded(&h), "a taller bar is expanded"); + assert!((6..=7).contains(&lines), "about six more lines: {lines}"); + assert_eq!(bar(&h).visible_lines, lines, "the field shows the dragged lines: {:?}", bar(&h)); + // Collapse, then expand: back to the dragged height, not the default five lines. + ctrl_shift_u(&mut h, CTRL_SHIFT); + h.run_steps(20); + assert!(!expanded(&h)); + assert_eq!(bar(&h).visible_lines, 1); + ctrl_shift_u(&mut h, CTRL_SHIFT); + h.run_steps(20); + assert_eq!(bar(&h).visible_lines, lines, "expanding returns to the dragged height"); + // Dragging the edge up to one line collapses the bar and keeps the expanded height. + let edge = bar(&h).handle.center(); + drag(&mut h, edge, edge - vec2(0.0, 20.0 * row)); + assert!(!expanded(&h), "a one-line bar is collapsed"); + assert_eq!(bar(&h).visible_lines, 1); + assert_eq!(h.state().ui.formula_bar_lines, lines); +} + +#[test] +fn expand_formula_bar_with_lines_sets_the_expanded_height() { + let mut h = harness(); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 8})), Ok(json!({"on": true, "lines": 8}))); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 1})), Ok(json!({"on": false, "lines": 8})), "one line collapses"); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({})), Ok(json!({"on": true}))); + assert_eq!(h.state().ui.formula_bar_lines, 8, "expanding returns to the set height"); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 0})), Ok(json!({"on": false, "lines": 8})), "zero lines is one line"); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": u64::MAX})), Ok(json!({"on": true, "lines": 100})), "capped"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..5c1fc5543 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -22,7 +22,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers | | Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | | Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | -| Formula bar with Name Box, expand, function hints | yes | yes; grows with its content from one to five lines (Alt+Enter, wrapped text, or viewing a multi-line cell), then scrolls with the caret in view, the Name Box and buttons staying on the first line; the chevron pins it at five; the in-cell editor shows every line | done; no drag-to-resize handle | +| Formula bar with Name Box, expand, function hints | yes | yes; grows with its content from one to five lines (Alt+Enter, wrapped text, or viewing a multi-line cell), then scrolls with the caret in view, the Name Box and buttons staying on the first line; dragging its bottom edge sets the height in whole lines (up to half the window; one line collapses it), and the chevron or Ctrl+Shift+U expands it back to that height (five lines until it is dragged); the in-cell editor shows every line | done; the Name Box width is fixed | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | Ctrl-drag (Cmd or Option on macOS) drops a copy (#971); active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Formula bar with Name Box, expand, function hints | yes | yes | done; dynamic-array spill children show the source formula muted and read-only, using current spill ownership | | Python cells: Python mode | yes | yes (11-01): Ctrl+Alt+Shift+P (Cmd+Option+Shift+P on a Mac), `=PY` then Tab, or editing a Python cell edits the code in the formula bar's code editor, with a PY badge beside fx; Enter starts a new line with the indentation, Tab and Shift+Tab indent, Ctrl+Enter (Cmd+Enter) puts the code in the cell through `python.set`, Esc cancels. Deleting all the code and committing clears a Python cell, as an empty entry clears any other cell. The formula bar shows while a Python edit is open, also when View › Formula Bar hides it. On a protected sheet, a Python cell formatted Hidden shows no code in the formula bar and starts no edit, as a hidden formula shows none | this build stores Python code and doesn't run it: new or changed code shows `#BLOCKED!`; no `xl()` pointing on the sheet yet | @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | The formula bar's bottom edge is a resize handle: dragging it sets the bar's height in whole lines, up to half the window, and dragging it to one line collapses it; expanding (the chevron, Ctrl+Shift+U) returns to the dragged height, which is kept with the UI preferences. `view.expandFormulaBar {lines}` sets it (#1246) | | 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 |