UI: Insert › Controls, form controls on the sheet, Assign Action and Format Control

The Insert tab gains Button, Slider and Spin Button beside Cell
Controls › Checkbox, which is unchanged. Each arms placement: a drag on
the grid draws the control at that size (a tall drag makes a vertical
slider), a click places it at its default size, and Escape disarms. Key
tips (N B U, N S L, N S B) place one at the active cell. A new button
opens Assign Action, as Excel opens Assign Macro.

form_controls.rs draws the controls in the sheet tokens, so they look
the same in both themes: buttons with hover and pressed states, sliders
with a track, a filled part and a thumb (and the value beside the thumb
while it is dragged), spin buttons with two arrows, stacked or side by
side. A plain click works a control: dragging the thumb, clicking the
track (a page), clicking or holding an arrow (it repeats after 0.4 s),
pressing a button (it runs on release over it). A drag or a hold is one
undo step. The control last worked takes the arrows, Page Up and Page
Down, Home and End, and Space or Enter for a button. Ctrl or Cmd+click
or a right-click selects a control to move or resize it; a right-click
opens the object menu, which for a control adds Edit Text and Assign
Action (buttons) and Format Control before Delete.
Double-clicking a selected control opens its editor. On a right-to-left
sheet a control moves as a whole and works where it is drawn.

Assign Action runs a macro (from the list or typed, on the selection or
a fixed range, with an optional output cell) or a command (searched
among those a macro may run, with JSON parameters). Format Control edits
the value, limits, incremental and page change, cell link, orientation,
name and the locked and print flags, checking the same rules as the
engine. A button whose action needs approval, or whose macro would write
over data, asks in a prompt whose buttons press it again with the
answer.

New theme tokens (thumb, hover, pressed, fill, focus ring) are in the
contrast test; Button, Slider and Spin icons are drawn in code; the new
labels are translated in every interface language. The catalog gains
Developer › Controls (280 / 296).
This commit is contained in:
prxnvt committed 2026-10-31 16:11:54 -04:00
1 parent 295c57535c
commit 7042306478
45 files changed
+1839 -32

No files matched your search

