diff --git a/ROADMAP.md b/ROADMAP.md index 8afe566c6..1a42a5fd6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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, `` 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 | diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 45455e00b..234e92e58 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -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"), diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index efa19d3d7..3881b4cc3 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -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) { diff --git a/crates/ui-egui/src/control_ui.rs b/crates/ui-egui/src/control_ui.rs new file mode 100644 index 000000000..4100ba2a3 --- /dev/null +++ b/crates/ui-egui/src/control_ui.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 ¯os { + 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 { + 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 { + text.trim().parse::().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 { + 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) = ¶ms { + 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))), + } + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 92187d5c0..411992c27 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1017,6 +1017,9 @@ impl Dialog { dialog } "pickList" => Dialog::custom("pickList", "Pick From List", json!({})), + "assignAction" => return crate::control_ui::open_assign(app, ¶ms), + "formatControl" => return crate::control_ui::open_format(app, ¶ms), + "controlQuestion" => return crate::control_ui::open_question(¶ms), "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!({})), diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs index 071a94e5b..8a2acc010 100644 --- a/crates/ui-egui/src/dialogs/show.rs +++ b/crates/ui-egui/src/dialogs/show.rs @@ -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); diff --git a/crates/ui-egui/src/form_controls.rs b/crates/ui-egui/src/form_controls.rs new file mode 100644 index 000000000..c4fff919a --- /dev/null +++ b/crates/ui-egui/src/form_controls.rs @@ -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, + pub pressed: Option, + 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 { + 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) -> 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()); + } +} diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 85ff53b07..e86f7aac6 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -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, + /// A form control being worked with the pointer. + pub control: Option, + /// The form control that takes the arrow keys (the one last worked). + pub focused_control: Option, + /// Insert › Controls: the kind a click or drag on the grid places, and where the drag began + /// (logical position). + pub placing: Option, + pub place_start: Option, /// The horizontal scrollbar's track in the sheet tab bar (hit-testing for tests). pub hscroll: Option, /// The handle between the sheet tabs and the horizontal scrollbar (hit-testing for tests). diff --git a/crates/ui-egui/src/grid/cursor.rs b/crates/ui-egui/src/grid/cursor.rs index f93e59cb6..2b560b3d9 100644 --- a/crates/ui-egui/src/grid/cursor.rs +++ b/crates/ui-egui/src/grid/cursor.rs @@ -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()) { diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 4b7adf587..289f76206 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -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; } diff --git a/crates/ui-egui/src/grid/keyboard.rs b/crates/ui-egui/src/grid/keyboard.rs index b8823d570..f2d571bb3 100644 --- a/crates/ui-egui/src/grid/keyboard.rs +++ b/crates/ui-egui/src/grid/keyboard.rs @@ -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 { + 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, + } +} diff --git a/crates/ui-egui/src/grid/menus.rs b/crates/ui-egui/src/grid/menus.rs index b05a04bc7..0cd9c9d74 100644 --- a/crates/ui-egui/src/grid/menus.rs +++ b/crates/ui-egui/src/grid/menus.rs @@ -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; diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 9a6d54030..88bed777b 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 6427b279d..15775cf5d 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index 9231f8244..1383f876f 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -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δεδομένων", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index a4b4e884a..9eb7f968a 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index fdb20f7bf..e6a1e4edb 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 31bc6721c..87726f66b 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 1f06755dd..202872cb9 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -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", "消しゴム"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c54ed7eee..bc189e74a 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -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" => "지우개", diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index a28459f83..a89f7b322 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index d7741a9c6..5954d5806 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index c79be64e1..5b4233669 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 058911d87..8f8f5940f 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -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ă", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index b0e894095..42eef64b6 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -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" => "Ластик", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index c9ce49e9e..ff4aec29b 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index 68cd198bb..4d3bd2df7 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 72c1bf474..1920d106d 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -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" => "Гумка", diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index df76b4a93..0a8db64f5 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -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", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index d6dd0eabd..831ac8401 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -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" => "橡皮擦", diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c1f894ea9..e7e3c948f 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -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); diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 2a2509f08..253c8321e 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -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 diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 5dfe7b2f5..fac814b70 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -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", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 76d66c4ca..502c7f8c0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs index e6afa30bd..93b6d6372 100644 --- a/crates/ui-egui/src/objects.rs +++ b/crates/ui-egui/src/objects.rs @@ -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 diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index d2adc491e..2d5f42c5a 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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; diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs index 63f61f755..38a3cfeef 100644 --- a/crates/ui-egui/src/ribbon/insert.rs +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -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!({})); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index f37eb0c35..a0b267c13 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -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) diff --git a/crates/ui-egui/tests/it/form_controls.rs b/crates/ui-egui/tests/it/form_controls.rs new file mode 100644 index 000000000..79823f62c --- /dev/null +++ b/crates/ui-egui/tests/it/form_controls.rs @@ -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 { + 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")); +} diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 810338a48..09a04d55c 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -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); } diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index ca72568da..a96b02a20 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -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; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index bbe00c51f..1ca0a3e9d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 71d75f659..f02cb8490 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b5a63ea36..7b8791f17 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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 diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a7c1345d5..9b19ddbb5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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) |