Conditional formatting: New Rule's formula box takes references clicked or dragged on the grid (#2348)

This commit is contained in:
Marco Arnulfo
2026-10-23 21:56:39 +02:00
parent 530a8cc810
commit d5aa6fbcbc
5 changed files with 270 additions and 3 deletions
+84
View File
@@ -7,6 +7,7 @@
use egui::{Color32, Key, Sense, Stroke, StrokeKind, vec2};
use crate::icons::{self, Icon};
use gridcraft_engine::core::{CellRef, RangeRef};
use serde_json::{Map, Value as Json, json};
use crate::SheetApp;
@@ -44,6 +45,24 @@ pub struct Dialog {
/// The dialog this one was opened from (Name Manager › New…): it comes back when this closes.
back: Option<Box<Dialog>>,
focus_initialized: bool,
/// The formula box that takes references clicked or dragged on the grid (New Formatting Rule).
pick: Option<RefPick>,
}
/// A formula box in point mode, as in Excel: a click on the grid writes the cell's absolute
/// reference at the caret instead of selecting it, and a drag grows it into a range.
#[derive(Clone, Debug)]
struct RefPick {
key: &'static str,
/// The sheet the dialog was opened on: references to another sheet carry its name.
sheet: usize,
/// Caret (char index), mirrored from the text box while it has the keyboard.
caret: usize,
/// The reference last inserted (byte span), replaced by the next click or the drag.
point: Option<(usize, usize)>,
anchor: CellRef,
/// Give the box the keyboard back, caret after the reference, on the next frame.
refocus: bool,
}
impl Dialog {
@@ -77,6 +96,43 @@ impl Dialog {
self.values.insert(key.into(), value);
}
/// Whether a click on the grid goes to a formula box of this dialog rather than the selection.
pub fn picks_references(&self) -> bool {
self.pick.is_some()
}
/// A click (`extend` false) or a drag reaching `cell`: writes the absolute reference at the
/// formula box's caret, replacing the reference the press inserted, as in the formula bar's
/// point mode. An empty box starts with `=`. After an operand there is no place for a
/// reference, so nothing changes. `active_sheet` names the sheet shown when it is not the
/// dialog's own.
pub fn pick_reference(&mut self, cell: CellRef, active_sheet: (usize, &str), extend: bool) {
let Some(pick) = self.pick.as_mut() else { return };
let text = self.values.get(pick.key).and_then(Json::as_str).unwrap_or("").to_string();
let mut ed = crate::editor::EditState::new(pick.sheet, cell, text, true, false);
ed.caret = pick.caret.min(ed.text.chars().count());
ed.point = pick.point;
if ed.text.trim().is_empty() {
ed.text = "=".into();
ed.caret = 1;
ed.point = None;
}
if !ed.can_point() {
return;
}
if !extend {
pick.anchor = cell;
}
let range = abs(&RangeRef::new(pick.anchor, cell));
let reference =
if active_sheet.0 == pick.sheet { range } else { format!("{}!{range}", gridcraft_engine::formula::quote_sheet(active_sheet.1)) };
ed.insert_ref(&reference);
pick.caret = ed.caret;
pick.point = ed.point;
pick.refocus = true;
self.values.insert(pick.key.into(), json!(ed.text));
}
fn form(name: &str, title: &str, command: &'static str, fields: Vec<Field>, defaults: Json) -> Dialog {
Dialog {
name: name.into(),
@@ -94,6 +150,7 @@ impl Dialog {
picture_target: None,
back: None,
focus_initialized: false,
pick: None,
}
}
@@ -114,6 +171,7 @@ impl Dialog {
picture_target: None,
back: None,
focus_initialized: false,
pick: None,
}
}
@@ -690,6 +748,10 @@ impl Dialog {
fields,
json!({"preset": "lightRedFillDarkRedText", "period": "today"}),
);
if ty == "expression" {
let sheet = app.session.active().map(|doc| doc.wb.active_sheet).unwrap_or(0);
d.pick = Some(RefPick { key: "formula", sheet, caret: 0, point: None, anchor: CellRef::new(0, 0), refocus: false });
}
d.values.insert("type".into(), json!(ty));
d.values.insert("operator".into(), p("operator"));
d
@@ -1634,6 +1696,25 @@ fn take_first_focus(d: &mut Dialog, r: &egui::Response) {
d.focus_first_field = false;
}
/// Keeps a picking formula box's caret, and gives it the keyboard back after a grid click put a
/// reference in it (the click took the focus), its caret after the reference.
fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) {
if typed {
pick.point = None;
}
if pick.refocus {
let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default();
state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(pick.caret))));
state.store(&r.ctx, r.id);
r.request_focus();
pick.refocus = false;
} else if r.has_focus()
&& let Some(range) = egui::text_edit::TextEditState::load(&r.ctx, r.id).and_then(|state| state.cursor.char_range())
{
pick.caret = range.primary.index.into();
}
}
/// Draws a dialog's `fields`, editing its values.
fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
// Field labels, choices and notes are English source strings, translated as they are drawn.
@@ -1659,6 +1740,9 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
if r.changed() {
d.values.insert(key.into(), json!(s));
}
if let Some(pick) = d.pick.as_mut().filter(|pick| pick.key == key) {
track_pick(pick, &r, r.changed());
}
});
}
Field::Password { key, label } => {
+20 -2
View File
@@ -2336,6 +2336,19 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
app.grid.filter_menu = Some((c.col, c.row, geo.mirror_pos(p)));
return;
}
// A dialog's formula box in point mode (New Formatting Rule) takes the reference; the
// selection, which the dialog acts on, stays.
if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) {
d.pick_reference(c, (wb.active_sheet, &sh.name), false);
app.grid.drag = Drag::Select;
app.grid.drag_select = true;
if resp.drag_started()
&& let Some(p) = pos
{
select_to_pointer(app, geo, sh, wb, p);
}
return;
}
// Point mode while editing a formula: clicking inserts a reference.
if let Some(ed) = app.editor.as_mut()
&& ed.can_point()
@@ -2643,7 +2656,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
/// An armed Format Painter pastes onto the cells just clicked or dragged over (not while
/// pointing a formula reference, which also drags a Select).
fn format_painter_apply(app: &mut SheetApp) {
if app.session.format_painter.is_some() && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) {
if app.session.format_painter.is_some()
&& !app.editor.as_ref().is_some_and(|ed| ed.can_point())
&& !app.dialog.as_ref().is_some_and(|d| d.picks_references())
{
let dest = app.session.active().map(|d| d.selection.current().a1());
if let Some(dest) = dest
&& let Err(e) = app.run("edit.formatPainter", json!({"apply": dest}))
@@ -2696,7 +2712,9 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p
geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)),
geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)),
);
if let Some(ed) = app.editor.as_mut()
if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) {
d.pick_reference(cell, (wb.active_sheet, &sh.name), true);
} else if let Some(ed) = app.editor.as_mut()
&& ed.point.is_some()
{
if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet).map(|(_, cell)| cell).or(ed.point_cell) {
+160
View File
@@ -0,0 +1,160 @@
//! New Formatting Rule ▸ formula: clicking or dragging on the grid writes the absolute reference
//! into the formula box at its caret, as in Excel, and leaves the selection the rule applies to alone.
use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2};
use gridcraft_engine::Session;
use gridcraft_ui_egui::{SheetApp, grid::Geo};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness() -> Harness {
let mut session = Session::new();
session.new_workbook();
session.execute("home.insertSheet", json!({})).unwrap();
session.execute("sheet.activate", json!({"sheet": 0})).unwrap();
session.execute("selection.set", json!({"range": "F8:G9"})).unwrap();
let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(4);
h.state_mut().open_dialog("cfQuick", json!({"type": "expression", "title": "New Formatting Rule"}));
h.run_steps(6);
h
}
fn formula(h: &Harness) -> String {
h.state().dialog.as_ref().and_then(|d| d.values.get("formula")).and_then(|v| v.as_str()).unwrap_or("").to_string()
}
fn selection(h: &Harness) -> String {
h.state().session.active().unwrap().selection.current().a1()
}
fn type_text(h: &mut Harness, text: &str) {
for c in text.chars() {
h.input_mut().events.push(Event::Text(c.to_string()));
h.run_steps(1);
}
}
fn cell_center(h: &Harness, a1: &str) -> Pos2 {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
let c = gridcraft_engine::core::CellRef::parse(a1).unwrap();
pos2(geo.x(sheet, c.col) + sheet.col_width(c.col) * geo.z * 0.5, geo.y(sheet, c.row) + sheet.row_height(c.row) * geo.z * 0.5)
}
fn click(h: &mut Harness, a1: &str) {
let pos = cell_center(h, a1);
for pressed in [true, false] {
h.input_mut()
.events
.extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]);
h.step();
}
h.run_steps(3);
}
fn drag(h: &mut Harness, from: &str, to: &str) {
let (a, b) = (cell_center(h, from), cell_center(h, to));
h.input_mut()
.events
.extend([Event::PointerMoved(a), Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }]);
h.step();
for i in 1..=6 {
let p = a + (b - a) * (i as f32 / 6.0);
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.step();
h.run_steps(3);
}
#[test]
fn clicking_a_cell_after_equals_inserts_its_absolute_reference() {
let mut h = harness();
type_text(&mut h, "=");
assert_eq!(formula(&h), "=", "the formula box had the keyboard");
click(&mut h, "B2");
assert_eq!(formula(&h), "=$B$2");
assert_eq!(selection(&h), "F8:G9", "the range the rule applies to is still selected");
}
#[test]
fn clicking_a_cell_in_an_empty_box_starts_the_formula() {
let mut h = harness();
click(&mut h, "C3");
assert_eq!(formula(&h), "=$C$3");
assert_eq!(selection(&h), "F8:G9");
}
#[test]
fn a_second_click_replaces_the_reference_just_inserted() {
let mut h = harness();
type_text(&mut h, "=");
click(&mut h, "B2");
click(&mut h, "D5");
assert_eq!(formula(&h), "=$D$5");
}
#[test]
fn dragging_inserts_a_range() {
let mut h = harness();
type_text(&mut h, "=");
drag(&mut h, "B2", "C4");
assert_eq!(formula(&h), "=$B$2:$C$4");
assert_eq!(selection(&h), "F8:G9");
}
#[test]
fn the_reference_goes_at_the_caret_and_typing_continues_after_it() {
let mut h = harness();
type_text(&mut h, "=SUM()");
h.key_press(Key::ArrowLeft);
h.run_steps(2);
click(&mut h, "B2");
assert_eq!(formula(&h), "=SUM($B$2)");
type_text(&mut h, "+1");
assert_eq!(formula(&h), "=SUM($B$2+1)", "the box has the keyboard back, its caret after the reference");
}
#[test]
fn a_click_after_an_operand_changes_nothing() {
let mut h = harness();
type_text(&mut h, "=A1>5");
click(&mut h, "B2");
assert_eq!(formula(&h), "=A1>5");
assert_eq!(selection(&h), "F8:G9");
}
#[test]
fn ok_applies_the_picked_formula_to_the_selection() {
let mut h = harness();
type_text(&mut h, "=");
click(&mut h, "B2");
type_text(&mut h, ">0");
h.key_press(Key::Enter);
h.run_steps(4);
assert!(h.state().dialog.is_none(), "Enter confirmed the rule");
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
let cf = sheet.cond_formats.last().expect("a rule was added");
assert_eq!(cf.ranges.iter().map(|r| r.a1()).collect::<Vec<_>>(), ["F8:G9"]);
assert!(format!("{:?}", cf.rule).contains("$B$2>0"), "rule: {:?}", cf.rule);
}
#[test]
fn other_dialogs_leave_grid_clicks_alone() {
let mut h = harness();
h.state_mut().dialog = None;
h.state_mut().open_dialog("cfQuick", json!({"type": "containsText", "title": "Text that Contains"}));
h.run_steps(6);
click(&mut h, "B2");
assert_eq!(selection(&h), "B2", "a box that is not a formula does not take references");
}
+1
View File
@@ -10,6 +10,7 @@ mod autosave_persist;
mod borders;
mod cell_editor_caret;
mod cell_pictures;
mod cf_rule_pick;
mod chart_resize;
mod clipboard;
mod comments_pane;
+5 -1
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **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
@@ -152,6 +152,9 @@ Seek, Scenario Manager, Data Analysis, Forecast Sheet and others exist. Missing
Solver, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges,
Workbook Links, Accessibility checker. Like Excel's, every dialog opens centred and moves by its
title bar, keeping its place while it is open (#1250).
New Formatting Rule's formula box points like the formula bar: a click on the grid writes the
cell's absolute reference at the caret (`=$B$2`) and a drag a range, leaving the selection the rule
applies to as it is (#2348). Other dialogs' reference fields don't take clicks yet.
## Accessibility
@@ -175,6 +178,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) |
| 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is |
| 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) |
| 2026-10-22 | minor | Show Outline Symbols (`view.showOutlineSymbols`, Ctrl+8): a sheet can hide the outline's level buttons and brackets while keeping its outline, as Excel saves with `showOutlineSymbols="0"` (read and written); the grid then gives the bands no room |