ui: show spill-cell formula as greyed out

This commit is contained in:
Pranav Chandra
2026-10-14 20:23:44 +05:30
parent 84acf6293b
commit 3177fbe03f
5 changed files with 164 additions and 18 deletions
+1 -1
View File
@@ -130,7 +130,7 @@ fn cell_get(s: &mut Session, p: &Json) -> Result<Json> {
"text": crate::display::cell_text(&d.wb, sh, c),
"input": sh.input_text(c),
"picture": sh.cell_pictures.get(&c).map(|picture| json!({"mime": picture.mime, "alt": picture.alt, "bytes": picture.data.len()})),
"spilledFrom": sh.spill_ranges.iter().find(|(a, r)| **a != c && r.contains(c)).map(|(a, _)| a.a1()),
"spilledFrom": sh.spill_anchor(c).map(|a| a.a1()),
"spill": sh.spill_ranges.get(&c).map(|r| r.a1()),
"style": style,
"merge": sh.merge_at(c).map(|m| m.a1()),
+6
View File
@@ -170,6 +170,12 @@ impl Sheet {
pub fn cell(&self, c: CellRef) -> Option<&Cell> {
self.cells.get(c)
}
/// The source of a non-anchor dynamic-array spill cell, from the current calc state.
/// Anchors, ordinary cells and cells outside successfully generated spills return `None`.
pub fn spill_anchor(&self, c: CellRef) -> Option<CellRef> {
self.spill_ranges.iter().find(|(anchor, range)| **anchor != c && range.contains(c)).map(|(anchor, _)| *anchor)
}
/// Displayed value of a cell: its own value, or a spilled value.
pub fn value(&self, c: CellRef) -> Value {
match self.cells.get(c) {
+149 -15
View File
@@ -39,20 +39,20 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
ui.painter().rect_stroke(field, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field.shrink2(vec2(6.0, 3.0))));
let id = egui::Id::new("gridcraft.formula_bar");
let editing_here = app.editor.as_ref().is_some_and(|e| e.from_formula_bar);
let read_only = active_spill_anchor(app).is_some();
if app.editor.is_some() {
editor_widget(app, &mut child, id, theme::ui_font(13.0), expanded, field.width() - 12.0);
} else {
// Show the active cell's content; clicking starts editing in the bar.
let text = active_input(app);
let color = if read_only { t.text_disabled } else { t.text };
let resp = child
.add(egui::Label::new(egui::RichText::new(text).font(theme::ui_font(13.0)).color(t.text)).truncate().sense(Sense::click()));
.add(egui::Label::new(egui::RichText::new(text).font(theme::ui_font(13.0)).color(color)).truncate().sense(Sense::click()));
let click = child.interact(field, id.with("click"), Sense::click());
if resp.clicked() || click.clicked() {
if !read_only && (resp.clicked() || click.clicked()) {
app.begin_edit(None, true);
}
}
let _ = editing_here;
ui.allocate_space(vec2(avail, h - 6.0));
let chevron = if expanded { Icon::ChevronUp } else { Icon::Chevron };
if crate::widgets::icon_button(ui, chevron, t.text_dim, "Expand formula bar", vec2(20.0, 22.0)).clicked() {
@@ -62,6 +62,14 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
});
}
/// Resolve ownership on every frame; never cache a spill range in presentation state.
pub(crate) fn active_spill_anchor(app: &SheetApp) -> Option<gridcraft_engine::core::CellRef> {
let d = app.session.active()?;
let sh = d.wb.active()?;
let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active);
sh.spill_anchor(at)
}
fn active_input(app: &SheetApp) -> String {
let Some(d) = app.session.active() else { return String::new() };
let Some(sh) = d.wb.active() else { return String::new() };
@@ -69,6 +77,15 @@ fn active_input(app: &SheetApp) -> String {
if let Some(picture) = sh.cell_pictures.get(&a) {
return if picture.alt.is_empty() { "Picture in cell".into() } else { format!("Picture: {}", picture.alt) };
}
if let Some(anchor) = sh.spill_anchor(a) {
if gridcraft_engine::display::formula_hidden(&d.wb, sh, anchor) {
return String::new();
}
return sh
.cell(anchor)
.map(|c| crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet))
.unwrap_or_default();
}
if let Some(c) = sh.cell(a) {
if c.formula.is_some() && d.wb.styles.get(c.style).protection.hidden && sh.is_protected() {
return String::new();
@@ -81,17 +98,6 @@ fn active_input(app: &SheetApp) -> String {
gridcraft_engine::display::edit_text(&d.wb, sh, a)
};
}
// A spilled cell shows the anchor's formula greyed out in Excel; we show it plainly.
for (anchor, r) in &sh.spill_ranges {
if r.contains(a)
&& let Some(f) = sh.cell(*anchor).and_then(|c| c.formula.as_ref())
{
if gridcraft_engine::display::formula_hidden(&d.wb, sh, *anchor) {
return String::new();
}
return crate::formula_locale::display(&format!("={}", f.text), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet);
}
}
String::new()
}
@@ -474,3 +480,131 @@ pub fn function_signature(name: &str) -> Option<String> {
pub fn function_description(name: &str) -> Option<String> {
function_info(name).and_then(|f| f["description"].as_str().map(str::to_string))
}
#[cfg(test)]
mod spill_tests {
use super::*;
use gridcraft_engine::Session;
fn app() -> SheetApp {
let mut s = Session::new();
s.new_workbook();
SheetApp::new(s, Default::default())
}
fn set(app: &mut SheetApp, cell: &str, input: &str) {
app.run("cell.set", json!({"cell": cell, "input": input})).unwrap();
}
fn select(app: &mut SheetApp, cell: &str) {
app.cancel_edit();
app.run("selection.set", json!({"cell": cell})).unwrap();
}
fn check(app: &mut SheetApp, cell: &str, text: &str, read_only: bool) {
select(app, cell);
assert_eq!(active_input(app), text);
assert_eq!(active_spill_anchor(app).is_some(), read_only);
app.begin_edit(None, true);
assert_eq!(app.editor.is_none(), read_only);
app.cancel_edit();
if read_only {
app.begin_edit(Some("=SUM(1,2)".into()), true);
assert!(app.editor.is_none());
}
}
#[test]
fn selection_restores_editing_and_keeps_spill_formulas_only_at_anchor() {
let mut app = app();
set(&mut app, "A1", "=SEQUENCE(5,1,10,10)");
set(&mut app, "D1", "=SUM(1,2)");
set(&mut app, "D2", "hello");
for cell in ["A2", "A3", "A4", "A5"] {
check(&mut app, cell, "=SEQUENCE(5,1,10,10)", true);
}
check(&mut app, "A1", "=SEQUENCE(5,1,10,10)", false);
check(&mut app, "D1", "=SUM(1,2)", false);
check(&mut app, "D2", "hello", false);
check(&mut app, "E1", "", false);
let sh = app.session.active().unwrap().wb.active().unwrap();
assert!(sh.cell(gridcraft_engine::core::CellRef::parse("A2").unwrap()).and_then(|c| c.formula.as_ref()).is_none());
// Intentional grid replacement retains the existing entry behavior.
select(&mut app, "A2");
app.begin_edit(Some("replacement".into()), false);
assert!(app.editor.is_some());
}
#[test]
fn spill_ownership_follows_recalculation_and_obstructions() {
let mut app = app();
set(&mut app, "D1", "2");
set(&mut app, "A1", "=SEQUENCE(D1,2)");
check(&mut app, "B2", "=SEQUENCE(D1,2)", true);
set(&mut app, "D1", "4");
check(&mut app, "B4", "=SEQUENCE(D1,2)", true);
set(&mut app, "D1", "1");
check(&mut app, "B4", "", false);
check(&mut app, "B1", "=SEQUENCE(D1,2)", true);
set(&mut app, "A1", "");
check(&mut app, "B1", "", false);
set(&mut app, "B2", "blocked");
set(&mut app, "A1", "=SEQUENCE(3,2)");
check(&mut app, "A2", "", false);
check(&mut app, "B2", "blocked", false);
set(&mut app, "B2", "");
check(&mut app, "B2", "=SEQUENCE(3,2)", true);
}
#[test]
fn formatted_children_and_other_dynamic_array_functions() {
let mut app = app();
app.run("home.bold", json!({"range": "A2"})).unwrap();
set(&mut app, "D1", "3");
set(&mut app, "D2", "1");
set(&mut app, "D3", "3");
for formula in ["=SORT(D1:D3)", "=UNIQUE(D1:D3)", "=FILTER(D1:D3,D1:D3>0)"] {
set(&mut app, "A1", formula);
check(&mut app, "A2", formula, true);
}
app.run("home.insertSheet", json!({"name": "Other"})).unwrap();
set(&mut app, "A1", "=SORT(Sheet1!D1:D3)");
check(&mut app, "A2", "=SORT(Sheet1!D1:D3)", true);
app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
check(&mut app, "A2", "=FILTER(D1:D3,D1:D3>0)", true);
}
#[test]
fn formula_bar_click_and_theme_presentation() {
use egui_kittest::kittest::Queryable as _;
for dark in [false, true] {
let mut app = app();
set(&mut app, "A1", "=SEQUENCE(3)");
select(&mut app, "A2");
let mut fonts_ready = false;
let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 100.0)).build_ui_state(
move |ui, app: &mut SheetApp| {
if !fonts_ready {
ui.ctx().set_fonts(theme::font_definitions());
fonts_ready = true;
return;
}
ui.ctx().set_visuals(if dark { egui::Visuals::dark() } else { egui::Visuals::light() });
show(app, ui);
},
app,
);
h.run_steps(3);
let disabled = Tokens::get(&h.ctx).text_disabled;
assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t)
if t.galley.job.text == "=SEQUENCE(3)" && t.galley.job.sections.iter().all(|section| section.format.color == disabled))));
h.get_by_label("=SEQUENCE(3)").click();
h.run_steps(3);
assert!(h.state().editor.is_none());
select(h.state_mut(), "A1");
h.run_steps(3);
h.get_by_label("=SEQUENCE(3)").click();
h.run_steps(3);
assert!(h.state().editor.as_ref().is_some_and(|e| e.from_formula_bar));
}
}
}
+5
View File
@@ -863,6 +863,11 @@ impl SheetApp {
/// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it.
pub fn begin_edit(&mut self, text: Option<String>, from_formula_bar: bool) {
// The source formula is display-only on spill children. Grid replacement keeps its
// existing behavior, but no formula-bar entry path may edit that displayed formula.
if from_formula_bar && formula_bar::active_spill_anchor(self).is_some() {
return;
}
if let Err(e) = self.commit_text_box_edit() {
self.message = Some(("Text Box".into(), clean_error(&e)));
return;
+3 -2
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (spill-child formula bar is muted and read-only) · **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
@@ -20,7 +20,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 | done |
| 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 |
| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) |
| Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial |
| Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing |
@@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Spill-child formula bar uses the anchor formula with disabled theme text and blocks formula-bar edit entry; selection and recalculation restore ordinary behavior |
| 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread |
| 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) |
| 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md |