mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-27 04:06:52 -06:00
Session keeps a window list (stable ids, per-window selection, active sheet and per-sheet selections swapped into the workbook without dirtying it) in place of the index-based hidden list and the copying New Window. Titles read Name:1 / Name:2 for a workbook with several windows. File > Close closes one window of several; window.close, window.list and window ids for window.activate, view.switchWindows, view.hideWindow and view.unhideWindow. Control channel and MCP tools take window/doc ids; scroll positions and cell drafts belong to their window.
105 lines
4.1 KiB
Rust
105 lines
4.1 KiB
Rust
//! An in-process engine session that answers the engine-level control-channel methods itself.
|
|
|
|
use gridcraft_engine::{Session, Target};
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::backend::Backend;
|
|
|
|
/// Error for methods that need a window.
|
|
pub(crate) const NO_UI: &str = "no UI in headless mode: start the app with `gridcraft --control 7979` and run the MCP server \
|
|
with `gridcraft-cli mcp --connect 7979`";
|
|
|
|
/// Headless backend: a [`Session`] with one blank workbook to start with.
|
|
pub struct Headless {
|
|
pub session: Session,
|
|
}
|
|
|
|
impl Default for Headless {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl Headless {
|
|
/// A session with a blank workbook (`Book1`) open.
|
|
pub fn new() -> Self {
|
|
let mut session = Session::new();
|
|
session.new_workbook();
|
|
Self { session }
|
|
}
|
|
|
|
/// A session wrapping an existing one (e.g. with a file already open).
|
|
pub fn with_session(session: Session) -> Self {
|
|
Self { session }
|
|
}
|
|
|
|
fn execute(&mut self, target: Target, id: &str, params: Value) -> Result<Value, String> {
|
|
// Programmatic calls never open dialogs; anything a command queued for a UI is dropped.
|
|
self.session.execute_headless_in(target, id, params).map_err(|e| e.to_string())
|
|
}
|
|
|
|
fn commands(&self, params: &Value) -> Value {
|
|
let q = params.get("search").and_then(Value::as_str).map(str::to_ascii_lowercase);
|
|
let list: Vec<Value> = self
|
|
.session
|
|
.commands()
|
|
.into_iter()
|
|
.filter(|c| {
|
|
q.as_ref().is_none_or(|q| {
|
|
c.id.to_ascii_lowercase().contains(q.as_str())
|
|
|| c.label.to_ascii_lowercase().contains(q.as_str())
|
|
|| c.menu.iter().any(|m| m.to_ascii_lowercase().contains(q.as_str()))
|
|
})
|
|
})
|
|
.map(|c| serde_json::to_value(c).unwrap_or_default())
|
|
.collect();
|
|
Value::Array(list)
|
|
}
|
|
}
|
|
|
|
impl Backend for Headless {
|
|
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
|
let params = if params.is_null() { json!({}) } else { params };
|
|
match method {
|
|
"ping" => Ok(json!("pong")),
|
|
"engine.execute" => {
|
|
let id = params.get("command").and_then(Value::as_str).ok_or("engine.execute: missing `command`")?.to_string();
|
|
let p = params.get("params").cloned().unwrap_or_else(|| json!({}));
|
|
if !(p.is_object() || p.is_null()) {
|
|
return Err(format!("engine.execute: `params` of `{id}` must be an object"));
|
|
}
|
|
let target = Target::from_json(¶ms).map_err(|e| e.to_string())?;
|
|
self.execute(target, &id, p)
|
|
}
|
|
"engine.commands" => Ok(self.commands(¶ms)),
|
|
"engine.journal" => Ok(Value::Array(self.session.journal.iter().map(|(id, p)| json!({"command": id, "params": p})).collect())),
|
|
m if m.starts_with("ui.") || m.starts_with("app.screenshot") => Err(format!("{m}: {NO_UI}")),
|
|
// Read-only engine methods (`document.inspect`, `sheet.read`, `cell.get`, …) are
|
|
// commands with the same id.
|
|
// Their params may name a `window` or `doc` to act in, except for the window commands,
|
|
// whose own params those are.
|
|
m if gridcraft_engine::find_command(m).is_some() => {
|
|
if gridcraft_engine::windows::is_window_command(m) {
|
|
return self.execute(Target::Active, m, params);
|
|
}
|
|
let target = Target::from_json(¶ms).map_err(|e| e.to_string())?;
|
|
let mut params = params;
|
|
if let Some(o) = params.as_object_mut() {
|
|
o.remove("window");
|
|
o.remove("doc");
|
|
}
|
|
self.execute(target, m, params)
|
|
}
|
|
other => Err(format!("unknown method `{other}`")),
|
|
}
|
|
}
|
|
|
|
fn has_ui(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn describe(&self) -> String {
|
|
"headless (in-process engine, no window)".to_string()
|
|
}
|
|
}
|