+4 -2
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (floating objects: one selection, eight resize handles, keyboard delete; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (form controls: buttons, sliders and spin buttons; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -22,7 +22,7 @@ This page is the summary. The detail is in:
| Number | Value | Kind |
|---|---|---|
| 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 276 / 292 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 280 / 296 (94.6%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) |
| **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) |
| **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) |
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-31 | Form controls: Insert › Controls places buttons, sliders (scroll bars) and spin buttons on the sheet. A slider or spin button holds a whole number from 0 to 30000 in a linked cell (a drag or a held arrow is one undo step, and editing the cell moves the control); a button runs a macro or one command through the macro policy, asking first in a workbook opened from a file; Assign Action and Format Control edit them, and XLSX files read and write them (control properties, `<controls>` and VML), alongside the form controls GridCraft keeps unchanged |
| 2026-10-31 | Floating objects: charts, pictures, shapes and slicers share one selection with a frame of eight resize handles (each moves only its own edges, also on right-to-left sheets); Delete or Backspace deletes the selected object and Escape drops the selection; slicers follow row and column edits and sorts like the other objects; chart series follow cut-moves and sheet renames and deletes; a copied sheet gives its objects fresh ids; `ui.inspect` reports `selectedObject` and `ui.object.select` selects any object (#190, #213) |
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step |
@@ -245,6 +246,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | Form controls; catalog 276 / 292 → 280 / 296 (Developer › Controls); progress log row |
| 2026-10-31 | minor | Progress log: one selection and eight resize handles for every floating object |
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row |
+5
View File
@@ -275,6 +275,11 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"),
("automate.recordActions", "Automate", "Scripting Tools", "Record Actions"),
("automate.trustScripts", "Automate", "Scripting Tools", "Trust Scripts"),
// Developer (Insert › Controls in GridCraft)
("insert.button", "Developer", "Controls", "Button (Form Control)"),
("insert.spinButton", "Developer", "Controls", "Spin Button (Form Control)"),
("insert.slider", "Developer", "Controls", "Scroll Bar (Form Control)"),
("control.format", "Developer", "Controls", "Properties"),
// Table Design
("table.rename", "Table Design", "Properties", "Table Name"),
("table.resize", "Table Design", "Properties", "Resize Table"),
+22 -4
View File
@@ -1,5 +1,5 @@
//! Floating objects on the sheet: charts (rendered by `gridcraft-chart` into primitives that
//! we paint with egui), pictures, shapes and slicers.
//! we paint with egui), pictures, shapes, slicers and form controls (`form_controls.rs`).
use egui::epaint::{PathShape, PathStroke};
use egui::{Align2, Color32, FontId, Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2};
@@ -376,6 +376,18 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
done(start, r);
}
paint_slicers(p, geo, wb, si, sh);
// Form controls sit above every other object.
let hover = p.ctx().input(|i| i.pointer.hover_pos()).map(|h| geo.mirror_pos(h)).filter(|h| geo.in_cells(*h));
for c in &sh.controls {
let r = anchor_rect(geo, sh, &c.anchor);
// A slider's value shows beside it while it is dragged.
if !r.expand(24.0).intersects(p.clip_rect()) {
continue;
}
let start = p.add(egui::Shape::Noop);
crate::form_controls::paint(p, r, c, geo.z, crate::form_controls::look(app, geo, c, r, hover));
done(start, r);
}
// The selected object's frame and handles go over every object, and move with it on a
// right-to-left sheet.
if let Some(r) = app.selected_object.and_then(|o| object_rect(geo, sh, o))
@@ -518,13 +530,19 @@ pub fn selected_handle(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option
crate::objects::handle_at(r, p).map(|h| (o, r, h))
}
/// The topmost object under `p` and its rect: slicers paint on top, so they are hit first, then
/// charts, pictures and shapes, each from the top of its stack. Nothing is hit outside the cell
/// area or while objects are hidden.
/// The topmost object under `p` and its rect: form controls paint on top, so they are hit first,
/// then slicers, charts, pictures and shapes, each from the top of its stack. Nothing is hit
/// outside the cell area or while objects are hidden.
pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Rect)> {
if !geo.cells.contains(p) || app.session.active().is_some_and(|d| objects_hidden(&d.wb)) {
return None;
}
for c in sh.controls.iter().rev() {
let r = anchor_rect(geo, sh, &c.anchor);
if r.contains(p) {
return Some((ObjRef::new(ObjKind::Control, c.id), r));
}
}
for sl in sh.slicers.iter().rev() {
let r = anchor_rect(geo, sh, &sl.anchor);
if r.contains(p) {
+517
View File
@@ -0,0 +1,517 @@
//! Form controls in the window, apart from drawing them: placing a new control (draw it on the
//! grid, or click for the default size), pressing a button and answering its questions, the
//! control's context menu (Edit Text, Assign Action, Format Control, Delete), and the Assign
//! Action and Format Control dialogs. Everything goes through the `insert.*` and `control.*`
//! commands.
use egui::{Pos2, Rect, Stroke, StrokeKind, Ui, vec2};
use gridcraft_engine::core::CellRef;
use gridcraft_engine::macros::policy::{Policy, macro_policy};
use gridcraft_engine::model::{CONTROL_MAX, ControlAction, ControlKind, FormControl, MacroInput, ObjKind, ObjRef, Sheet};
use serde_json::{Value as Json, json};
use crate::SheetApp;
use crate::dialogs::Dialog;
use crate::grid::Geo;
use crate::theme::Tokens;
/// The control `id` on the active sheet.
pub fn control(app: &SheetApp, id: u32) -> Option<FormControl> {
app.session.active()?.wb.active()?.controls.iter().find(|c| c.id == id).cloned()
}
/// Whether the control `id` on the active sheet is a button (its menu adds Edit Text and Assign
/// Action).
pub fn is_button(app: &SheetApp, id: u32) -> bool {
app.session.active().and_then(|d| d.wb.active()).is_some_and(|sh| sh.controls.iter().any(|c| c.id == id && c.kind == ControlKind::Button))
}
fn insert_id(kind: ControlKind) -> &'static str {
match kind {
ControlKind::Button => "insert.button",
ControlKind::Slider => "insert.slider",
ControlKind::Spin => "insert.spinButton",
}
}
/// The cell under a logical grid position and the offset into it, in sheet points.
fn cell_point(geo: &Geo, sh: &Sheet, p: Pos2) -> (CellRef, f32, f32) {
let (col, row) = (geo.col_at(sh, p.x), geo.row_at(sh, p.y));
let dx = ((p.x - geo.x(sh, col)) / geo.z).max(0.0);
let dy = ((p.y - geo.y(sh, row)) / geo.z).max(0.0);
(CellRef::new(row, col), dx, dy)
}
/// Insert › Controls placement: while a kind is armed, a drag on the grid draws the control and a
/// click places it at its default size. Escape disarms. True when it used the pointer.
pub fn place(app: &mut SheetApp, ui: &Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet) -> bool {
let Some(kind) = app.grid.placing else {
app.grid.place_start = None;
return false;
};
let ctx = ui.ctx().clone();
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.grid.placing = None;
app.grid.place_start = None;
return true;
}
let pointer = ctx.input(|i| i.pointer.clone());
// Logical positions (see `Geo::rtl`).
let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p));
if pos.is_some_and(|p| geo.in_cells(p)) {
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
}
if pointer.primary_pressed()
&& resp.contains_pointer()
&& let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos).filter(|p| geo.in_cells(*p))
{
app.grid.place_start = Some(p);
}
let Some(start) = app.grid.place_start else { return resp.contains_pointer() && pointer.any_down() };
let rect = Rect::from_two_pos(start, pos.unwrap_or(start));
if pointer.primary_down() {
let t = Tokens::get(&ctx);
ui.painter().rect_stroke(geo.mirror_rect(rect), 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside);
return true;
}
app.grid.place_start = None;
app.grid.placing = None;
let (cell, dx, dy) = cell_point(geo, sh, rect.min);
let mut params = json!({"at": cell.a1(), "dx": dx, "dy": dy});
// A drag sets the size (and a slider's orientation); a click keeps the default size.
if rect.width() >= 6.0 || rect.height() >= 6.0 {
params["width"] = json!((rect.width() / geo.z).max(8.0));
params["height"] = json!((rect.height() / geo.z).max(8.0));
if kind == ControlKind::Slider {
params["horizontal"] = json!(rect.width() >= rect.height());
}
}
match app.run(insert_id(kind), params) {
Ok(v) => {
if let Some(id) = v["control"].as_u64().and_then(|id| u32::try_from(id).ok()) {
app.selected_object = Some(ObjRef::new(ObjKind::Control, id));
// A new button asks what it should run, as Excel's Assign Macro does.
if kind == ControlKind::Button {
app.open_dialog("assignAction", json!({"id": id}));
}
}
}
Err(e) => app.message = Some(("Insert Control".into(), crate::clean_error(&e))),
}
true
}
/// Opens a control's editor (double-click): a button's text, else Format Control.
pub fn edit(app: &mut SheetApp, id: u32) {
match control(app, id).map(|c| c.kind) {
Some(ControlKind::Button) => app.open_dialog("formatControl", json!({"id": id, "textOnly": true})),
Some(_) => app.open_dialog("formatControl", json!({"id": id})),
None => {}
}
}
// ---------------------------------------------------------------- pressing a button
/// The question in an `approve: …` error, if it is one.
fn approve_text(e: &str) -> Option<String> {
let rest = e.split_once("approve: ")?.1;
Some(rest.split_once(" (run again with").map_or(rest, |(q, _)| q).to_string())
}
/// The question in a `confirm: …` error, if it is one.
fn confirm_text(e: &str) -> Option<String> {
let rest = e.split_once("confirm: ")?.1;
Some(rest.split_once(" (run again with").map_or(rest, |(q, _)| q).to_string())
}
/// Presses a button (`control.press` with `params`). A question (approve the action, or make
/// room for a macro's output) opens a prompt whose buttons press it again with the answer; any
/// other error shows in a message box.
pub fn press(app: &mut SheetApp, params: Json) {
let Err(e) = app.run("control.press", params.clone()) else { return };
let (text, choices) = if let Some(q) = approve_text(&e) {
(q, json!([["Run", {"approve": true}]]))
} else if let Some(q) = confirm_text(&e) {
let what = q.split_once(" Overwrite them").map_or(q.as_str(), |(a, _)| a).trim_end_matches(['.', ' ']).to_string();
(
format!("{what}. Overwrite them, or move the data out of the way?"),
json!([["Overwrite", {"onConflict": "overwrite"}], ["Shift Down", {"onConflict": "shiftDown"}], ["Shift Right", {"onConflict": "shiftRight"}]]),
)
} else {
app.message = Some(("Button".into(), crate::clean_error(&e)));
return;
};
app.open_dialog("controlQuestion", json!({"text": text, "params": params, "choices": choices}));
}
pub fn open_question(params: &Json) -> Option<Dialog> {
let text = params.get("text").and_then(Json::as_str)?;
let v = json!({"text": text, "params": params.get("params").cloned().unwrap_or_else(|| json!({})), "choices": params.get("choices").cloned().unwrap_or_else(|| json!([]))});
Some(Dialog::custom("controlQuestion", "Button", v))
}
/// The prompt [`press`] opens: the question, a button per answer, and Cancel.
pub fn question_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) {
ui.label(val(d, "text"));
ui.add_space(8.0);
let choices: Vec<(String, Json)> = d
.values
.get("choices")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(|c| Some((c.get(0)?.as_str()?.to_string(), c.get(1)?.clone()))).collect())
.unwrap_or_default();
let mut answer = None;
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button(" Cancel ").clicked() {
*open = false;
}
for (label, extra) in choices.iter().rev() {
if ui.button(format!(" {label} ")).clicked() {
answer = Some(extra.clone());
}
}
});
if let Some(extra) = answer {
*open = false;
let mut params = d.values.get("params").cloned().unwrap_or_else(|| json!({}));
if let (Some(p), Some(x)) = (params.as_object_mut(), extra.as_object()) {
for (k, v) in x {
p.insert(k.clone(), v.clone());
}
}
press(app, params);
}
}
// ---------------------------------------------------------------- Assign Action
fn val(d: &Dialog, key: &str) -> String {
d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string()
}
/// The workbook's macros: (name, description).
fn macros(app: &SheetApp) -> Vec<(String, String)> {
app.session.active().map(|d| d.wb.macros.iter().map(|m| (m.name.clone(), m.description.clone())).collect()).unwrap_or_default()
}
/// Assign Action: a macro (on the selection or a fixed range, with an optional output cell) or a
/// command with its parameters.
pub fn open_assign(app: &SheetApp, params: &Json) -> Option<Dialog> {
let id = params.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok())?;
let c = control(app, id)?;
let mut v =
json!({"id": id, "mode": "macro", "macro": "", "input": "selection", "range": "", "output": "", "command": "", "params": "{}", "search": ""});
match &c.action {
Some(ControlAction::Macro { name, input, output }) => {
v["macro"] = json!(name);
if let MacroInput::Fixed(r) = input {
v["input"] = json!("fixed");
v["range"] = json!(r);
}
v["output"] = json!(output.clone().unwrap_or_default());
}
Some(ControlAction::Command { id, params }) => {
v["mode"] = json!("command");
v["command"] = json!(id);
v["params"] = json!(serde_json::to_string_pretty(params).unwrap_or_else(|_| "{}".into()));
}
None => {
// A new button suggests the workbook's first macro.
v["macro"] = json!(macros(app).first().map(|m| m.0.clone()).unwrap_or_default());
}
}
Some(Dialog::custom("assignAction", &format!("Assign Action: {}", c.display_name()), v))
}
/// The `control.assign` params the dialog describes, or why it can't yet.
pub fn assign_params(d: &Dialog) -> Result<Json, String> {
let id = d.values.get("id").cloned().unwrap_or(Json::Null);
if val(d, "mode") == "command" {
let command = val(d, "command");
if command.trim().is_empty() {
return Err("Choose a command.".into());
}
let text = val(d, "params");
let params: Json = if text.trim().is_empty() {
json!({})
} else {
serde_json::from_str(&text).map_err(|e| format!("The parameters aren't valid JSON: {e}"))?
};
if !params.is_object() {
return Err("The parameters must be a JSON object, such as {\"range\": \"A1:B2\"}.".into());
}
return Ok(json!({"id": id, "command": command.trim(), "params": params}));
}
let name = val(d, "macro");
if name.trim().is_empty() {
return Err("Type or choose a macro name.".into());
}
let input = if val(d, "input") == "fixed" { val(d, "range") } else { "selection".into() };
if input.trim().is_empty() {
return Err("Type the range the macro always runs on.".into());
}
Ok(json!({"id": id, "macro": name.trim(), "input": input.trim(), "output": val(d, "output").trim()}))
}
/// Commands a button may run whose id or label contains `search` (every one when it is empty):
/// (id, label). File commands are left out: the ones a button may run only produce bytes.
fn command_choices(search: &str) -> Vec<(&'static str, &'static str)> {
let q = search.trim().to_lowercase();
gridcraft_engine::command_specs()
.iter()
.filter(|c| c.journal && !c.id.starts_with("file."))
.filter(|c| !matches!(macro_policy(c.id, &json!({}), gridcraft_engine::Trust::Local), Policy::Deny(_)))
.filter(|c| q.is_empty() || c.id.to_lowercase().contains(&q) || c.label.to_lowercase().contains(&q))
.map(|c| (c.id, c.label))
.take(200)
.collect()
}
fn text_field(ui: &mut Ui, d: &mut Dialog, key: &str, width: f32) {
let mut s = val(d, key);
ui.add(egui::TextEdit::singleline(&mut s).desired_width(width).id_salt(("control_dialog", key.to_string())));
d.values.insert(key.into(), json!(s));
}
pub fn assign_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) {
let t = Tokens::get(ui.ctx());
let mut mode = val(d, "mode");
ui.horizontal(|ui| {
ui.radio_value(&mut mode, "macro".to_string(), "Run a macro");
ui.radio_value(&mut mode, "command".to_string(), "Run a command");
});
d.values.insert("mode".into(), json!(mode));
ui.add_space(4.0);
if mode == "macro" {
let macros = macros(app);
let mut name = val(d, "macro");
ui.label("Macro name:");
ui.add(egui::TextEdit::singleline(&mut name).desired_width(f32::INFINITY).id_salt("assign_macro"));
egui::Frame::NONE.fill(t.input_bg).stroke(Stroke::new(1.0, t.input_border)).inner_margin(2.0).show(ui, |ui| {
ui.set_min_size(vec2(ui.available_width(), 110.0));
egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| {
if macros.is_empty() {
ui.label(egui::RichText::new("This workbook has no macros yet.").italics().color(t.text_dim));
}
for (n, desc) in &macros {
let r =
ui.add(egui::Button::selectable(n.eq_ignore_ascii_case(name.trim()), n.as_str()).min_size(vec2(ui.available_width(), 20.0)));
let r = if desc.is_empty() { r } else { r.on_hover_text(desc) };
if r.clicked() {
name = n.clone();
}
}
});
});
d.values.insert("macro".into(), json!(name));
ui.add_space(4.0);
let mut input = val(d, "input");
ui.label("Run it on:");
ui.radio_value(&mut input, "selection".to_string(), "The selection when the button is pressed");
ui.horizontal(|ui| {
ui.radio_value(&mut input, "fixed".to_string(), "Always this range:");
if input == "fixed" {
text_field(ui, d, "range", 160.0);
}
});
d.values.insert("input".into(), json!(input));
ui.horizontal(|ui| {
ui.label("Output to (optional):");
text_field(ui, d, "output", 120.0);
});
} else {
let mut search = val(d, "search");
let command = val(d, "command");
ui.label("Command:");
ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search commands").desired_width(f32::INFINITY).id_salt("assign_search"));
egui::Frame::NONE.fill(t.input_bg).stroke(Stroke::new(1.0, t.input_border)).inner_margin(2.0).show(ui, |ui| {
ui.set_min_size(vec2(ui.available_width(), 130.0));
egui::ScrollArea::vertical().max_height(130.0).show(ui, |ui| {
for (cid, label) in command_choices(&search) {
let text = format!("{label} ({cid})");
if ui.add(egui::Button::selectable(cid == command, text).min_size(vec2(ui.available_width(), 20.0))).clicked() {
d.values.insert("command".into(), json!(cid));
}
}
});
});
d.values.insert("search".into(), json!(search));
let doc = gridcraft_engine::find_command(val(d, "command").trim()).map(|c| c.params).unwrap_or("");
if !doc.is_empty() {
ui.label(egui::RichText::new(doc).small().color(t.text_dim));
}
ui.label("Parameters (JSON):");
let mut params = val(d, "params");
ui.add(egui::TextEdit::multiline(&mut params).code_editor().desired_rows(3).desired_width(f32::INFINITY).id_salt("assign_params"));
d.values.insert("params".into(), json!(params));
}
let problem = assign_params(d).err();
if let Some(p) = &problem {
ui.label(egui::RichText::new(p).color(t.danger));
}
let (ok, cancel) = ok_cancel(ui, problem.is_none());
if cancel || (ok && assign(app, d)) {
*open = false;
}
}
/// OK (offered while `enabled`) and Cancel at the bottom right, as the other dialogs place them:
/// (OK clicked, Cancel clicked).
fn ok_cancel(ui: &mut Ui, enabled: bool) -> (bool, bool) {
let (mut ok, mut cancel) = (false, false);
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let t = Tokens::get(ui.ctx());
ok = ui.add_enabled(enabled, egui::Button::new(egui::RichText::new(" OK ").color(t.on_accent)).fill(t.accent)).clicked();
cancel = ui.button(" Cancel ").clicked();
});
(ok, cancel)
}
/// Runs `control.assign` from the dialog. False (with the error shown) when it failed.
fn assign(app: &mut SheetApp, d: &Dialog) -> bool {
let result = assign_params(d).and_then(|params| app.run("control.assign", params).map_err(|e| crate::clean_error(&e)));
match result {
Ok(_) => true,
Err(e) => {
app.message = Some(("Assign Action".into(), e));
false
}
}
}
// ---------------------------------------------------------------- Format Control
/// Format Control: a slider's or spin button's cell link, value and limits (orientation and page
/// change for sliders); a button's text. `textOnly` shows just the text (Edit Text).
pub fn open_format(app: &SheetApp, params: &Json) -> Option<Dialog> {
let id = params.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok())?;
let c = control(app, id)?;
let text_only = params.get("textOnly").and_then(Json::as_bool) == Some(true) && c.kind == ControlKind::Button;
let v = json!({
"id": id,
"textOnly": text_only,
"text": c.text,
"name": c.name,
"value": c.value.to_string(),
"min": c.min.to_string(),
"max": c.max.to_string(),
"step": c.step.to_string(),
"page": c.page.to_string(),
"link": c.link.clone().unwrap_or_default(),
"horizontal": c.horizontal,
"print": c.print,
"locked": c.locked,
});
let title = if text_only { "Edit Text".to_string() } else { format!("Format Control: {}", c.display_name()) };
Some(Dialog::custom("formatControl", &title, v))
}
fn whole(text: &str) -> Option<u32> {
text.trim().parse::<u32>().ok().filter(|v| *v <= CONTROL_MAX)
}
/// The `control.format` params the dialog describes, or the first problem with them (the same
/// rules the engine checks, so OK is only offered when it will work).
pub fn format_params(app: &SheetApp, d: &Dialog) -> Result<Json, String> {
let id = d.values.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok()).ok_or("The control is gone.")?;
let c = control(app, id).ok_or("The control is gone.")?;
let flag = |k: &str| d.values.get(k).and_then(Json::as_bool).unwrap_or(false);
if d.values.get("textOnly").and_then(Json::as_bool) == Some(true) {
return Ok(json!({"id": id, "text": val(d, "text")}));
}
let mut out = json!({"id": id, "name": val(d, "name"), "print": flag("print"), "locked": flag("locked")});
if c.kind == ControlKind::Button {
out["text"] = json!(val(d, "text"));
return Ok(out);
}
let mut draft = c.clone();
for (key, label, slot) in [
("min", "Minimum value", &mut draft.min),
("max", "Maximum value", &mut draft.max),
("step", "Incremental change", &mut draft.step),
("page", "Page change", &mut draft.page),
] {
if key == "page" && c.kind != ControlKind::Slider {
continue;
}
*slot = whole(&val(d, key)).ok_or_else(|| format!("{label} must be a whole number from 0 to {CONTROL_MAX}."))?;
out[key] = json!(*slot);
}
draft.check_numbers()?;
let value = whole(&val(d, "value")).ok_or_else(|| format!("Current value must be a whole number from 0 to {CONTROL_MAX}."))?;
if value < draft.min || value > draft.max {
return Err(format!("Current value must be between {} and {}.", draft.min, draft.max));
}
out["value"] = json!(value);
out["link"] = json!(val(d, "link").trim());
if c.kind == ControlKind::Slider {
out["horizontal"] = json!(flag("horizontal"));
}
Ok(out)
}
pub fn format_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) {
let t = Tokens::get(ui.ctx());
let Some(c) = d.values.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok()).and_then(|id| control(app, id)) else {
*open = false;
return;
};
let text_only = d.values.get("textOnly").and_then(Json::as_bool) == Some(true);
if c.kind == ControlKind::Button {
let mut text = val(d, "text");
ui.label("Text:");
ui.add(egui::TextEdit::multiline(&mut text).desired_rows(2).desired_width(f32::INFINITY).id_salt("control_text"));
d.values.insert("text".into(), json!(text));
} else {
egui::Grid::new("format_control_grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
let mut fields = vec![("value", "Current value:"), ("min", "Minimum value:"), ("max", "Maximum value:"), ("step", "Incremental change:")];
if c.kind == ControlKind::Slider {
fields.push(("page", "Page change:"));
}
fields.push(("link", "Cell link:"));
for (key, label) in fields {
ui.label(label);
text_field(ui, d, key, if key == "link" { 160.0 } else { 90.0 });
ui.end_row();
}
});
if c.kind == ControlKind::Slider {
let mut h = d.values.get("horizontal").and_then(Json::as_bool).unwrap_or(true);
ui.horizontal(|ui| {
ui.label("Orientation:");
ui.radio_value(&mut h, true, "Horizontal");
ui.radio_value(&mut h, false, "Vertical");
});
d.values.insert("horizontal".into(), json!(h));
}
}
if !text_only {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("Name:");
let mut name = val(d, "name");
ui.add(egui::TextEdit::singleline(&mut name).hint_text(c.display_name()).desired_width(200.0).id_salt("control_name"));
d.values.insert("name".into(), json!(name));
});
for (key, label) in [("locked", "Locked (when the sheet is protected)"), ("print", "Print object")] {
let mut on = d.values.get(key).and_then(Json::as_bool).unwrap_or(true);
ui.checkbox(&mut on, label);
d.values.insert(key.into(), json!(on));
}
}
let params = format_params(app, d);
if let Err(p) = &params {
ui.label(egui::RichText::new(p).color(t.danger));
}
let (ok, cancel) = ok_cancel(ui, params.is_ok());
if cancel {
*open = false;
}
if ok && let Ok(p) = params {
match app.run("control.format", p) {
Ok(_) => *open = false,
Err(e) => app.message = Some(("Format Control".into(), crate::clean_error(&e))),
}
}
}
+3
View File
@@ -1017,6 +1017,9 @@ impl Dialog {
dialog
}
"pickList" => Dialog::custom("pickList", "Pick From List", json!({})),
"assignAction" => return crate::control_ui::open_assign(app, &params),
"formatControl" => return crate::control_ui::open_format(app, &params),
"controlQuestion" => return crate::control_ui::open_question(&params),
"saveCopy" => Dialog::custom("saveCopy", "Save a Copy", json!({"format": p("format")})),
"saveChanges" => Dialog::custom("saveChanges", "Save Changes?", json!({"title": p("title"), "quit": p("quit")})),
"start" => Dialog::custom("start", "GridCraft", json!({})),
+4
View File
@@ -143,6 +143,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
"recommendedCharts" => 600.0,
"analysisToolPak" => 420.0,
"solver" => 460.0,
"assignAction" => 440.0,
"zoom" => 220.0,
"pasteSpecial" => 480.0,
_ => 380.0,
@@ -184,6 +185,9 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
"solver" => solver(ui, &mut d, &mut confirm, &mut open),
"solverResults" => solver_results(app, ui, &mut d, &mut open),
"spelling" => spelling(app, ui, &mut d, &mut open),
"assignAction" => crate::control_ui::assign_dialog(app, ui, &mut d, &mut open),
"formatControl" => crate::control_ui::format_dialog(app, ui, &mut d, &mut open),
"controlQuestion" => crate::control_ui::question_dialog(app, ui, &mut d, &mut open),
"agents" => {
ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:");
ui.add_space(4.0);
+443
View File
@@ -0,0 +1,443 @@
//! Form controls on the sheet: drawing buttons, sliders and spin buttons, and working them with
//! the pointer and the keyboard. A click operates a control; Ctrl or Cmd+click, or a right-click,
//! selects it instead (to move, resize or format it). Values change through `control.setValue`,
//! and a whole drag or a held arrow is one undo step.
//!
//! Positions are logical (see `Geo::rtl`): on a right-to-left sheet a control moves as a whole
//! but is not mirrored, so a point inside it is reflected back with [`inner`] first.
use egui::epaint::PathShape;
use egui::{Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2};
use gridcraft_engine::model::{ControlKind, FormControl, ObjKind, ObjRef, Sheet};
use serde_json::json;
use crate::SheetApp;
use crate::grid::Geo;
use crate::theme::{self, Tokens};
/// How long an arrow or the track waits before it repeats, and then between repeats (seconds).
const REPEAT_DELAY: f64 = 0.4;
const REPEAT_EVERY: f64 = 0.06;
/// The part of a control under the pointer.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Part {
Button,
/// A spin button's arrow that adds a step.
Up,
/// A spin button's arrow that takes a step away.
Down,
Thumb,
/// The track on the minimum's side of the thumb (a page down).
TrackBefore,
/// The track on the maximum's side of the thumb (a page up).
TrackAfter,
}
/// A control being worked with the pointer.
#[derive(Clone, Debug, PartialEq)]
pub struct Gesture {
pub id: u32,
pub part: Part,
/// Thumb drags: where along the track the pointer grabbed the thumb, from its centre.
pub grab: f32,
/// The undo stack's length when the gesture began: everything it changes is one step.
pub undo_start: usize,
/// When a held arrow or track repeats next (egui time, seconds).
pub next_repeat: f64,
}
/// What a control looks like this frame.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Look {
pub hover: Option<Part>,
pub pressed: Option<Part>,
pub focused: bool,
/// Show the value next to the thumb (while dragging).
pub show_value: bool,
}
fn fraction(c: &FormControl) -> f32 {
let span = c.max.saturating_sub(c.min).max(1) as f32;
(c.value.saturating_sub(c.min) as f32 / span).clamp(0.0, 1.0)
}
/// A slider's track and thumb in `r`. A horizontal slider has its minimum on the left; a vertical
/// one has it at the top (as Excel's scroll bars do).
pub fn slider_parts(r: Rect, c: &FormControl, z: f32) -> (Rect, Rect) {
let f = fraction(c);
if c.horizontal {
let knob = (r.height() - 4.0 * z).clamp(6.0 * z, 16.0 * z).min(r.width() / 2.0).max(1.0);
let track = Rect::from_center_size(r.center(), vec2((r.width() - knob).max(1.0), (4.0 * z).min(r.height())));
let x = track.left() + f * track.width();
(track, Rect::from_center_size(pos2(x, r.center().y), vec2(knob, (r.height() - 4.0 * z).max(4.0))))
} else {
let knob = (r.width() - 4.0 * z).clamp(6.0 * z, 16.0 * z).min(r.height() / 2.0).max(1.0);
let track = Rect::from_center_size(r.center(), vec2((4.0 * z).min(r.width()), (r.height() - knob).max(1.0)));
let y = track.top() + f * track.height();
(track, Rect::from_center_size(pos2(r.center().x, y), vec2((r.width() - 4.0 * z).max(4.0), knob)))
}
}
/// A spin button's halves: (the arrow that adds, the arrow that subtracts). Tall spin buttons
/// stack them (up on top); wide ones put them side by side (up on the right).
pub fn spin_parts(r: Rect) -> (Rect, Rect) {
if r.height() >= r.width() {
let (top, bottom) = r.split_top_bottom_at_fraction(0.5);
(top, bottom)
} else {
let (left, right) = r.split_left_right_at_fraction(0.5);
(right, left)
}
}
/// A logical point `p` inside control rect `r`, as the control is drawn: on a right-to-left
/// sheet the control moves as a whole without mirroring, so `p` is reflected within `r`.
pub fn inner(geo: &Geo, r: Rect, p: Pos2) -> Pos2 {
if geo.rtl { pos2(r.left() + r.right() - p.x, p.y) } else { p }
}
/// The part of control `c` (drawn in `r`) under `p`.
pub fn part_at(r: Rect, c: &FormControl, z: f32, p: Pos2) -> Option<Part> {
if !r.contains(p) {
return None;
}
Some(match c.kind {
ControlKind::Button => Part::Button,
ControlKind::Spin => {
let (up, _) = spin_parts(r);
if up.contains(p) { Part::Up } else { Part::Down }
}
ControlKind::Slider => {
let (_, thumb) = slider_parts(r, c, z);
if thumb.expand(2.0).contains(p) {
Part::Thumb
} else {
let before = if c.horizontal { p.x < thumb.center().x } else { p.y < thumb.center().y };
if before { Part::TrackBefore } else { Part::TrackAfter }
}
}
})
}
/// The value a thumb dragged to `p` stands for (`grab`: the offset from the thumb's centre where
/// it was grabbed).
pub fn value_at(r: Rect, c: &FormControl, z: f32, p: Pos2, grab: f32) -> f64 {
let (track, _) = slider_parts(r, c, z);
let f = if c.horizontal { (p.x - grab - track.left()) / track.width().max(1.0) } else { (p.y - grab - track.top()) / track.height().max(1.0) };
c.min as f64 + f.clamp(0.0, 1.0) as f64 * c.max.saturating_sub(c.min) as f64
}
/// Paints control `c` in `r`, in the sheet's control colours.
pub fn paint(p: &Painter, r: Rect, c: &FormControl, z: f32, look: Look) {
let t = Tokens::get(p.ctx());
let border = Stroke::new(1.0, t.sheet_control_border);
let bg = |part: Part| {
if look.pressed == Some(part) {
t.sheet_control_pressed
} else if look.hover == Some(part) {
t.sheet_control_hover
} else {
t.sheet_control_bg
}
};
let clip = p.with_clip_rect(r.expand(2.0 * z.max(1.0) + 3.0).intersect(p.clip_rect()));
match c.kind {
ControlKind::Button => {
let fill = bg(Part::Button);
clip.rect_filled(r, 3.0 * z, fill);
clip.rect_stroke(r, 3.0 * z, border, StrokeKind::Inside);
let shift = if look.pressed == Some(Part::Button) { vec2(0.0, 1.0) } else { vec2(0.0, 0.0) };
let g = clip.layout(c.text.clone(), theme::ui_font(12.0 * z), t.sheet_control_text, (r.width() - 8.0 * z).max(1.0));
clip.with_clip_rect(r.shrink(1.0).intersect(clip.clip_rect())).galley(r.center() - g.size() / 2.0 + shift, g, t.sheet_control_text);
}
ControlKind::Slider => {
clip.rect_filled(r, 2.0 * z, t.sheet_control_bg);
clip.rect_stroke(r, 2.0 * z, border, StrokeKind::Inside);
let (track, thumb) = slider_parts(r, c, z);
clip.rect_filled(track, 2.0 * z, t.sheet_track);
let filled = if c.horizontal {
Rect::from_min_max(track.min, pos2(thumb.center().x, track.max.y))
} else {
Rect::from_min_max(track.min, pos2(track.max.x, thumb.center().y))
};
clip.rect_filled(filled, 2.0 * z, t.sheet_fill);
let knob = if look.pressed == Some(Part::Thumb) || look.hover == Some(Part::Thumb) { t.sheet_control_text } else { t.sheet_thumb };
clip.rect_filled(thumb, 3.0 * z, knob);
clip.rect_stroke(thumb, 3.0 * z, Stroke::new(1.0, t.sheet_control_bg), StrokeKind::Inside);
if look.show_value {
let g = clip.layout_no_wrap(c.value.to_string(), theme::ui_font(11.0 * z), t.sheet_control_text);
let at = if c.horizontal {
pos2(thumb.center().x - g.size().x / 2.0, r.top() - g.size().y - 4.0 * z)
} else {
pos2(r.right() + 4.0 * z, thumb.center().y - g.size().y / 2.0)
};
let card = Rect::from_min_size(at, g.size()).expand(2.0 * z);
p.rect_filled(card, 2.0 * z, t.sheet_control_bg);
p.rect_stroke(card, 2.0 * z, border, StrokeKind::Inside);
p.galley(at, g, t.sheet_control_text);
}
}
ControlKind::Spin => {
let (up, down) = spin_parts(r);
let tall = r.height() >= r.width();
for (half, part) in [(up, Part::Up), (down, Part::Down)] {
clip.rect_filled(half, 2.0 * z, bg(part));
clip.rect_stroke(half, 2.0 * z, border, StrokeKind::Inside);
let s = (half.width().min(half.height()) * 0.28).max(1.5);
let m = half.center();
let tri = match (tall, part) {
(true, Part::Up) => vec![pos2(m.x, m.y - s * 0.6), pos2(m.x - s, m.y + s * 0.5), pos2(m.x + s, m.y + s * 0.5)],
(true, _) => vec![pos2(m.x, m.y + s * 0.6), pos2(m.x + s, m.y - s * 0.5), pos2(m.x - s, m.y - s * 0.5)],
(false, Part::Up) => vec![pos2(m.x + s * 0.6, m.y), pos2(m.x - s * 0.5, m.y + s), pos2(m.x - s * 0.5, m.y - s)],
(false, _) => vec![pos2(m.x - s * 0.6, m.y), pos2(m.x + s * 0.5, m.y - s), pos2(m.x + s * 0.5, m.y + s)],
};
clip.add(PathShape::convex_polygon(tri, t.sheet_control_text, Stroke::NONE));
}
}
}
if look.focused {
p.rect_stroke(r.expand(2.0), 3.0 * z, Stroke::new(2.0, t.sheet_focus), StrokeKind::Outside);
}
}
/// The control with `id` on `sh`.
pub fn find(sh: &Sheet, id: u32) -> Option<&FormControl> {
sh.controls.iter().find(|c| c.id == id)
}
/// How control `c` looks this frame, from the gesture, the focus and the pointer (`hover`, a
/// logical position).
pub fn look(app: &SheetApp, geo: &Geo, c: &FormControl, r: Rect, hover: Option<Pos2>) -> Look {
let gesture = app.grid.control.as_ref().filter(|g| g.id == c.id);
let selected = app.selected_object == Some(ObjRef::new(ObjKind::Control, c.id));
let hover = if selected { None } else { hover.and_then(|p| part_at(r, c, geo.z, inner(geo, r, p))) };
let pressed = gesture.map(|g| g.part).filter(|part| *part == Part::Thumb || hover == Some(*part));
Look { hover, pressed, focused: app.grid.focused_control == Some(c.id) && !selected, show_value: gesture.is_some_and(|g| g.part == Part::Thumb) }
}
impl SheetApp {
/// Sets a slider or spin button's value; inside a gesture, keeps it one undo step.
fn control_set(&mut self, id: u32, value: f64) -> bool {
match self.session.run("control.setValue", json!({"id": id, "value": value})) {
Ok(_) => {
if let Some(start) = self.grid.control.as_ref().map(|g| g.undo_start)
&& let Some(d) = self.session.active_mut()
&& d.undo.len() > start + 1
{
d.undo.truncate(start + 1);
}
true
}
Err(e) => {
self.grid.control = None;
self.message = Some(("Form Control".into(), crate::clean_error(&e)));
false
}
}
}
/// Moves a slider or spin button by `steps` steps (negative: toward the minimum), or by pages
/// when `page` is set.
pub fn control_nudge(&mut self, id: u32, steps: i64, page: bool) -> bool {
let Some(c) = self.session.active().and_then(|d| d.wb.active()).and_then(|sh| find(sh, id)).cloned() else { return false };
if c.kind == ControlKind::Button {
return false;
}
let by = if page && c.kind == ControlKind::Slider { c.page } else { c.step } as f64;
self.control_set(id, c.value as f64 + steps as f64 * by)
}
/// Runs a button's action. Questions (approve it, make room for a macro's output) become a
/// prompt.
pub fn press_button(&mut self, id: u32) {
crate::control_ui::press(self, json!({"id": id}));
}
}
/// Pointer handling for controls on the grid. Returns true when it used the pointer this frame
/// (so the grid neither selects cells nor objects).
pub fn interact(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) -> bool {
let (pointer, mods, time) = ctx.input(|i| (i.pointer.clone(), i.modifiers, i.time));
let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p));
// A control that no longer exists loses its gesture and focus.
if let Some(id) = app.grid.focused_control
&& find(sh, id).is_none()
{
app.grid.focused_control = None;
}
if let Some(g) = app.grid.control.clone() {
let Some(c) = find(sh, g.id).cloned() else {
app.grid.control = None;
return true;
};
let r = crate::chartview::anchor_rect(geo, sh, &c.anchor);
if pointer.primary_down() {
match g.part {
Part::Thumb => {
if let Some(p) = pos {
let v = c.snap(value_at(r, &c, geo.z, inner(geo, r, p), g.grab));
if v != c.value {
app.control_set(c.id, v as f64);
}
}
}
Part::Button => {}
part => {
// Held arrows and track repeat while the pointer stays on them.
let over = pos.and_then(|p| part_at(r, &c, geo.z, inner(geo, r, p))) == Some(part);
if over && time >= g.next_repeat {
step_part(app, &c, part);
if let Some(gg) = app.grid.control.as_mut() {
gg.next_repeat = time + REPEAT_EVERY;
}
}
ctx.request_repaint_after(std::time::Duration::from_millis(30));
}
}
return true;
}
// Released: a button runs when the pointer is still on it.
app.grid.control = None;
if g.part == Part::Button && pos.is_some_and(|p| r.contains(p)) {
app.press_button(c.id);
}
resp.request_focus();
return true;
}
if !pointer.primary_pressed() || !resp.contains_pointer() {
// Any other press on the grid moves the focus off the control.
if pointer.any_pressed() && app.grid.focused_control.is_some() && !resp.contains_pointer() {
app.grid.focused_control = None;
}
return false;
}
let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return false };
// The selected object's handles reach past its frame and win over a control beneath them.
if let Some(r) = app.selected_object.and_then(|o| crate::chartview::object_rect(geo, sh, o))
&& crate::objects::handle_at(r, p).is_some()
{
return false;
}
let hit = crate::chartview::hit(app, geo, sh, p).filter(|(o, _)| o.kind == ObjKind::Control);
// Ctrl or Cmd+click selects; a selected control moves and resizes instead of working.
let Some((o, r)) = hit.filter(|(o, _)| !mods.command && !mods.ctrl && app.selected_object != Some(*o)) else {
app.grid.focused_control = None;
return false;
};
let Some(c) = find(sh, o.id).cloned() else { return false };
let p = inner(geo, r, p);
let Some(part) = part_at(r, &c, geo.z, p) else { return false };
app.selected_object = None;
app.grid.focused_control = Some(c.id);
let undo_start = app.session.active().map_or(0, |d| d.undo.len());
let grab = if part == Part::Thumb {
let (_, thumb) = slider_parts(r, &c, geo.z);
if c.horizontal { p.x - thumb.center().x } else { p.y - thumb.center().y }
} else {
0.0
};
app.grid.control = Some(Gesture { id: c.id, part, grab, undo_start, next_repeat: time + REPEAT_DELAY });
if !matches!(part, Part::Button | Part::Thumb) {
step_part(app, &c, part);
}
resp.request_focus();
true
}
/// One click on an arrow or the track.
fn step_part(app: &mut SheetApp, c: &FormControl, part: Part) {
match part {
Part::Up => app.control_nudge(c.id, 1, false),
Part::Down => app.control_nudge(c.id, -1, false),
Part::TrackBefore => app.control_nudge(c.id, -1, true),
Part::TrackAfter => app.control_nudge(c.id, 1, true),
Part::Button | Part::Thumb => false,
};
}
/// Keys for the focused control: arrows move by a step, Page Up and Page Down by a page, Home and
/// End to the ends; Space or Enter presses a button. True when the key was used.
pub fn key(app: &mut SheetApp, sh: &Sheet, key: egui::Key) -> bool {
use egui::Key;
let Some(c) = app.grid.focused_control.and_then(|id| find(sh, id)).cloned() else { return false };
if app.selected_object.is_some() {
return false;
}
match (c.kind, key) {
(_, Key::Escape) => {
app.grid.focused_control = None;
true
}
(ControlKind::Button, Key::Space | Key::Enter) => {
app.press_button(c.id);
true
}
(ControlKind::Button, _) => false,
// A spin button's up arrow adds; a slider's minimum is at its left or top.
(ControlKind::Spin, Key::ArrowUp | Key::ArrowRight) | (ControlKind::Slider, Key::ArrowDown | Key::ArrowRight) => {
app.control_nudge(c.id, 1, false)
}
(ControlKind::Spin, Key::ArrowDown | Key::ArrowLeft) | (ControlKind::Slider, Key::ArrowUp | Key::ArrowLeft) => {
app.control_nudge(c.id, -1, false)
}
(ControlKind::Spin, Key::PageUp) | (ControlKind::Slider, Key::PageDown) => app.control_nudge(c.id, 1, true),
(ControlKind::Spin, Key::PageDown) | (ControlKind::Slider, Key::PageUp) => app.control_nudge(c.id, -1, true),
(_, Key::Home) => app.control_set(c.id, c.min as f64),
(_, Key::End) => app.control_set(c.id, c.max as f64),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn slider(value: u32, horizontal: bool) -> FormControl {
FormControl { kind: ControlKind::Slider, min: 0, max: 100, value, horizontal, ..Default::default() }
}
#[test]
fn the_thumb_sits_at_the_value_and_dragging_reads_it_back() {
let r = Rect::from_min_size(pos2(10.0, 10.0), vec2(200.0, 20.0));
for v in [0, 25, 50, 100] {
let c = slider(v, true);
let (_, thumb) = slider_parts(r, &c, 1.0);
assert!((value_at(r, &c, 1.0, thumb.center(), 0.0) - v as f64).abs() < 0.5, "{v}");
assert_eq!(part_at(r, &c, 1.0, thumb.center()), Some(Part::Thumb));
}
let c = slider(50, true);
assert_eq!(part_at(r, &c, 1.0, pos2(15.0, 20.0)), Some(Part::TrackBefore));
assert_eq!(part_at(r, &c, 1.0, pos2(205.0, 20.0)), Some(Part::TrackAfter));
assert_eq!(part_at(r, &c, 1.0, pos2(5.0, 20.0)), None);
// Past the ends clamps.
assert_eq!(value_at(r, &c, 1.0, pos2(-500.0, 20.0), 0.0), 0.0);
assert_eq!(value_at(r, &c, 1.0, pos2(900.0, 20.0), 0.0), 100.0);
// Vertical: the minimum is at the top.
let r = Rect::from_min_size(pos2(0.0, 0.0), vec2(20.0, 200.0));
let c = slider(0, false);
let (_, thumb) = slider_parts(r, &c, 1.0);
assert!(thumb.center().y < 20.0);
assert_eq!(part_at(r, &slider(50, false), 1.0, pos2(10.0, 190.0)), Some(Part::TrackAfter));
}
#[test]
fn spin_halves() {
let tall = Rect::from_min_size(pos2(0.0, 0.0), vec2(18.0, 36.0));
let c = FormControl { kind: ControlKind::Spin, ..Default::default() };
assert_eq!(part_at(tall, &c, 1.0, pos2(9.0, 5.0)), Some(Part::Up));
assert_eq!(part_at(tall, &c, 1.0, pos2(9.0, 30.0)), Some(Part::Down));
let wide = Rect::from_min_size(pos2(0.0, 0.0), vec2(40.0, 18.0));
assert_eq!(part_at(wide, &c, 1.0, pos2(35.0, 9.0)), Some(Part::Up));
assert_eq!(part_at(wide, &c, 1.0, pos2(5.0, 9.0)), Some(Part::Down));
}
#[test]
fn degenerate_ranges_do_not_divide_by_zero() {
let r = Rect::from_min_size(pos2(0.0, 0.0), vec2(1.0, 1.0));
let c = FormControl { kind: ControlKind::Slider, min: 5, max: 5, value: 5, ..Default::default() };
let (track, thumb) = slider_parts(r, &c, 1.0);
assert!(track.width().is_finite() && thumb.center().x.is_finite());
assert!(value_at(r, &c, 1.0, pos2(0.5, 0.5), 0.0).is_finite());
}
}
+8
View File
@@ -240,6 +240,14 @@ pub struct GridState {
pub hf_start: bool,
/// The grid geometry of the last frame (hit-testing for agents and tests).
pub geo: Option<Geo>,
/// A form control being worked with the pointer.
pub control: Option<crate::form_controls::Gesture>,
/// The form control that takes the arrow keys (the one last worked).
pub focused_control: Option<u32>,
/// Insert › Controls: the kind a click or drag on the grid places, and where the drag began
/// (logical position).
pub placing: Option<gridcraft_engine::model::ControlKind>,
pub place_start: Option<Pos2>,
/// The horizontal scrollbar's track in the sheet tab bar (hit-testing for tests).
pub hscroll: Option<Rect>,
/// The handle between the sheet tabs and the horizontal scrollbar (hit-testing for tests).
+12 -3
View File
@@ -1,3 +1,5 @@
use gridcraft_engine::model::ObjKind;
use super::geo::Geo;
use super::interact::{col_edge, freeze_handles, hidden_col_edge, hidden_row_edge, on_border, row_edge};
use super::{CellRef, Color32, CursorIcon, Drag, Pos2, RangeRef, Rect, Sheet, SheetApp, Stroke, pos2, vec2};
@@ -21,8 +23,9 @@ pub(super) fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Resp
{
app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p));
}
// Draw tools own the cursor over cells, including while recording a stroke.
if !app.session.draw_tool.is_empty() && in_cells {
// Draw tools and Insert › Controls placement own the cursor over cells, including while
// recording a stroke or drawing the control.
if (!app.session.draw_tool.is_empty() || app.grid.placing.is_some()) && in_cells {
ctx.set_cursor_icon(CursorIcon::Crosshair);
return;
}
@@ -79,9 +82,15 @@ fn hover_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, poin
// The selected object's handles resize it; any object's body moves it.
let idle = app.grid.drag == Drag::None && app.editor.is_none();
let object_handle = if idle { crate::chartview::selected_handle(app, geo, sh, p).map(|(.., h)| h) } else { None };
let on_object = idle && crate::chartview::hit(app, geo, sh, p).is_some();
let hit = if idle { crate::chartview::hit(app, geo, sh, p) } else { None };
// A form control works on click unless it is selected (or Ctrl or Cmd is held).
let mods = ctx.input(|i| i.modifiers);
let works = hit.is_some_and(|(o, _)| o.kind == ObjKind::Control && app.selected_object != Some(o) && !mods.command && !mods.ctrl);
let on_object = hit.is_some();
if let Some(h) = object_handle {
ctx.set_cursor_icon(h.cursor(geo.rtl));
} else if works {
ctx.set_cursor_icon(CursorIcon::PointingHand);
} else if on_object {
ctx.set_cursor_icon(CursorIcon::Move);
} else if near_col_edge || (in_row_header && row_edge(geo, sh, p.y).is_some()) {
+15 -2
View File
@@ -64,6 +64,15 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left());
let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top());
let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && p.x >= geo.head_left() && p.y >= geo.head_top());
// Insert › Controls places a control; a click works a form control (Ctrl or Cmd+click, or a
// right-click, selects it instead).
if app.editor.is_none() && crate::control_ui::place(app, ui, resp, geo, sh) {
return;
}
if app.editor.is_none() && app.session.draw_tool.is_empty() && crate::form_controls::interact(app, &ctx, resp, geo, sh) {
keyboard(app, &ctx, resp, geo, sh);
return;
}
let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default();
let cur_rect = geo.range_rect(sh, sel.current());
let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0));
@@ -146,14 +155,18 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
}
app.selected_object = None;
}
// A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's
// under it; a right press that wobbles into a drag counts too (#2067).
// A right-click on a chart, picture, shape, slicer or form control opens the object's menu, not
// the cell's under it; a right press that wobbles into a drag counts too (#2067). On a form
// control it selects the control without running it.
let secondary_drag = resp.drag_started_by(egui::PointerButton::Secondary);
if (resp.secondary_clicked() || secondary_drag)
&& let Some(p) = if secondary_drag { pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) } else { pos }
&& let Some((obj, _)) = crate::chartview::hit(app, geo, sh, p)
{
app.selected_object = Some(obj);
if obj.kind == ObjKind::Control {
app.grid.focused_control = None;
}
app.grid.object_menu = Some((geo.mirror_pos(p), obj.kind.name(), obj.id));
return;
}
+18 -1
View File
@@ -32,7 +32,7 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
ctx.memory_mut(|m| {
m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })
});
let events = ctx.input(|i| i.events.clone());
let events = grid_events(app, ctx, sh);
// A shortcut key that also types a character (Shift+Space sends Text(" ")) must not start an edit.
let shortcut_pressed = events.iter().any(|e| {
matches!(e, egui::Event::Key { pressed: true, modifiers: m, key, .. }
@@ -157,3 +157,20 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
}
}
}
/// This frame's input events, without those the focused form control took.
fn grid_events(app: &mut SheetApp, ctx: &egui::Context, sh: &Sheet) -> Vec<egui::Event> {
let events = ctx.input(|i| i.events.clone());
events.into_iter().filter(|ev| !form_control_event(app, sh, ev)).collect()
}
/// Whether the focused form control takes `ev`: arrows, Page Up and Down, Home, End, Space and
/// Enter without Ctrl, Cmd or Alt, and the space a Space press types (it presses a focused button
/// and doesn't start typing in a cell).
fn form_control_event(app: &mut SheetApp, sh: &Sheet, ev: &egui::Event) -> bool {
match ev {
egui::Event::Text(text) => text == " " && app.grid.focused_control.is_some() && app.selected_object.is_none(),
egui::Event::Key { key, pressed: true, modifiers: m, .. } => !m.command && !m.ctrl && !m.alt && crate::form_controls::key(app, sh, *key),
_ => false,
}
}
+13 -2
View File
@@ -369,14 +369,22 @@ pub(super) fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
}
}
/// The right-click menu of a chart, picture, shape or slicer.
/// The right-click menu of a chart, picture, shape, slicer or form control (Edit Text and Assign
/// Action for a button, and Format Control, before Delete).
pub(super) fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
let Some((at, kind, id)) = app.grid.object_menu else { return };
let mut close = false;
let resp = egui::Area::new(egui::Id::unique("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(200.0);
let mut items = vec![("Delete", "object.delete")];
let mut items = vec![];
if kind == "control" {
if crate::control_ui::is_button(app, id) {
items.extend([("Edit Text", "ui:editText"), ("Assign Action…", "ui:assignAction")]);
}
items.extend([("Format Control…", "ui:formatControl"), ("-", "")]);
}
items.push(("Delete", "object.delete"));
if kind == "chart" {
items.extend([("-", ""), ("Select Data…", "ui:selectData"), ("Format Chart Area…", "ui:formatChart")]);
}
@@ -391,6 +399,9 @@ pub(super) fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
match action {
"ui:selectData" => app.open_dialog("selectData", json!({"chart": id})),
"ui:formatChart" => app.grid.pane = Some("formatChart".into()),
"ui:editText" => app.open_dialog("formatControl", json!({"id": id, "textOnly": true})),
"ui:assignAction" => app.open_dialog("assignAction", json!({"id": id})),
"ui:formatControl" => app.open_dialog("formatControl", json!({"id": id})),
_ => {
app.run_or_alert("object.delete", json!({"kind": kind, "id": id}));
app.selected_object = None;
+9
View File
@@ -242,6 +242,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Convert to Comments" => "Převést na komentáře",
"Checkbox" => "Zaškrtávací políčko",
"Insert checkboxes in the selected cells" => "Vložit zaškrtávací políčka do vybraných buněk",
"Button" => "Tlačítko",
"Slider" => "Posuvník",
"Spin Button" => "Číselník",
"Draw a button that runs a macro or a command" => "Nakreslit tlačítko, které spustí makro nebo příkaz",
"Draw a slider that sets a whole number in a linked cell" => "Nakreslit posuvník, který nastaví celé číslo v propojené buňce",
"Draw a spin button that steps a whole number in a linked cell" => "Nakreslit číselník, který po krocích mění celé číslo v propojené buňce",
"Edit Text" => "Upravit text",
"Assign Action…" => "Přiřadit akci…",
"Format Control…" => "Formát ovládacího prvku…",
"Sheet\nRight-to-Left" => "List zprava\ndoleva",
"Sheet Right-to-Left" => "List zprava doleva",
"Data\nAnalysis" => "Analýza\ndat",
+13
View File
@@ -246,6 +246,19 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Convert to Comments" => "In Kommentare konvertieren",
"Checkbox" => "Kontrollkästchen",
"Insert checkboxes in the selected cells" => "Kontrollkästchen in die ausgewählten Zellen einfügen",
"Button" => "Schaltfläche",
"Slider" => "Schieberegler",
"Spin Button" => "Drehfeld",
"Draw a button that runs a macro or a command" => "Eine Schaltfläche zeichnen, die ein Makro oder einen Befehl ausführt",
"Draw a slider that sets a whole number in a linked cell" => {
"Einen Schieberegler zeichnen, der eine ganze Zahl in einer verknüpften Zelle festlegt"
}
"Draw a spin button that steps a whole number in a linked cell" => {
"Ein Drehfeld zeichnen, das eine ganze Zahl in einer verknüpften Zelle schrittweise ändert"
}
"Edit Text" => "Text bearbeiten",
"Assign Action…" => "Aktion zuweisen…",
"Format Control…" => "Steuerelement formatieren…",
"Sheet\nRight-to-Left" => "Blatt von rechts\nnach links",
"Sheet Right-to-Left" => "Blatt von rechts nach links",
"Data\nAnalysis" => "Daten-\nanalyse",
+11
View File
@@ -235,6 +235,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Convert to Comments" => "Μετατροπή σε σχόλια",
"Checkbox" => "Πλαίσιο ελέγχου",
"Insert checkboxes in the selected cells" => "Εισαγωγή πλαισίων ελέγχου στα επιλεγμένα κελιά",
"Button" => "Κουμπί",
"Slider" => "Ρυθμιστικό",
"Spin Button" => "Κουμπί περιστροφής",
"Draw a button that runs a macro or a command" => "Σχεδίαση κουμπιού που εκτελεί μια μακροεντολή ή μια εντολή",
"Draw a slider that sets a whole number in a linked cell" => "Σχεδίαση ρυθμιστικού που ορίζει έναν ακέραιο αριθμό σε ένα συνδεδεμένο κελί",
"Draw a spin button that steps a whole number in a linked cell" => {
"Σχεδίαση κουμπιού περιστροφής που αλλάζει κατά βήματα έναν ακέραιο αριθμό σε ένα συνδεδεμένο κελί"
}
"Edit Text" => "Επεξεργασία κειμένου",
"Assign Action…" => "Αντιστοίχιση ενέργειας…",
"Format Control…" => "Μορφοποίηση στοιχείου ελέγχου…",
"Sheet\nRight-to-Left" => "Φύλλο από\nδεξιά προς αριστερά",
"Sheet Right-to-Left" => "Φύλλο από δεξιά προς αριστερά",
"Data\nAnalysis" => "Ανάλυση\nδεδομένων",
+13
View File
@@ -245,6 +245,19 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Convert to Comments" => "Convertir en comentarios",
"Checkbox" => "Casilla",
"Insert checkboxes in the selected cells" => "Insertar casillas en las celdas seleccionadas",
"Button" => "Botón",
"Slider" => "Control deslizante",
"Spin Button" => "Control de número",
"Draw a button that runs a macro or a command" => "Dibujar un botón que ejecuta una macro o un comando",
"Draw a slider that sets a whole number in a linked cell" => {
"Dibujar un control deslizante que establece un número entero en una celda vinculada"
}
"Draw a spin button that steps a whole number in a linked cell" => {
"Dibujar un control de número que cambia un número entero en una celda vinculada"
}
"Edit Text" => "Modificar texto",
"Assign Action…" => "Asignar acción…",
"Format Control…" => "Formato de control…",
"Sheet\nRight-to-Left" => "Hoja de derecha\na izquierda",
"Sheet Right-to-Left" => "Hoja de derecha a izquierda",
"Data\nAnalysis" => "Análisis\nde datos",
+11
View File
@@ -118,6 +118,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Courbes de tendance",
"Checkbox" => "Case à cocher",
"Insert checkboxes in the selected cells" => "Insérer des cases à cocher dans les cellules sélectionnées",
"Button" => "Bouton",
"Slider" => "Curseur",
"Spin Button" => "Toupie",
"Draw a button that runs a macro or a command" => "Dessiner un bouton qui exécute une macro ou une commande",
"Draw a slider that sets a whole number in a linked cell" => "Dessiner un curseur qui définit un nombre entier dans une cellule liée",
"Draw a spin button that steps a whole number in a linked cell" => {
"Dessiner une toupie qui modifie par pas un nombre entier dans une cellule liée"
}
"Edit Text" => "Modifier le texte",
"Assign Action…" => "Affecter une action…",
"Format Control…" => "Format de contrôle…",
"Header & Footer" => "En-tête et pied de page",
"Draw with ink" => "Dessiner à l'encre",
"Eraser" => "Gomme",
+13
View File
@@ -111,6 +111,19 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Sparkline",
"Checkbox" => "Casella di controllo",
"Insert checkboxes in the selected cells" => "Inserisci caselle di controllo nelle celle selezionate",
"Button" => "Pulsante",
"Slider" => "Dispositivo di scorrimento",
"Spin Button" => "Pulsante di selezione",
"Draw a button that runs a macro or a command" => "Disegna un pulsante che esegue una macro o un comando",
"Draw a slider that sets a whole number in a linked cell" => {
"Disegna un dispositivo di scorrimento che imposta un numero intero in una cella collegata"
}
"Draw a spin button that steps a whole number in a linked cell" => {
"Disegna un pulsante di selezione che modifica a passi un numero intero in una cella collegata"
}
"Edit Text" => "Modifica testo",
"Assign Action…" => "Assegna azione…",
"Format Control…" => "Formato controllo…",
"Header & Footer" => "Intestazione e piè di pagina",
"Draw with ink" => "Disegna a mano libera",
"Eraser" => "Gomma",
+9
View File
@@ -115,6 +115,15 @@ pub(super) const CORE: &[(&str, &str)] = &[
("Sparklines", "スパークライン"),
("Checkbox", "チェックボックス"),
("Insert checkboxes in the selected cells", "選択したセルにチェックボックスを挿入"),
("Button", "ボタン"),
("Slider", "スライダー"),
("Spin Button", "スピン ボタン"),
("Draw a button that runs a macro or a command", "マクロまたはコマンドを実行するボタンを描画"),
("Draw a slider that sets a whole number in a linked cell", "リンクされたセルに整数を設定するスライダーを描画"),
("Draw a spin button that steps a whole number in a linked cell", "リンクされたセルの整数を増減するスピン ボタンを描画"),
("Edit Text", "テキストの編集"),
("Assign Action…", "動作の登録…"),
("Format Control…", "コントロールの書式設定…"),
("Header & Footer", "ヘッダーとフッター"),
("Draw with ink", "インクで描画"),
("Eraser", "消しゴム"),
+9
View File
@@ -113,6 +113,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "스파크라인",
"Checkbox" => "확인란",
"Insert checkboxes in the selected cells" => "선택한 셀에 확인란 삽입",
"Button" => "단추",
"Slider" => "슬라이더",
"Spin Button" => "스핀 단추",
"Draw a button that runs a macro or a command" => "매크로나 명령을 실행하는 단추 그리기",
"Draw a slider that sets a whole number in a linked cell" => "연결된 셀에 정수를 설정하는 슬라이더 그리기",
"Draw a spin button that steps a whole number in a linked cell" => "연결된 셀의 정수를 증감하는 스핀 단추 그리기",
"Edit Text" => "텍스트 편집",
"Assign Action…" => "동작 지정…",
"Format Control…" => "컨트롤 서식…",
"Header & Footer" => "머리글 및 바닥글",
"Draw with ink" => "잉크로 그리기",
"Eraser" => "지우개",
+11
View File
@@ -112,6 +112,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Sparklines",
"Checkbox" => "Checkbox",
"Insert checkboxes in the selected cells" => "Checkboxes invoegen in de geselecteerde cellen",
"Button" => "Knop",
"Slider" => "Schuifregelaar",
"Spin Button" => "Kringveld",
"Draw a button that runs a macro or a command" => "Een knop tekenen die een macro of een opdracht uitvoert",
"Draw a slider that sets a whole number in a linked cell" => "Een schuifregelaar tekenen die een geheel getal in een gekoppelde cel instelt",
"Draw a spin button that steps a whole number in a linked cell" => {
"Een kringveld tekenen dat een geheel getal in een gekoppelde cel stapsgewijs wijzigt"
}
"Edit Text" => "Tekst bewerken",
"Assign Action…" => "Actie toewijzen…",
"Format Control…" => "Besturingselement opmaken…",
"Header & Footer" => "Koptekst & voettekst",
"Draw with ink" => "Tekenen met inkt",
"Eraser" => "Gum",
+11
View File
@@ -107,6 +107,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Wykresy przebiegu w czasie",
"Checkbox" => "Pole wyboru",
"Insert checkboxes in the selected cells" => "Wstaw pola wyboru w zaznaczonych komórkach",
"Button" => "Przycisk",
"Slider" => "Suwak",
"Spin Button" => "Pokrętło",
"Draw a button that runs a macro or a command" => "Narysuj przycisk, który uruchamia makro lub polecenie",
"Draw a slider that sets a whole number in a linked cell" => "Narysuj suwak, który ustawia liczbę całkowitą w połączonej komórce",
"Draw a spin button that steps a whole number in a linked cell" => {
"Narysuj pokrętło, które zmienia o krok liczbę całkowitą w połączonej komórce"
}
"Edit Text" => "Edytuj tekst",
"Assign Action…" => "Przypisz akcję…",
"Format Control…" => "Formatuj formant…",
"Header & Footer" => "Nagłówek i stopka",
"Draw with ink" => "Rysuj piórem",
"Eraser" => "Gumka",
+13
View File
@@ -110,6 +110,19 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Minigráficos",
"Checkbox" => "Caixa de Seleção",
"Insert checkboxes in the selected cells" => "Inserir caixas de seleção nas células selecionadas",
"Button" => "Botão",
"Slider" => "Controle Deslizante",
"Spin Button" => "Botão de Rotação",
"Draw a button that runs a macro or a command" => "Desenhar um botão que executa uma macro ou um comando",
"Draw a slider that sets a whole number in a linked cell" => {
"Desenhar um controle deslizante que define um número inteiro em uma célula vinculada"
}
"Draw a spin button that steps a whole number in a linked cell" => {
"Desenhar um botão de rotação que altera um número inteiro em uma célula vinculada"
}
"Edit Text" => "Editar Texto",
"Assign Action…" => "Atribuir Ação…",
"Format Control…" => "Formatar Controle…",
"Header & Footer" => "Cabeçalho e Rodapé",
"Draw with ink" => "Desenhar com Tinta",
"Eraser" => "Borracha",
+11
View File
@@ -114,6 +114,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Diagrame sparkline",
"Checkbox" => "Casetă de selectare",
"Insert checkboxes in the selected cells" => "Inserați casete de selectare în celulele selectate",
"Button" => "Buton",
"Slider" => "Glisor",
"Spin Button" => "Buton rotativ",
"Draw a button that runs a macro or a command" => "Desenați un buton care rulează o macrocomandă sau o comandă",
"Draw a slider that sets a whole number in a linked cell" => "Desenați un glisor care setează un număr întreg într-o celulă legată",
"Draw a spin button that steps a whole number in a linked cell" => {
"Desenați un buton rotativ care modifică pas cu pas un număr întreg într-o celulă legată"
}
"Edit Text" => "Editare text",
"Assign Action…" => "Atribuire acțiune…",
"Format Control…" => "Formatare control…",
"Header & Footer" => "Antet și subsol",
"Draw with ink" => "Desenare cu cerneală",
"Eraser" => "Radieră",
+11
View File
@@ -113,6 +113,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Спарклайны",
"Checkbox" => "Флажок",
"Insert checkboxes in the selected cells" => "Вставить флажки в выбранные ячейки",
"Button" => "Кнопка",
"Slider" => "Ползунок",
"Spin Button" => "Счётчик",
"Draw a button that runs a macro or a command" => "Нарисовать кнопку, которая запускает макрос или команду",
"Draw a slider that sets a whole number in a linked cell" => "Нарисовать ползунок, который задаёт целое число в связанной ячейке",
"Draw a spin button that steps a whole number in a linked cell" => {
"Нарисовать счётчик, который изменяет целое число в связанной ячейке с шагом"
}
"Edit Text" => "Изменить текст",
"Assign Action…" => "Назначить действие…",
"Format Control…" => "Формат элемента управления…",
"Header & Footer" => "Верхний и нижний колонтитулы",
"Draw with ink" => "Рисование от руки",
"Eraser" => "Ластик",
+11
View File
@@ -114,6 +114,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Mini grafikoni",
"Checkbox" => "Polje za potvrdu",
"Insert checkboxes in the selected cells" => "Umetni polja za potvrdu u izabrane ćelije",
"Button" => "Dugme",
"Slider" => "Klizač",
"Spin Button" => "Dugme za okretanje",
"Draw a button that runs a macro or a command" => "Nacrtaj dugme koje pokreće makro ili komandu",
"Draw a slider that sets a whole number in a linked cell" => "Nacrtaj klizač koji postavlja ceo broj u povezanoj ćeliji",
"Draw a spin button that steps a whole number in a linked cell" => {
"Nacrtaj dugme za okretanje koje menja ceo broj u povezanoj ćeliji po koracima"
}
"Edit Text" => "Uredi tekst",
"Assign Action…" => "Dodeli radnju…",
"Format Control…" => "Oblikuj kontrolu…",
"Header & Footer" => "Zaglavlje i podnožje",
"Draw with ink" => "Crtaj mastilom",
"Eraser" => "Gumica",
+11
View File
@@ -241,6 +241,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Convert to Comments" => "Açıklamalara Dönüştür",
"Checkbox" => "Onay Kutusu",
"Insert checkboxes in the selected cells" => "Seçili hücrelere onay kutuları ekle",
"Button" => "Düğme",
"Slider" => "Kaydırıcı",
"Spin Button" => "Döndürme Düğmesi",
"Draw a button that runs a macro or a command" => "Makro veya komut çalıştıran bir düğme çiz",
"Draw a slider that sets a whole number in a linked cell" => "Bağlı bir hücrede tamsayı ayarlayan bir kaydırıcı çiz",
"Draw a spin button that steps a whole number in a linked cell" => {
"Bağlı bir hücredeki tamsayıyı adım adım değiştiren bir döndürme düğmesi çiz"
}
"Edit Text" => "Metni Düzenle",
"Assign Action…" => "Eylem Ata…",
"Format Control…" => "Denetimi Biçimlendir…",
"Sheet\nRight-to-Left" => "Sayfa\nSağdan Sola",
"Sheet Right-to-Left" => "Sayfa Sağdan Sola",
"Data\nAnalysis" => "Veri\nAnalizi",
+11
View File
@@ -109,6 +109,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Спарклайни",
"Checkbox" => "Прапорець",
"Insert checkboxes in the selected cells" => "Вставити прапорці у виділені комірки",
"Button" => "Кнопка",
"Slider" => "Повзунок",
"Spin Button" => "Лічильник",
"Draw a button that runs a macro or a command" => "Намалювати кнопку, яка запускає макрос або команду",
"Draw a slider that sets a whole number in a linked cell" => "Намалювати повзунок, який задає ціле число у зв’язаній комірці",
"Draw a spin button that steps a whole number in a linked cell" => {
"Намалювати лічильник, який змінює ціле число у зв’язаній комірці з кроком"
}
"Edit Text" => "Редагувати текст",
"Assign Action…" => "Призначити дію…",
"Format Control…" => "Формат елемента керування…",
"Header & Footer" => "Верхній і нижній колонтитули",
"Draw with ink" => "Малювання від руки",
"Eraser" => "Гумка",
+9
View File
@@ -115,6 +115,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Biểu đồ thu nhỏ",
"Checkbox" => "Hộp kiểm",
"Insert checkboxes in the selected cells" => "Chèn hộp kiểm vào các ô đã chọn",
"Button" => "Nút",
"Slider" => "Thanh trượt",
"Spin Button" => "Nút xoay",
"Draw a button that runs a macro or a command" => "Vẽ một nút chạy macro hoặc lệnh",
"Draw a slider that sets a whole number in a linked cell" => "Vẽ một thanh trượt đặt số nguyên trong ô được liên kết",
"Draw a spin button that steps a whole number in a linked cell" => "Vẽ một nút xoay thay đổi từng bước số nguyên trong ô được liên kết",
"Edit Text" => "Sửa văn bản",
"Assign Action…" => "Gán hành động…",
"Format Control…" => "Định dạng điều khiển…",
"Header & Footer" => "Đầu trang & Chân trang",
"Draw with ink" => "Vẽ bằng mực",
"Eraser" => "Tẩy",
+9
View File
@@ -113,6 +113,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "迷你图",
"Checkbox" => "复选框",
"Insert checkboxes in the selected cells" => "在所选单元格中插入复选框",
"Button" => "按钮",
"Slider" => "滑块",
"Spin Button" => "数值调节钮",
"Draw a button that runs a macro or a command" => "绘制运行宏或命令的按钮",
"Draw a slider that sets a whole number in a linked cell" => "绘制在链接单元格中设置整数的滑块",
"Draw a spin button that steps a whole number in a linked cell" => "绘制按步长调整链接单元格中整数的数值调节钮",
"Edit Text" => "编辑文字",
"Assign Action…" => "指定操作…",
"Format Control…" => "设置控件格式…",
"Header & Footer" => "页眉和页脚",
"Draw with ink" => "墨迹绘制",
"Eraser" => "橡皮擦",
+23
View File
@@ -85,6 +85,12 @@ pub enum Icon {
Symbol,
Sparkline,
Checkbox,
/// A form button with a pointer.
Button,
/// A form slider (scroll bar).
Slider,
/// A form spin button.
Spin,
Function,
Name,
Trace,
@@ -485,6 +491,23 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
g.rect(2.0, 2.0, 12.0, 12.0, None, Some(c));
g.line(&[(4.5, 8.0), (7.0, 10.5), (11.5, 5.0)], c, 1.4);
}
Icon::Button => {
let frame = Rect::from_min_max(g.pt(1.5, 3.0), g.pt(14.5, 11.0));
g.p.rect_filled(frame, 2.0 * g.s, paper);
g.p.rect_stroke(frame, 2.0 * g.s, Stroke::new(1.0, c), egui::StrokeKind::Inside);
g.poly(&[(9.0, 7.5), (9.0, 15.0), (13.5, 12.5)], c);
}
Icon::Slider => {
g.line(&[(2.0, 8.0), (14.0, 8.0)], light, 1.6);
g.line(&[(2.0, 8.0), (7.0, 8.0)], blue, 1.6);
g.circle(7.0, 8.0, 2.4, Some(paper), Some(c));
}
Icon::Spin => {
g.rect(4.0, 1.5, 8.0, 13.0, Some(paper), Some(c));
g.stroke(&[(4.0, 8.0), (12.0, 8.0)], c);
g.poly(&[(8.0, 3.2), (10.5, 6.3), (5.5, 6.3)], c);
g.poly(&[(5.5, 9.7), (10.5, 9.7), (8.0, 12.8)], c);
}
Icon::Fx => g.text(8.0, 8.0, "fx", 10.5, c, (false, true)),
Icon::Plus => {
g.line(&[(8.0, 3.0), (8.0, 13.0)], c, 1.3);
@@ -152,6 +152,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
("HW", Entry::Continue), // Excel: Wrap Text Menu; group level: containment(Wrap Text)
("HWW", Entry::Finish(Action::Command("home.wrapText"))), // Excel: Wrap Text
("HX", Entry::Finish(Action::Command("edit.cut"))), // Excel: Cut
("NBU", Entry::Finish(Action::Command("insert.button"))), // Excel: Button (Form Control)
("NC1", Entry::Finish(Action::Control("insert.chart.column"))), // Excel: Column Chart; Column chart (kind TBD)
("NC2", Entry::Finish(Action::Command("review.newComment"))), // Excel: New Comment
("NCM", Entry::Finish(Action::Dialog("comment"))), // Excel: Excel: New Comment (modern)
@@ -179,10 +180,12 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
("NQ", Entry::Finish(Action::Control("insert.chart.pie"))), // Excel: Pie Chart; Pie (kind TBD)
("NR", Entry::Finish(Action::Command("insert.recommendedCharts"))), // Excel: Recommended Charts
("NSA", Entry::Finish(Action::Control("insert.chart.statistic"))), // Excel: Statistical Chart; Statistical chart (kind TBD)
("NSB", Entry::Finish(Action::Command("insert.spinButton"))), // Excel: Spin Button (Form Control)
("NSC", Entry::Finish(Action::Pending("insert.screenshot"))), // pending; Excel: Screenshot Menu; Screenshot; reserved until the command lands
("NSD", Entry::Finish(Action::Command("insert.chart"))), // Excel: Combo Chart; Combo (kind TBD)
("NSF", Entry::Finish(Action::Command("insert.slicer"))), // Excel: Slicer; insert.slicer missing (gap #8); reserved until the command lands
("NSH", Entry::Finish(Action::Command("insert.shape"))), // Excel: Shapes
("NSL", Entry::Finish(Action::Command("insert.slider"))), // Excel: Scroll Bar (Form Control)
("NSP", Entry::Finish(Action::Command("insert.recommendedPivotTables"))), // Excel: Recommended PivotTables
("NST", Entry::Finish(Action::Pending("insert.timeline"))), // pending; Excel: Timeline; insert.timeline missing (gap #8); reserved until the command lands
("NSZ", Entry::Finish(Action::Command("insert.pivotChart"))), // Excel: PivotChart; PivotChart; reserved until the command lands
+21
View File
@@ -1035,6 +1035,13 @@
"excel": "Cut",
"note": ""
},
{
"seq": "NBU",
"kind": "command",
"target": "insert.button",
"excel": "Button (Form Control)",
"note": ""
},
{
"seq": "NC1",
"kind": "control",
@@ -1222,6 +1229,13 @@
"excel": "Statistical Chart",
"note": "Statistical chart (kind TBD)"
},
{
"seq": "NSB",
"kind": "command",
"target": "insert.spinButton",
"excel": "Spin Button (Form Control)",
"note": ""
},
{
"seq": "NSC",
"kind": "command",
@@ -1252,6 +1266,13 @@
"excel": "Shapes",
"note": ""
},
{
"seq": "NSL",
"kind": "command",
"target": "insert.slider",
"excel": "Scroll Bar (Form Control)",
"note": ""
},
{
"seq": "NSP",
"kind": "command",
+2
View File
@@ -21,6 +21,7 @@ mod cell_pictures;
pub mod chartview;
pub mod code_editor;
pub mod control;
pub mod control_ui;
mod craft_fonts_policy;
pub mod credits;
pub mod dialogs;
@@ -30,6 +31,7 @@ pub mod doc_windows;
pub mod editor;
mod floating_pictures;
pub mod fonts;
pub mod form_controls;
pub mod formula_bar;
mod formula_locale;
pub mod grid;
+5 -1
View File
@@ -116,6 +116,8 @@ pub enum OpenAction {
EditText(u32),
/// The Format Chart pane.
FormatChart(u32),
/// A form control's editor: a button's text, else Format Control.
EditControl(u32),
/// Nothing beyond selecting it.
Select,
}
@@ -124,7 +126,8 @@ pub fn open_action(sh: &Sheet, o: ObjRef) -> OpenAction {
match o.kind {
ObjKind::Chart => OpenAction::FormatChart(o.id),
ObjKind::Shape if sh.shapes.iter().any(|s| s.id == o.id && s.kind == ShapeKind::TextBox) => OpenAction::EditText(o.id),
ObjKind::Shape | ObjKind::Image | ObjKind::Slicer | ObjKind::Control => OpenAction::Select,
ObjKind::Control => OpenAction::EditControl(o.id),
ObjKind::Shape | ObjKind::Image | ObjKind::Slicer => OpenAction::Select,
}
}
@@ -138,6 +141,7 @@ impl SheetApp {
self.begin_text_box_edit(id);
}
OpenAction::FormatChart(_) => self.grid.pane = Some("formatChart".into()),
OpenAction::EditControl(id) => crate::control_ui::edit(self, id),
OpenAction::Select => {}
}
action
+3 -1
View File
@@ -8,7 +8,9 @@ use crate::SheetApp;
use crate::icons::{self, Icon};
use crate::keytips::keytip_table;
use crate::theme::{self, Cat, Tokens};
use crate::widgets::{big_button, big_split_button, color_palette, icon_button, small_button, small_split_button, split_button, toggle_button};
use crate::widgets::{
big_button, big_split_button, color_palette, icon_button, small_button, small_split_button, small_toggle_button, split_button, toggle_button,
};
mod automate;
mod contextual;
+22 -2
View File
@@ -1,7 +1,7 @@
use super::controls::{act, anchor_keytip, control_keytip, invoke, popup_command, sep};
use super::{Icon, Sense, SheetApp, Stroke, StrokeKind, Tokens, Ui, big_button, icons, json, small_button, vec2};
use super::{Icon, Sense, SheetApp, Stroke, StrokeKind, Tokens, Ui, big_button, icons, json, small_button, small_toggle_button, vec2};
/// Draws the Insert tab: PivotTable, Table, Pictures, Shapes, Icons, charts, slicer, sparklines, checkbox, links, comments, text box, header/footer and symbols.
/// Draws the Insert tab: PivotTable, Table, Pictures, Shapes, Icons, charts, slicer, sparklines, form controls (button, slider, spin button), checkbox, links, comments, text box, header/footer and symbols.
pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) {
let __k = big_button(ui, Icon::PivotTable, "PivotTable", "PivotTable", false);
control_keytip(app, ui, &__k, "insert.pivotTable");
@@ -165,6 +165,26 @@ pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) {
}
});
sep(ui);
// Form controls: draw a button, slider or spin button on the sheet (a click places it at its
// default size). Their key tips run the command at the active cell.
use gridcraft_engine::model::ControlKind;
let placing = app.grid.placing;
ui.vertical(|ui| {
for (icon, kind, label, tip, id) in [
(Icon::Button, ControlKind::Button, "Button", "Draw a button that runs a macro or a command", "insert.button"),
(Icon::Slider, ControlKind::Slider, "Slider", "Draw a slider that sets a whole number in a linked cell", "insert.slider"),
(Icon::Spin, ControlKind::Spin, "Spin Button", "Draw a spin button that steps a whole number in a linked cell", "insert.spinButton"),
] {
let on = placing == Some(kind);
let r = small_toggle_button(ui, icon, label, tip, on);
control_keytip(app, ui, &r, id);
if r.clicked() {
app.grid.placing = if placing == Some(kind) { None } else { Some(kind) };
app.selected_object = None;
}
}
});
sep(ui);
// Cell Controls.
if big_button(ui, Icon::Checkbox, "Checkbox", "Insert checkboxes in the selected cells", false).clicked() {
act(app, "insert.checkbox", json!({}));
+30 -1
View File
@@ -102,8 +102,17 @@ pub struct Tokens {
pub sheet_control_bg: Color32,
pub sheet_control_border: Color32,
pub sheet_control_text: Color32,
/// A placeholder fill on the sheet (a picture that has not loaded).
/// A placeholder fill on the sheet (a picture that has not loaded), and a form slider's track.
pub sheet_track: Color32,
/// A form slider's thumb.
pub sheet_thumb: Color32,
/// A form button or spin arrow under the pointer, and pressed.
pub sheet_control_hover: Color32,
pub sheet_control_pressed: Color32,
/// The filled part of a form slider's track (from the minimum to the thumb).
pub sheet_fill: Color32,
/// The ring around the form control that takes the arrow keys.
pub sheet_focus: Color32,
/// The macro recording dot (Record Macro, the status bar).
pub recording: Color32,
/// A breakpoint dot in the macro step editor's gutter.
@@ -165,6 +174,11 @@ const SHEET_CONTROL_BG: Color32 = rgb(0xF3F3F3);
const SHEET_CONTROL_BORDER: Color32 = rgb(0x8A8A8A);
const SHEET_CONTROL_TEXT: Color32 = rgb(0x1F1F1F);
const SHEET_TRACK: Color32 = rgb(0xD6D6D6);
const SHEET_THUMB: Color32 = rgb(0x6B6B6B);
const SHEET_CONTROL_HOVER: Color32 = rgb(0xE4E4E4);
const SHEET_CONTROL_PRESSED: Color32 = rgb(0xCFCFCF);
const SHEET_FILL: Color32 = rgb(0x107C41);
const SHEET_FOCUS: Color32 = rgb(0x107C41);
impl Tokens {
pub fn light() -> Tokens {
@@ -238,6 +252,11 @@ impl Tokens {
sheet_control_border: SHEET_CONTROL_BORDER,
sheet_control_text: SHEET_CONTROL_TEXT,
sheet_track: SHEET_TRACK,
sheet_thumb: SHEET_THUMB,
sheet_control_hover: SHEET_CONTROL_HOVER,
sheet_control_pressed: SHEET_CONTROL_PRESSED,
sheet_fill: SHEET_FILL,
sheet_focus: SHEET_FOCUS,
recording: pick(dark, rgb(0xC42B1C), rgb(0xF1705F)),
breakpoint: pick(dark, rgb(0xD13438), rgb(0xF1707A)),
sheet_macro_input: SHEET_MACRO_INPUT,
@@ -385,6 +404,12 @@ mod contrast_tests {
add("selection border on sheet", t.sel_border, t.grid_bg, GLYPH);
add("sheet control text", t.sheet_control_text, t.sheet_control_bg, TEXT);
add("sheet control border on sheet", t.sheet_control_border, t.grid_bg, GLYPH);
add("slider thumb on track", t.sheet_thumb, t.sheet_track, GLYPH);
add("sheet control text on hover", t.sheet_control_text, t.sheet_control_hover, TEXT);
add("sheet control text when pressed", t.sheet_control_text, t.sheet_control_pressed, TEXT);
add("slider fill on sheet", t.sheet_fill, t.grid_bg, GLYPH);
add("slider fill on track", t.sheet_fill, t.sheet_track, GLYPH);
add("control focus ring on sheet", t.sheet_focus, t.grid_bg, GLYPH);
add("recording dot on ribbon", t.recording, t.ribbon, GLYPH);
add("recording dot on status bar", t.recording, t.status_bar, GLYPH);
for (name, c) in [
@@ -435,6 +460,10 @@ mod contrast_tests {
(l.sheet_control_bg, l.sheet_control_border, l.sheet_control_text, l.sheet_track),
(d.sheet_control_bg, d.sheet_control_border, d.sheet_control_text, d.sheet_track)
);
assert_eq!(
(l.sheet_thumb, l.sheet_control_hover, l.sheet_control_pressed, l.sheet_fill, l.sheet_focus),
(d.sheet_thumb, d.sheet_control_hover, d.sheet_control_pressed, d.sheet_fill, d.sheet_focus)
);
assert_eq!(
(l.sheet_macro_input, l.sheet_macro_footprint, l.sheet_macro_conflict, l.sheet_macro_target),
(d.sheet_macro_input, d.sheet_macro_footprint, d.sheet_macro_conflict, d.sheet_macro_target)
+421
View File
@@ -0,0 +1,421 @@
//! Form controls through real pointer and keyboard input: dragging a slider (one undo step),
//! paging on the track, clicking and holding spin arrows, keyboard steps, a button running its
//! macro, Cmd+click or right-click selecting a control without running it, right-to-left
//! sheets, and the Insert › Controls buttons, context menu and dialogs.
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, vec2};
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_engine::model::{FormControl, ObjKind, ObjRef};
use gridcraft_ui_egui::form_controls::{Part, part_at, slider_parts};
use gridcraft_ui_egui::{SheetApp, chartview, grid::Geo};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
struct Ids {
slider: u32,
spin: u32,
button: u32,
}
fn fixture() -> (Harness, Ids) {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "B1", "input": "20"})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "=B1*10"})).unwrap();
let id = |v: serde_json::Value| v["control"].as_u64().unwrap() as u32;
let slider =
id(s.execute("insert.slider", json!({"at": "E3", "width": 200, "height": 20, "link": "B1", "max": 100, "step": 1, "page": 10})).unwrap());
let spin =
id(s.execute("insert.spinButton", json!({"at": "E8", "width": 20, "height": 40, "link": "B2", "max": 50, "step": 2, "value": 10})).unwrap());
s.execute(
"macro.save",
json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "H1", "input": "pressed"}}], "output": {"onConflict": "overwrite"}}),
)
.unwrap();
let button = id(s.execute("insert.button", json!({"at": "E12", "text": "Mark it", "macro": "Mark"})).unwrap());
s.execute("selection.set", json!({"cell": "A20"})).unwrap();
let mut h = egui_kittest::Harness::builder()
.with_size(vec2(1400.0, 900.0))
.with_step_dt(1.0 / 60.0)
.with_os(egui::os::OperatingSystem::Mac)
.build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
SheetApp::new(s, Default::default()),
);
h.run_steps(4);
(h, Ids { slider, spin, button })
}
fn control(h: &Harness, id: u32) -> FormControl {
h.state().session.doc().unwrap().wb.active().unwrap().controls.iter().find(|c| c.id == id).cloned().unwrap()
}
fn rect(h: &Harness, id: u32) -> Rect {
let app = h.state();
let sh = app.session.doc().unwrap().wb.active().unwrap();
chartview::object_rect(&Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll), sh, ObjRef::new(ObjKind::Control, id)).unwrap()
}
fn cell(h: &Harness, a: &str) -> Value {
h.state().session.doc().unwrap().wb.active().unwrap().value(CellRef::parse(a).unwrap())
}
fn undo_len(h: &Harness) -> usize {
h.state().session.doc().unwrap().undo.len()
}
fn button(h: &mut Harness, p: Pos2, pressed: bool, which: PointerButton, modifiers: Modifiers) {
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: which, pressed, modifiers });
h.step();
}
fn click_with(h: &mut Harness, p: Pos2, modifiers: Modifiers) {
h.run_steps(30);
button(h, p, true, PointerButton::Primary, modifiers);
button(h, p, false, PointerButton::Primary, modifiers);
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
}
fn click(h: &mut Harness, p: Pos2) {
click_with(h, p, Modifiers::NONE);
}
fn key(h: &mut Harness, key: Key) {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
}
fn thumb(h: &Harness, id: u32) -> Rect {
let c = control(h, id);
slider_parts(rect(h, id), &c, 1.0).1
}
#[test]
fn dragging_the_slider_is_one_undo_step() {
let (mut h, ids) = fixture();
let start = thumb(&h, ids.slider).center();
assert_eq!(control(&h, ids.slider).value, 20);
let before = undo_len(&h);
let r = rect(&h, ids.slider);
let (track, _) = slider_parts(r, &control(&h, ids.slider), 1.0);
h.run_steps(30);
button(&mut h, start, true, PointerButton::Primary, Modifiers::NONE);
for k in 1..=6 {
let x = start.x + (track.left() + track.width() * 0.7 - start.x) * k as f32 / 6.0;
h.input_mut().events.push(Event::PointerMoved(Pos2::new(x, start.y + 3.0)));
h.step();
}
let mid = control(&h, ids.slider).value;
assert!((68..=72).contains(&mid), "the thumb follows the pointer: {mid}");
assert_eq!(cell(&h, "B1"), Value::Number(mid as f64), "the linked cell follows while dragging");
assert_eq!(cell(&h, "C1"), Value::Number(mid as f64 * 10.0), "and its dependents");
button(&mut h, Pos2::new(track.left() + track.width() * 0.7, start.y), false, PointerButton::Primary, Modifiers::NONE);
h.run_steps(2);
assert_eq!(undo_len(&h), before + 1, "the whole drag is one undo step");
assert_eq!(h.state().selected_object, None, "working a control doesn't select it");
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(cell(&h, "B1"), Value::Number(20.0));
assert_eq!(control(&h, ids.slider).value, 20);
}
#[test]
fn the_track_pages_and_editing_the_cell_moves_the_thumb() {
let (mut h, ids) = fixture();
let r = rect(&h, ids.slider);
let after = Pos2::new(r.right() - 4.0, r.center().y);
assert_eq!(part_at(r, &control(&h, ids.slider), 1.0, after), Some(Part::TrackAfter));
click(&mut h, after);
assert_eq!(cell(&h, "B1"), Value::Number(30.0), "a click on the track moves a page");
click(&mut h, Pos2::new(r.left() + 3.0, r.center().y));
assert_eq!(cell(&h, "B1"), Value::Number(20.0));
let was = thumb(&h, ids.slider).center().x;
h.state_mut().run("cell.set", json!({"cell": "B1", "input": "90"})).unwrap();
h.run_steps(2);
assert_eq!(control(&h, ids.slider).value, 90);
assert!(thumb(&h, ids.slider).center().x > was + 100.0, "editing the linked cell moves the thumb");
}
#[test]
fn spin_arrows_step_and_repeat_while_held() {
let (mut h, ids) = fixture();
let r = rect(&h, ids.spin);
let up = Pos2::new(r.center().x, r.top() + 5.0);
let down = Pos2::new(r.center().x, r.bottom() - 5.0);
click(&mut h, up);
assert_eq!(cell(&h, "B2"), Value::Number(12.0));
click(&mut h, down);
click(&mut h, down);
assert_eq!(cell(&h, "B2"), Value::Number(8.0));
// Held: after a pause it repeats, and the whole hold is one undo step.
let before = undo_len(&h);
h.run_steps(30);
button(&mut h, up, true, PointerButton::Primary, Modifiers::NONE);
h.run_steps(60);
button(&mut h, up, false, PointerButton::Primary, Modifiers::NONE);
let v = control(&h, ids.spin).value;
assert!(v >= 14, "holding the arrow repeats: {v}");
assert_eq!(undo_len(&h), before + 1);
// It stops at the maximum.
h.run_steps(30);
button(&mut h, up, true, PointerButton::Primary, Modifiers::NONE);
h.run_steps(400);
button(&mut h, up, false, PointerButton::Primary, Modifiers::NONE);
assert_eq!(control(&h, ids.spin).value, 50);
}
#[test]
fn the_keyboard_steps_the_control_last_worked() {
let (mut h, ids) = fixture();
let r = rect(&h, ids.spin);
click(&mut h, Pos2::new(r.center().x, r.top() + 5.0));
assert_eq!(h.state().grid.focused_control, Some(ids.spin));
key(&mut h, Key::ArrowUp);
key(&mut h, Key::ArrowUp);
assert_eq!(control(&h, ids.spin).value, 16);
key(&mut h, Key::ArrowDown);
assert_eq!(control(&h, ids.spin).value, 14);
key(&mut h, Key::End);
assert_eq!(control(&h, ids.spin).value, 50);
let tr = rect(&h, ids.slider);
click(&mut h, Pos2::new(tr.right() - 4.0, tr.center().y));
assert_eq!(h.state().grid.focused_control, Some(ids.slider));
key(&mut h, Key::ArrowRight);
assert_eq!(control(&h, ids.slider).value, 31);
key(&mut h, Key::PageDown);
assert_eq!(control(&h, ids.slider).value, 41);
key(&mut h, Key::PageUp);
key(&mut h, Key::ArrowLeft);
assert_eq!(control(&h, ids.slider).value, 30);
key(&mut h, Key::Home);
assert_eq!(cell(&h, "B1"), Value::Number(0.0));
// Escape lets go; the arrows move the cell cursor again.
key(&mut h, Key::Escape);
assert_eq!(h.state().grid.focused_control, None);
key(&mut h, Key::ArrowDown);
assert_eq!(control(&h, ids.slider).value, 0);
assert_eq!(h.state().session.doc().unwrap().selection.active.a1(), "A21");
}
#[test]
fn a_button_runs_its_macro_and_cmd_or_right_click_only_selects() {
let (mut h, ids) = fixture();
let r = rect(&h, ids.button);
click_with(&mut h, r.center(), Modifiers::COMMAND);
assert_eq!(h.state().selected_object, Some(ObjRef::new(ObjKind::Control, ids.button)));
assert_eq!(cell(&h, "H1"), Value::Empty, "Cmd+click selects without running");
// A click on a selected control moves it rather than running it.
click(&mut h, r.center());
assert_eq!(cell(&h, "H1"), Value::Empty);
// Click elsewhere, then right-click: selects, doesn't run.
click(&mut h, Pos2::new(r.right() + 300.0, r.bottom() + 100.0));
assert_eq!(h.state().selected_object, None);
h.run_steps(30);
button(&mut h, r.center(), true, PointerButton::Secondary, Modifiers::NONE);
button(&mut h, r.center(), false, PointerButton::Secondary, Modifiers::NONE);
h.run_steps(2);
assert_eq!(h.state().selected_object, Some(ObjRef::new(ObjKind::Control, ids.button)));
assert_eq!(cell(&h, "H1"), Value::Empty);
h.state_mut().selected_object = None;
// A plain click runs the macro, as one undo step.
let before = undo_len(&h);
click(&mut h, r.center());
assert_eq!(cell(&h, "H1"), Value::text("pressed"));
assert_eq!(undo_len(&h), before + 1);
assert!(h.state().message.is_none(), "{:?}", h.state().message);
// Pressing and sliding off before letting go doesn't run it.
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run_steps(30);
button(&mut h, r.center(), true, PointerButton::Primary, Modifiers::NONE);
button(&mut h, r.right_bottom() + vec2(80.0, 80.0), false, PointerButton::Primary, Modifiers::NONE);
h.run_steps(2);
assert_eq!(cell(&h, "H1"), Value::Empty);
}
#[test]
fn a_button_from_a_file_asks_before_it_runs() {
use egui_kittest::kittest::Queryable as _;
let (mut h, ids) = fixture();
h.state_mut().session.doc_mut().unwrap().trust = gridcraft_engine::Trust::FromFile;
let r = rect(&h, ids.button);
click(&mut h, r.center());
assert_eq!(cell(&h, "H1"), Value::Empty, "nothing runs before the person says so");
let d = h.state().dialog.clone().expect("a prompt");
assert_eq!(d.name(), "controlQuestion");
let text = d.values["text"].as_str().unwrap_or("").to_string();
assert!(text.contains("came with a workbook you opened"), "{text}");
h.get_by_label(" Run ").click();
h.run_steps(2);
assert_eq!(cell(&h, "H1"), Value::text("pressed"), "Run presses it again with the approval");
assert!(h.state().dialog.is_none());
}
#[test]
fn on_a_right_to_left_sheet_a_control_works_where_it_is_drawn() {
let (mut h, ids) = fixture();
h.state_mut().run("view.rightToLeft", json!({"on": true})).unwrap();
h.run_steps(3);
let screen = |h: &Harness, id: u32| {
let app = h.state();
let sh = app.session.doc().unwrap().wb.active().unwrap();
let geo = Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll);
geo.mirror_rect(chartview::object_rect(&geo, sh, ObjRef::new(ObjKind::Control, id)).unwrap())
};
// The slider moves as a whole and keeps its minimum on the left.
let r = screen(&h, ids.slider);
click(&mut h, Pos2::new(r.right() - 4.0, r.center().y));
assert_eq!(cell(&h, "B1"), Value::Number(30.0), "the right end of the track pages up");
let r = screen(&h, ids.spin);
click(&mut h, Pos2::new(r.center().x, r.top() + 5.0));
assert_eq!(cell(&h, "B2"), Value::Number(12.0), "the upper arrow adds a step");
}
// ---------------------------------------------------------------- ribbon, menus and dialogs
fn insert_tab() -> Harness {
let mut s = Session::new();
s.new_workbook();
let mut app = SheetApp::new(s, Default::default());
app.ui.ribbon_tab = "Insert".into();
let mut h = egui_kittest::Harness::builder()
.with_size(vec2(1400.0, 900.0))
.with_step_dt(1.0 / 60.0)
.with_os(egui::os::OperatingSystem::Mac)
.build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(4);
h
}
fn controls(h: &Harness) -> Vec<FormControl> {
h.state().session.doc().unwrap().wb.active().unwrap().controls.clone()
}
fn cells_rect(h: &Harness) -> Rect {
h.state().grid.cells_rect.unwrap()
}
#[test]
fn the_controls_group_places_by_click_or_drag() {
use egui_kittest::kittest::Queryable as _;
let mut h = insert_tab();
// A click places a slider at its default size.
h.get_by_label("Slider").click();
h.run_steps(2);
assert_eq!(h.state().grid.placing, Some(gridcraft_engine::model::ControlKind::Slider));
let at = cells_rect(&h).min + vec2(300.0, 120.0);
click(&mut h, at);
let list = controls(&h);
assert_eq!(list.len(), 1);
assert_eq!((list[0].anchor.width, list[0].anchor.height, list[0].horizontal), (144.0, 18.0, true));
assert_eq!(h.state().grid.placing, None, "placing is one-shot");
assert_eq!(h.state().selected_object, Some(ObjRef::new(ObjKind::Control, list[0].id)), "the new control is selected");
// A tall drag draws a vertical slider of that size.
h.get_by_label("Slider").click();
h.run_steps(2);
let from = cells_rect(&h).min + vec2(600.0, 100.0);
h.run_steps(30);
button(&mut h, from, true, PointerButton::Primary, Modifiers::NONE);
h.input_mut().events.push(Event::PointerMoved(from + vec2(10.0, 60.0)));
h.step();
button(&mut h, from + vec2(20.0, 150.0), false, PointerButton::Primary, Modifiers::NONE);
h.run_steps(2);
let s = controls(&h).pop().unwrap();
// The drawn size is in sheet points (screen pixels over the zoom).
let drawn = rect(&h, s.id);
assert!(!s.horizontal && (drawn.width() - 20.0).abs() < 1.5 && (drawn.height() - 150.0).abs() < 1.5, "{drawn:?}");
// Escape disarms.
h.get_by_label("Spin Button").click();
h.run_steps(2);
key(&mut h, Key::Escape);
assert_eq!(h.state().grid.placing, None);
// A button asks what to run.
h.get_by_label("Button").click();
h.run_steps(2);
let spot = cells_rect(&h).min + vec2(100.0, 300.0);
click(&mut h, spot);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("assignAction"));
}
#[test]
fn assign_action_and_format_control_dialogs() {
use egui_kittest::kittest::Queryable as _;
let (mut h, ids) = fixture();
// Right-click opens the control's menu; Assign Action picks a command.
let r = rect(&h, ids.button);
h.run_steps(30);
button(&mut h, r.center(), true, PointerButton::Secondary, Modifiers::NONE);
button(&mut h, r.center(), false, PointerButton::Secondary, Modifiers::NONE);
h.run_steps(2);
assert_eq!(h.state().grid.object_menu.map(|(_, kind, id)| (kind, id)), Some(("control", ids.button)));
// The ribbon has a Delete button of its own.
for item in ["Edit Text", "Assign Action…", "Format Control…", "Delete"] {
assert!(h.query_all_by_label(item).next().is_some(), "{item}");
}
h.get_by_label("Assign Action…").click();
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("assignAction"));
// The dialog settles its size (and so its place) over the first frames.
h.run_steps(30);
let radio = h.get_by_label("Run a command").rect();
click(&mut h, radio.center());
assert_eq!(h.state().dialog.as_ref().map(|d| d.values["mode"].clone()), Some(json!("command")));
{
let d = h.state_mut().dialog.as_mut().unwrap();
d.values.insert("command".into(), json!("cell.set"));
d.values.insert("params".into(), json!("{\"cell\": \"H2\", \"input\": \"cmd\"}"));
}
h.run_steps(2);
h.get_by_label(" OK ").click();
h.run_steps(2);
assert!(h.state().dialog.is_none(), "{:?}", h.state().message);
assert!(matches!(control(&h, ids.button).action, Some(gridcraft_engine::model::ControlAction::Command { ref id, .. }) if id == "cell.set"));
h.state_mut().selected_object = None;
click(&mut h, r.center());
assert_eq!(cell(&h, "H2"), Value::text("cmd"), "{:?}", h.state().message);
// Format Control: refuses broken limits, then applies good ones and writes the link.
h.state_mut().open_dialog("formatControl", json!({"id": ids.spin}));
h.run_steps(2);
assert!(h.query_by_label("Incremental change:").is_some());
assert!(h.query_by_label("Page change:").is_none(), "a spin button has no page change");
{
let d = h.state_mut().dialog.as_mut().unwrap();
d.values.insert("max".into(), json!("0"));
}
h.run_steps(2);
assert!(h.query_by_label_contains("must be greater than Minimum").is_some());
{
let d = h.state_mut().dialog.as_mut().unwrap();
d.values.insert("max".into(), json!("40"));
d.values.insert("value".into(), json!("30"));
d.values.insert("link".into(), json!("$D$1"));
}
h.run_steps(2);
h.get_by_label(" OK ").click();
h.run_steps(2);
let c = control(&h, ids.spin);
assert_eq!((c.max, c.value, c.link.as_deref()), (40, 30, Some("$D$1")));
assert_eq!(cell(&h, "D1"), Value::Number(30.0));
// Double-clicking a selected button edits its text.
h.state_mut().selected_object = Some(ObjRef::new(ObjKind::Control, ids.button));
h.state_mut().open_object(ObjRef::new(ObjKind::Control, ids.button));
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().map(|d| d.title.as_str()), Some("Edit Text"));
}
+22 -1
View File
@@ -1095,6 +1095,13 @@ fn keytip_hx_() {
assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.cut"), "HX must journal edit.cut");
}
#[test]
fn keytip_nbu_() {
let mut h = harness(blank());
sequence(&mut h, "NBU", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.button"), "NBU must journal insert.button");
}
#[test]
fn keytip_nc1_() {
let mut h = harness(blank());
@@ -1302,6 +1309,13 @@ fn keytip_nsa_() {
assert_eq!(h.state().keytips.prefix(), None);
}
#[test]
fn keytip_nsb_() {
let mut h = harness(blank());
sequence(&mut h, "NSB", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.spinButton"), "NSB must journal insert.spinButton");
}
#[test]
fn keytip_nsc_() {
let mut h = harness(blank());
@@ -1333,6 +1347,13 @@ fn keytip_nsh_() {
assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.shape"), "NSH must journal insert.shape");
}
#[test]
fn keytip_nsl_() {
let mut h = harness(blank());
sequence(&mut h, "NSL", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.slider"), "NSL must journal insert.slider");
}
#[test]
fn keytip_nsp_() {
let mut h = harness(blank());
@@ -2754,7 +2775,7 @@ fn keytip_q_() {
#[test]
fn generated_test_count_matches_the_table() {
const EXPECTED: usize = 365;
const EXPECTED: usize = 368;
assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED);
}
+1
View File
@@ -39,6 +39,7 @@ mod find_dialog_focus;
mod floating_pictures;
mod font_box_filter;
mod font_size_box;
mod form_controls;
mod format_chart_type;
mod format_painter;
mod formula_bar;
+4 -2
View File
@@ -92,8 +92,10 @@ include `window` or `doc` to run it in that window, as with `engine.execute`; th
them as their own parameters.
`ui.inspect` reports the selected floating object as `selectedObject: {kind, id}` (or `null`),
where `kind` is `chart`, `image`, `shape` or `slicer`; the same names go in the `kind` parameter
of `object.delete`, `object.move`, `object.setAnchorMode` and the `arrange.*` commands.
where `kind` is `chart`, `image`, `shape`, `slicer` or `control` (a form button, slider or spin
button); the same names go in the `kind` parameter of `object.delete`, `object.move`,
`object.setAnchorMode` and the `arrange.*` commands. Form controls are worked with
`control.setValue` and `control.press`, and listed by `control.list`.
`selectedChart` remains for existing clients: the id when the selection is a chart, otherwise
`null`. With an object selected, `ui.key {key: "Delete"}` (or Backspace) deletes the object
instead of clearing cells, and Escape clears the selection.
+11 -1
View File
@@ -4,7 +4,7 @@
Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md).
**Overall: 276 / 292 commands implemented (94.5%).**
**Overall: 280 / 296 commands implemented (94.6%).**
| Tab | Implemented | Total | Parity |
|---|---|---|---|
@@ -18,6 +18,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as
| Review | 15 | 18 | 83% |
| View | 21 | 21 | 100% |
| Automate | 4 | 4 | 100% |
| Developer | 4 | 4 | 100% |
| Table Design | 13 | 13 | 100% |
| Chart Design | 8 | 8 | 100% |
| Format | 3 | 3 | 100% |
@@ -334,6 +335,15 @@ Implemented:
- `automate.recordActions` Record Actions (Scripting Tools)
- `automate.trustScripts` Trust Scripts (Scripting Tools)
## Developer (4 / 4, 100%)
Implemented:
- `insert.button` Button (Form Control) (Controls)
- `insert.spinButton` Spin Button (Form Control) (Controls)
- `insert.slider` Scroll Bar (Form Control) (Controls)
- `control.format` Properties (Controls)
## Table Design (13 / 13, 100%)
Implemented:
+8 -7
View File
@@ -1,6 +1,6 @@
# Target-app parity: GridCraft vs Microsoft Excel
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (chart resizing no longer listed as missing; earlier: automation: macro interface; earlier: catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (form controls; catalog 280 / 296; earlier: chart resizing no longer listed as missing; earlier: automation: macro interface; earlier: catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365)
This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it,
[`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail:
@@ -13,7 +13,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
| Number | Value | Kind |
|---|---|---|
| Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements |
| Ribbon and menu catalog | **276 / 292 (94.5%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) |
| Ribbon and menu catalog | **280 / 296 (94.6%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) |
| Worksheet functions, by name | **513 / 523 (98.1%)** | measured: registered functions vs Microsoft's published function list (method below) |
| Worksheet functions that do real work | **504 / 523 (96.4%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) |
| **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) |
@@ -54,11 +54,12 @@ long `.xls` track, so about half parallelizes (≈30–50 wall-clock hours with
COPILOT and PY: **523 functions**. Ours: every `f!(…)` spec in `crates/functions/src/*.rs` plus
`SPECIAL_FUNCTIONS` in `crates/calc/src/eval.rs` (evaluator special forms), minus the test-only
`ADD2`: **505**, all of them Excel names.
- **Ribbon catalog:** `crates/engine/src/catalog.rs` (292 unique ids across 15 tabs) against the
313 command ids registered in `crates/engine/src/cmd/*.rs`, recomputed from source by script and
- **Ribbon catalog:** `crates/engine/src/catalog.rs` (296 unique ids across 16 tabs) against the
321 command ids registered in `crates/engine/src/cmd/*.rs`, recomputed from source by script and
identical to the committed `parity-checklist.md`. The catalog is our own transcription of
Excel's main ribbon tabs; it leaves out most contextual tabs (Picture Format, Shape Format,
Header & Footer, Sparkline, Slicer, Timeline), the Developer and Help tabs and sub-menu items, so
Header & Footer, Sparkline, Slicer, Timeline), most of the Developer tab (its form controls are
in), the Help tab and sub-menu items, so
89% of it is roughly **60–65% of Excel's full command surface** (estimated).
- **Depth and readiness:** source reading (the model types in `crates/model/src/features.rs`,
readers and writers in `crates/xlsx/src`, the evaluator), 478 `#[test]` functions, and the
@@ -91,9 +92,9 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2
| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) |
| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer and Limits reports; no Sensitivity report or options dialog). Missing: Forecast Sheet, Analyze Data |
| Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) |
| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) |
| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes, form buttons, scroll bars and spin buttons. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) |
| Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) |
| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros, edited and debugged in a step editor, and run from the Macros dialog or a Ctrl+Shift shortcut. VBA is out of scope by owner decision: an `.xlsm` keeps its VBA project unchanged on save and never runs it. No Office Scripts compatibility |
| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros, edited and debugged in a step editor, and run from the Macros dialog, a Ctrl+Shift shortcut or a form button (which can also run one command). VBA is out of scope by owner decision: an `.xlsm` keeps its VBA project unchanged on save and never runs it. No Office Scripts compatibility |
| **Weighted** | 100 | **~75%** | **~59%** | **300–485** | |
## Dimensions
+3 -2
View File
@@ -1,10 +1,10 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: checkbox cells print as boxes; earlier: Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: 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-10-31 · **Change:** minor (form controls: buttons, sliders and spin buttons on the sheet; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: checkbox cells print as boxes; earlier: Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: 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
separately in [parity-checklist.md](parity-checklist.md) (276 / 292).
separately in [parity-checklist.md](parity-checklist.md) (280 / 296).
Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes),
`crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes
@@ -68,6 +68,7 @@ this logic, with one undo step.
| Copy as HTML table for other apps | done (#36, 10-10) |
| Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Show Details on a PivotTable's value, subtotal or grand total cell (`pivot.showDetails`). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup |
| Move and resize charts, pictures and shapes | done (#190, #213): charts, pictures, shapes and slicers select with a frame of eight handles; each handle resizes only its own edges (they never cross), the body moves the object, and the pointer shows a resize or move cursor; Delete or Backspace deletes the selected object and Escape drops the selection; double-click edits a text box, opens Format Chart for a chart. Rotation handle, Shift for proportional resizing, Alt to snap to cells and multi-object selection missing |
| Form controls: buttons, scroll bars (sliders), spin buttons | done (10-31): Insert › Controls (Button, Slider, Spin Button; Excel has them under Developer › Insert) places one by a drag, or at its default size by a click; a click works a control: drag the thumb, page on the track, click or hold an arrow (a drag or a hold is one undo step), press a button, which runs a macro or a command and asks first in a workbook opened from a file; the control last worked takes the arrows, Page Up and Page Down, Home, End, and Space or Enter; Ctrl or Cmd+click or a right-click selects it to move or resize; its menu has Edit Text, Assign Action, Format Control and Delete. Check boxes, option buttons, list boxes and drop-downs (form controls) are kept from files but not shown |
| Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial |
| Quick Analysis (Ctrl+Q) | missing |
| Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); dragging the handle at the scrollbar's left edge gives the tabs more or less of the bar; each workbook keeps its share while it is open (not saved in the file) |