gridcraft/crates/ui-egui/src/control.rs

533 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Programmatic control of the running app (agents, tests, MCP).
//!
//! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`):
//! - `engine.execute {command, params}` — run any engine or UI command
//! - `engine.commands {search?}` — every command with enablement and params docs
//! - `document.inspect`, `sheet.read {range?}`, `cell.get {cell?}` — read state
//! - `ui.inspect` — UI state (ribbon tab, editor, dialog, grid geometry, perf)
//! - `ui.click {x, y, button?, count?, shift?, cmd?, alt?}`, `ui.drag {x, y, toX, toY}`,
//! `ui.move {x, y}` — real pointer input in window points
//! - `ui.cell.click {cell}`, `ui.cell.drag {from, to}` — pointer input on a cell's centre
//! - `ui.key {key, shift?, cmd?, alt?, ctrl?}`, `ui.text {text}` — keyboard input
//! - `ui.ribbon {tab}`, `ui.dialog {name}`, `ui.dialog.set {field, value}`,
//! `ui.dialog.confirm`, `ui.dialog.cancel`, `ui.message.dismiss`
//! - `ui.edit.begin {text?}`, `ui.edit.commit {move?}`, `ui.edit.cancel`
//! - `ui.screenshot {path?}` — PNG of the window (base64 when no path); animations are settled
//! - `ui.resize {width, height}`, `ui.set {dark?, formulaBar?, ribbonCollapsed?, animations?}`
//! - `app.open {path, password?}`, `app.save {path?}`, `app.quit`
use std::sync::mpsc::Sender;
use gridcraft_engine::core::CellRef;
use serde_json::{Value as Json, json};
use crate::SheetApp;
pub type ControlResponse = Json;
pub struct ControlRequest {
pub method: String,
pub params: Json,
pub reply: Sender<ControlResponse>,
}
impl ControlRequest {
pub fn new(method: impl Into<String>, params: Json) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
let (tx, rx) = std::sync::mpsc::channel();
(Self { method: method.into(), params, reply: tx }, rx)
}
}
pub enum Outcome {
Done(Json),
Screenshot { path: Option<String> },
}
#[derive(Default)]
pub struct Shots {
token: u64,
queued: Vec<(u64, f64, u32)>,
pending: Vec<(u64, Option<String>, Sender<ControlResponse>, f64)>,
}
impl Shots {
/// A screenshot was asked for and hasn't been delivered yet: animations hold still, so the
/// picture shows the settled state.
pub(crate) fn in_flight(&self) -> bool {
!self.queued.is_empty() || !self.pending.is_empty()
}
}
fn ok(v: Json) -> Outcome {
Outcome::Done(json!({"ok": true, "result": v}))
}
fn err(e: impl std::fmt::Display) -> Outcome {
Outcome::Done(json!({"ok": false, "error": e.to_string()}))
}
fn wrap(r: Result<Json, String>) -> Outcome {
match r {
Ok(v) => ok(v),
Err(e) => err(e),
}
}
pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json {
let r = ctx.content_rect();
let v = app.view();
json!({
"ui": serde_json::to_value(&app.ui).unwrap_or_default(),
"window": [r.width(), r.height()],
"grid": app.grid.cells_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
"scroll": [v.scroll.x, v.scroll.y],
"editor": app.editor.as_ref().map(|e| json!({"cell": e.cell.a1(), "text": e.text, "formulaBar": e.from_formula_bar, "point": e.can_point()})),
"dialog": app.dialog.as_ref().map(|d| json!({"name": d.name, "title": d.title, "values": d.values, "tab": d.tab})),
"backstage": app.backstage.as_ref().map(|b| b.page.id()),
"message": app.message.as_ref().map(|(t, m)| json!({"title": t, "text": m})),
"selectedChart": app.selected_chart,
"mode": format!("{:?}", app.session.mode),
"documents": app.session.documents().iter().map(|d| json!({"title": d.display_title(), "dirty": d.is_dirty()})).collect::<Vec<_>>(),
"activeDocument": app.session.active_index(),
"perf": {"frameMs": app.perf.frame_ms, "gridMs": app.perf.grid_ms, "fps": app.perf.fps},
})
}
fn key_from(name: &str) -> Option<egui::Key> {
egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() {
"enter" | "return" => Some(egui::Key::Enter),
"esc" | "escape" => Some(egui::Key::Escape),
"delete" | "del" => Some(egui::Key::Delete),
"backspace" => Some(egui::Key::Backspace),
"left" => Some(egui::Key::ArrowLeft),
"right" => Some(egui::Key::ArrowRight),
"up" => Some(egui::Key::ArrowUp),
"down" => Some(egui::Key::ArrowDown),
"space" => Some(egui::Key::Space),
"tab" => Some(egui::Key::Tab),
"pageup" => Some(egui::Key::PageUp),
"pagedown" => Some(egui::Key::PageDown),
"home" => Some(egui::Key::Home),
"end" => Some(egui::Key::End),
"f2" => Some(egui::Key::F2),
"f4" => Some(egui::Key::F4),
"f9" => Some(egui::Key::F9),
_ => None,
})
}
fn modifiers(p: &Json) -> egui::Modifiers {
let b = |n: &str| p.get(n).and_then(Json::as_bool).unwrap_or(false);
egui::Modifiers {
alt: b("alt"),
ctrl: b("ctrl"),
shift: b("shift"),
mac_cmd: b("cmd") && cfg!(target_os = "macos"),
command: b("cmd") || (b("ctrl") && !cfg!(target_os = "macos")),
}
}
fn cell_center(app: &SheetApp, c: CellRef) -> Option<egui::Pos2> {
let d = app.session.active()?;
let sh = d.wb.active()?;
let rect = app.grid.rect?;
let geo = crate::grid::Geo::new(sh, rect, app.view().scroll);
Some(geo.screen_cell_rect(sh, c).center())
}
fn push_click(app: &mut SheetApp, pos: egui::Pos2, button: egui::PointerButton, count: u32, m: egui::Modifiers) {
app.synthetic.push_back(vec![egui::Event::PointerMoved(pos)]);
let mut batch = Vec::new();
for _ in 0..count.max(1) {
batch.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m });
batch.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m });
}
app.synthetic.push_back(batch);
}
fn push_drag(app: &mut SheetApp, a: egui::Pos2, b: egui::Pos2, steps: usize, m: egui::Modifiers) {
app.synthetic.push_back(vec![egui::Event::PointerMoved(a)]);
app.synthetic.push_back(vec![egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }]);
for i in 1..=steps.max(1) {
let t = i as f32 / steps.max(1) as f32;
app.synthetic.push_back(vec![egui::Event::PointerMoved(a + (b - a) * t)]);
}
app.synthetic.push_back(vec![egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }]);
}
/// Keeps the commands whose id, label or ribbon path contains `search` (case-insensitive); `None` keeps all.
fn filter_commands(commands: Vec<Json>, search: Option<&str>) -> Vec<Json> {
let Some(q) = search.map(str::to_ascii_lowercase) else { return commands };
let has = |c: &Json, k: &str| c.get(k).and_then(Json::as_str).is_some_and(|v| v.to_ascii_lowercase().contains(&q));
let in_menu = |c: &Json| {
let menu = c.get("menu").and_then(Json::as_array);
menu.is_some_and(|m| m.iter().filter_map(Json::as_str).any(|v| v.to_ascii_lowercase().contains(&q)))
};
commands.into_iter().filter(|c| has(c, "id") || has(c, "label") || in_menu(c)).collect()
}
pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -> Outcome {
let s = |k: &str| p.get(k).and_then(Json::as_str);
let f = |k: &str| p.get(k).and_then(Json::as_f64).unwrap_or(0.0) as f32;
let out = match method {
"engine.execute" | "command" | "ui.menu.invoke" => {
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
let params = p.get("params").cloned().unwrap_or(json!({}));
wrap(app.run(id, if params.is_null() { json!({}) } else { params }))
}
"engine.commands" => {
let mut v: Vec<Json> = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
for (id, label, params) in [
("ui.ribbonTab", "Ribbon Tab", ""),
("view.collapseRibbon", "Collapse Ribbon", ""),
("view.darkMode", "Dark Mode", ""),
("view.theme", "Display Theme", ""),
("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"),
("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"),
("ui.dialog", "Open Dialog", ""),
("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"),
(
"ui.printSettings",
"Print Settings",
"{printer?: discovered name, copies?: 1..999, duplex?: printerDefault|off|longEdge|shortEdge|null, grayscale?: bool, refresh?: bool} → {printer,copies,duplex,grayscale,printers,ready,busy,discovering,status,detail}; {} inspects without submitting",
),
(
"ui.printSubmit",
"Submit Print Job",
"existing file.print sheet/pagination params → {printing,bytes,pending}; submits without opening a dialog",
),
("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"|\"de\"|\"cs\"} (alias: code)"),
("app.language.english", "Interface Language: English", ""),
("app.language.japanese", "Interface Language: Japanese", ""),
] {
v.push(json!({"id": id, "label": label, "params": params, "enabled": true, "ui": true}));
}
ok(Json::Array(filter_commands(v, s("search"))))
}
"engine.journal" => ok(json!(app.session.journal.iter().map(|(id, p)| json!({"command": id, "params": p})).collect::<Vec<_>>())),
"document.inspect" | "sheet.read" | "cell.get" | "selection.stats" => wrap(app.session.run(method, p.clone())),
"ui.inspect" => ok(inspect(app, ctx)),
"ui.ribbon" => {
app.ui.ribbon_tab = s("tab").unwrap_or("Home").to_string();
app.ui.ribbon_collapsed = false;
ok(Json::Null)
}
"ui.set" => {
if let Some(v) = p.get("dark").and_then(Json::as_bool) {
app.ui.dark = v;
app.ui.system_theme = false;
}
if let Some(v) = p.get("formulaBar").and_then(Json::as_bool) {
let _ = app.run("view.formulaBar", json!({"on": v}));
}
if let Some(v) = p.get("ribbonCollapsed").and_then(Json::as_bool) {
app.ui.ribbon_collapsed = v;
}
if let Some(v) = p.get("animations").and_then(Json::as_bool) {
app.ui.animations = v;
}
let mut out = serde_json::to_value(&app.ui).unwrap_or_default();
if let Some(o) = out.as_object_mut() {
o.insert("formulaBar".into(), json!(app.session.prefs.formula_bar));
}
ok(out)
}
"ui.dialog" => {
app.open_dialog(s("name").unwrap_or(""), p.clone());
ok(json!({"dialog": app.dialog.as_ref().map(|d| d.name.clone())}))
}
"ui.dialog.set" => match app.dialog.as_mut() {
Some(d) => {
let Some(field) = s("field") else { return err("missing `field`") };
if field == "tab" {
d.tab = s("value").unwrap_or("").to_string();
} else if field == "search" {
d.search = s("value").unwrap_or("").to_string();
} else {
d.values.insert(field.to_string(), p.get("value").cloned().unwrap_or(Json::Null));
}
ok(json!(d.values))
}
None => err("no dialog is open"),
},
"ui.dialog.confirm" => match app.dialog.take() {
Some(d) => match d.command {
Some(cmd) => {
let params = Json::Object(d.values.clone());
let r = app.run(cmd, params);
if r.is_err() {
app.dialog = Some(d);
}
wrap(r)
}
None => {
app.dialog = Some(d);
err("this dialog has no OK command; drive it with its own commands")
}
},
None => err("no dialog is open"),
},
"ui.dialog.cancel" => {
app.dialog = None;
ok(Json::Null)
}
"ui.message.dismiss" => {
app.message = None;
ok(Json::Null)
}
"ui.edit.begin" => {
app.begin_edit(s("text").map(str::to_string), false);
ok(Json::Null)
}
"ui.edit.text" => match app.editor.as_mut() {
Some(e) => {
e.text = s("text").unwrap_or("").to_string();
e.caret = e.text.chars().count();
ok(Json::Null)
}
None => err("not editing"),
},
"ui.edit.commit" => {
let (dr, dc) = match s("move") {
Some("right") => (0, 1),
Some("up") => (-1, 0),
Some("left") => (0, -1),
Some("none") => (0, 0),
_ => (1, 0),
};
if app.commit_edit(dr, dc, false, false) {
ok(Json::Null)
} else {
err(app.message.clone().map(|m| m.1).unwrap_or_else(|| "could not commit".into()))
}
}
"ui.edit.cancel" => {
app.cancel_edit();
ok(Json::Null)
}
"ui.key" => {
let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") };
let m = modifiers(p);
app.synthetic.push_back(vec![
egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m },
egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m },
]);
ok(Json::Null)
}
"ui.text" => {
app.synthetic.push_back(vec![egui::Event::Text(s("text").unwrap_or("").to_string())]);
ok(Json::Null)
}
"ui.move" => {
app.synthetic.push_back(vec![egui::Event::PointerMoved(egui::pos2(f("x"), f("y")))]);
ok(Json::Null)
}
"ui.click" => {
let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary };
push_click(app, egui::pos2(f("x"), f("y")), button, p.get("count").and_then(Json::as_u64).unwrap_or(1) as u32, modifiers(p));
ok(Json::Null)
}
"ui.drag" => {
push_drag(
app,
egui::pos2(f("x"), f("y")),
egui::pos2(f("toX"), f("toY")),
p.get("steps").and_then(Json::as_u64).unwrap_or(8) as usize,
modifiers(p),
);
ok(Json::Null)
}
"ui.cell.click" => {
let Some(c) = s("cell").and_then(CellRef::parse) else { return err("missing `cell`") };
let Some(pos) = cell_center(app, c) else { return err("no grid yet") };
let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary };
push_click(app, pos, button, p.get("count").and_then(Json::as_u64).unwrap_or(1) as u32, modifiers(p));
ok(json!({"x": pos.x, "y": pos.y}))
}
"ui.cell.drag" => {
let (Some(a), Some(b)) = (s("from").and_then(CellRef::parse), s("to").and_then(CellRef::parse)) else {
return err("missing `from`/`to`");
};
let (Some(pa), Some(pb)) = (cell_center(app, a), cell_center(app, b)) else { return err("no grid yet") };
push_drag(app, pa, pb, 8, modifiers(p));
ok(Json::Null)
}
"ui.resize" => {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(f("width").max(400.0), f("height").max(300.0))));
ok(Json::Null)
}
"ui.focus" => {
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
ok(Json::Null)
}
"ui.screenshot" => return Outcome::Screenshot { path: s("path").map(str::to_string) },
"app.open" => {
let Some(path) = s("path") else { return err("missing `path`") };
// Not `open_path`: that asks for a missing password in a dialog, and programmatic
// calls never open one. A password-protected workbook takes `password` instead.
let r = app.run("file.open", json!({"path": path, "password": s("password")}));
if let Ok(r) = &r {
app.after_open(Some(path), r);
}
app.after_engine();
match (r, app.message.take()) {
(Err(e), _) => err(crate::clean_error(&e)),
(Ok(_), Some((_, m))) => err(m),
(Ok(_), None) => ok(json!({"title": app.session.active().map(|d| d.display_title())})),
}
}
"app.save" => wrap(app.run("file.save", json!({"path": s("path")}))),
"app.quit" => {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ok(Json::Null)
}
// Anything else that names a command runs it.
other if gridcraft_engine::find_command(other).is_some() => wrap(app.run(other, p.clone())),
other => err(format!("unknown method `{other}`")),
};
ctx.request_repaint();
out
}
pub fn poll(app: &mut SheetApp, ctx: &egui::Context) {
let Some(rx) = app.control_rx.take() else { return };
while let Ok(req) = rx.try_recv() {
let outcome = handle(app, ctx, &req.method, &req.params);
match outcome {
Outcome::Done(v) => {
let _ = req.reply.send(v);
}
Outcome::Screenshot { path } => {
app.shots.token += 1;
let token = app.shots.token;
app.shots.queued.push((token, crate::now_ms() + 100.0, 0));
app.shots.pending.push((token, path, req.reply, crate::now_ms() + 8000.0));
}
}
}
app.control_rx = Some(rx);
if !app.synthetic.is_empty() {
ctx.request_repaint();
}
}
pub fn issue_screenshots(app: &mut SheetApp, ctx: &egui::Context) {
let now = crate::now_ms();
app.shots.queued.retain_mut(|(token, at, frames)| {
*frames += 1;
if now >= *at && *frames >= 3 {
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token)));
false
} else {
true
}
});
if !app.shots.queued.is_empty() || !app.shots.pending.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
pub fn collect_screenshots(app: &mut SheetApp, ctx: &egui::Context) {
if app.shots.pending.is_empty() {
return;
}
let events: Vec<(u64, std::sync::Arc<egui::ColorImage>)> = ctx.input(|i| {
i.raw
.events
.iter()
.filter_map(|e| match e {
egui::Event::Screenshot { user_data, image, .. } => {
let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::<u64>()).copied()?;
Some((token, image.clone()))
}
_ => None,
})
.collect()
});
for (token, image) in events {
if let Some(i) = app.shots.pending.iter().position(|(t, ..)| *t == token) {
let (_, path, reply, _) = app.shots.pending.remove(i);
let _ = reply.send(save_image(&image, path.as_deref()));
}
}
let now = crate::now_ms();
app.shots.pending.retain(|(_, _, reply, deadline)| {
if now < *deadline {
return true;
}
let _ = reply.send(json!({"ok": false, "error": "no frame was presented before the screenshot timeout; make the window visible, use `ui.focus`, then retry `ui.screenshot`"}));
false
});
}
/// Encodes a screenshot as PNG to `path`, or returns it as base64.
pub fn save_image(image: &egui::ColorImage, path: Option<&str>) -> Json {
let [w, h] = image.size;
let mut rgba = Vec::with_capacity(w * h * 4);
for p in &image.pixels {
rgba.extend_from_slice(&p.to_array());
}
let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, rgba) else { return json!({"ok": false, "error": "bad image"}) };
let mut png = Vec::new();
if let Err(e) = image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) {
return json!({"ok": false, "error": e.to_string()});
}
match path {
Some(p) => match std::fs::write(p, &png) {
Ok(()) => json!({"ok": true, "result": {"path": p, "width": w, "height": h}}),
Err(e) => json!({"ok": false, "error": e.to_string()}),
},
None => json!({"ok": true, "result": {"base64": gridcraft_engine::io::base64_encode(&png), "width": w, "height": h}}),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Vec<Json> {
vec![
json!({"id": "home.borders", "label": "Borders", "menu": ["Home", "Font"]}),
json!({"id": "home.bold", "label": "Bold", "menu": ["Home", "Font"]}),
json!({"id": "ui.ribbonTab", "label": "Ribbon Tab", "enabled": true, "ui": true}),
]
}
fn ids(v: &[Json]) -> Vec<&str> {
v.iter().filter_map(|c| c.get("id").and_then(Json::as_str)).collect()
}
#[test]
fn search_filters_case_insensitively() {
assert_eq!(ids(&filter_commands(sample(), Some("border"))), ["home.borders"]);
assert_eq!(filter_commands(sample(), Some("BORDER")), filter_commands(sample(), Some("border")));
assert_eq!(ids(&filter_commands(sample(), Some("ribbon"))), ["ui.ribbonTab"]);
assert_eq!(ids(&filter_commands(sample(), Some("font"))), ["home.borders", "home.bold"]);
assert!(filter_commands(sample(), Some("catalog_no_match_7391")).is_empty());
assert_eq!(filter_commands(sample(), None).len(), 3);
}
#[test]
fn print_command_discovery_documents_settings_and_submission() {
let mut session = gridcraft_engine::Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, crate::Services::default());
let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search":"print"})) else {
panic!("command discovery must return immediately");
};
assert_eq!(reply["ok"], true);
let commands = reply["result"].as_array().unwrap();
for (id, expected_docs) in [
("ui.printSettings", &["copies", "duplex", "refresh", "discovering", "status", "detail"][..]),
("ui.printSubmit", &["file.print", "pagination", "pending", "without opening a dialog"][..]),
] {
let command = commands.iter().find(|command| command["id"] == id).unwrap();
assert_eq!(command["ui"], true);
let docs = command["params"].as_str().unwrap();
for expected in expected_docs {
assert!(docs.contains(expected), "{id}: missing {expected}");
}
}
assert!(!app.printing.busy(), "discovery must not submit a job");
assert!(app.backstage.is_none(), "discovery must not open settings");
}
}