mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 10:20:32 -06:00
370 lines
18 KiB
Rust
370 lines
18 KiB
Rust
use super::screenshot::Geometry;
|
|
use super::{Action, Control, Host, NodeId, Reply, Value, accesskit, json};
|
|
|
|
impl Control {
|
|
/// Validate and dispatch one control method, rejecting unknown methods and malformed parameters with
|
|
/// actionable errors.
|
|
pub(super) fn handle(&mut self, ctx: &egui::Context, host: &mut impl Host, method: &str, p: &Value, since: web_time::Instant) -> Handled {
|
|
let str_param = |k: &str| p.get(k).and_then(Value::as_str).ok_or_else(|| format!("{method}: missing string parameter {k}"));
|
|
let r: Result<Handled, String> = (|| match method {
|
|
"ui.state" => {
|
|
let mut state = host.state();
|
|
// Viewport info is fresh in `logic`; the last UI pass's rectangle can be stale.
|
|
let geometry = Geometry::current(ctx, host.capture_epoch());
|
|
let size = geometry.size;
|
|
state["window"] = json!({ "width": size.x, "height": size.y, "pixels_per_point": geometry.pixels_per_point });
|
|
Ok(Handled::Now(Ok(state)))
|
|
}
|
|
"ui.commands" => Ok(Handled::Now(Ok(host.commands()))),
|
|
"ui.panel" | "ui.dock.reset" => Ok(Handled::Now(host.panel(method == "ui.dock.reset", p))),
|
|
"ui.command" if str_param("id")? == "ui.panel" => Ok(Handled::Now(host.panel(false, p.get("params").unwrap_or(p)))),
|
|
"ui.command" => Ok(Handled::Now(host.command(str_param("id")?))),
|
|
"ui.set" => {
|
|
let value = match p.get("value") {
|
|
Some(Value::String(s)) => s.clone(),
|
|
Some(v) => v.to_string(),
|
|
None => return Err("ui.set: missing parameter value".into()),
|
|
};
|
|
Ok(Handled::Now(host.set(str_param("key")?, &value)))
|
|
}
|
|
"ui.open" => Ok(Handled::Now(host.open(str_param("path")?))),
|
|
"ui.inspect" => Ok(Handled::Now(Ok(self.inspect(p)))),
|
|
"ui.click" => self.click(p, since),
|
|
"ui.focus" => self.focus(p, since),
|
|
"ui.resize" => {
|
|
only_params(p, method, &["width", "height"])?;
|
|
let dimension = |key: &str| -> Result<f32, String> {
|
|
p.get(key)
|
|
.and_then(Value::as_f64)
|
|
.filter(|n| n.is_finite() && (320.0..=8192.0).contains(n))
|
|
.map(|n| n as f32)
|
|
.ok_or_else(|| format!("ui.resize: {key} must be between 320 and 8192 logical points"))
|
|
};
|
|
let size = egui::vec2(dimension("width")?, dimension("height")?);
|
|
if cfg!(target_arch = "wasm32") {
|
|
return Err("ui.resize needs a native window".into());
|
|
}
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
|
|
ctx.request_repaint();
|
|
Ok(Handled::AfterFrames(2, json!({ "requested": [size.x, size.y] })))
|
|
}
|
|
"ui.quit" => {
|
|
only_params(p, method, &[])?;
|
|
if cfg!(target_arch = "wasm32") {
|
|
return Err("ui.quit needs a native window".into());
|
|
}
|
|
let quitting = host.quit(ctx);
|
|
Ok(Handled::Now(Ok(json!({ "quitting": quitting, "needs_confirmation": !quitting }))))
|
|
}
|
|
"ui.drag" => self.drag(p, since),
|
|
"ui.move" => {
|
|
let point = |key: &str| -> Result<f32, String> {
|
|
p.get(key)
|
|
.and_then(Value::as_f64)
|
|
.map(|n| n as f32)
|
|
.filter(|n| n.is_finite())
|
|
.ok_or_else(|| format!("ui.move: {key} must be a finite coordinate in points"))
|
|
};
|
|
let pos = egui::pos2(point("x")?, point("y")?);
|
|
self.after_input(vec![vec![egui::Event::PointerMoved(pos)]], json!({ "moved": [pos.x, pos.y] }), since)
|
|
}
|
|
"ui.type" => {
|
|
let text = str_param("text")?.to_string();
|
|
self.after_input(vec![vec![egui::Event::Text(text)]], json!({ "typed": true }), since)
|
|
}
|
|
"ui.key" => {
|
|
let name = str_param("key")?;
|
|
let key = egui::Key::from_name(name)
|
|
.ok_or_else(|| format!("ui.key: unknown key {name:?} (egui key names: A, Enter, Escape, ArrowDown, F5, …)"))?;
|
|
let modifiers = modifiers(p.get("modifiers"))?;
|
|
self.after_input(key_batches(key, modifiers), json!({ "key": name }), since)
|
|
}
|
|
"ui.screenshot" => {
|
|
let region = match p.get("region") {
|
|
None | Some(Value::Null) => None,
|
|
Some(r) => Some(rect_param(r)?),
|
|
};
|
|
ctx.request_repaint();
|
|
Ok(Handled::Screenshot(region))
|
|
}
|
|
other => Err(format!(
|
|
"unknown method {other:?} (ui.state, ui.inspect, ui.click, ui.focus, ui.resize, ui.quit, ui.move, ui.drag, ui.type, ui.key, ui.command, ui.commands, ui.set, ui.open, ui.screenshot)"
|
|
)),
|
|
})();
|
|
r.unwrap_or_else(|e| Handled::Now(Err(e)))
|
|
}
|
|
|
|
fn inspect(&self, p: &Value) -> Value {
|
|
let query = p.get("query").and_then(Value::as_str).map(str::to_lowercase);
|
|
let role = p.get("role").and_then(Value::as_str).map(str::to_lowercase);
|
|
let focused = p.get("focused").and_then(Value::as_bool);
|
|
let limit = p.get("limit").and_then(Value::as_u64).unwrap_or(500) as usize;
|
|
let Ok(s) = self.shared.lock() else { return json!({ "widgets": [] }) };
|
|
let mut out = Vec::new();
|
|
let mut total = 0usize;
|
|
let mut stack: Vec<(NodeId, usize)> = s.root.map(|r| vec![(r, 0)]).unwrap_or_default();
|
|
while let Some((id, depth)) = stack.pop() {
|
|
let Some(node) = s.nodes.get(&id) else { continue };
|
|
for c in node.children().iter().rev() {
|
|
stack.push((*c, depth + 1));
|
|
}
|
|
let w = widget(id, node, depth, s.focus == Some(id));
|
|
let text =
|
|
format!("{} {} {}", w["label"].as_str().unwrap_or(""), w["value"].as_str().unwrap_or(""), w["description"].as_str().unwrap_or(""))
|
|
.to_lowercase();
|
|
let role_ok = role.as_ref().is_none_or(|r| w["role"].as_str().is_some_and(|x| x.to_lowercase() == *r));
|
|
let query_ok = query.as_ref().is_none_or(|q| text.contains(q.as_str()));
|
|
let focus_ok = focused.is_none_or(|want| (s.focus == Some(id)) == want);
|
|
if role_ok && query_ok && focus_ok && id != s.root.unwrap_or(id) {
|
|
total += 1;
|
|
if out.len() < limit {
|
|
out.push(w);
|
|
}
|
|
}
|
|
}
|
|
json!({ "widgets": out, "count": total, "truncated": total > out.len() })
|
|
}
|
|
|
|
/// Press at `from`, move to `to` in `steps` frames, release (drawing, selecting text, moving).
|
|
fn drag(&mut self, p: &Value, since: web_time::Instant) -> Result<Handled, String> {
|
|
let point = |k: &str| -> Result<egui::Pos2, String> {
|
|
match p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect::<Vec<_>>()).as_deref() {
|
|
Some([x, y]) => Some(egui::pos2(*x as f32, *y as f32))
|
|
.filter(|pos| pos.is_finite())
|
|
.ok_or_else(|| format!("ui.drag: {k} must be [x, y], finite coordinates in points")),
|
|
_ => Err(format!("ui.drag: {k} must be [x, y] in points")),
|
|
}
|
|
};
|
|
let (from, to) = (point("from")?, point("to")?);
|
|
if !(to - from).is_finite() {
|
|
return Err("ui.drag: from and to must be a finite distance apart, in points".into());
|
|
}
|
|
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200) as usize;
|
|
let modifiers = modifiers(p.get("modifiers"))?;
|
|
let which = pointer_button(p, "ui.drag")?;
|
|
// The events carry the modifiers and so does the input state: gestures that read
|
|
// `ui.input(|i| i.modifiers)` (a Ctrl-drag copy, an Alt-drag column select) see them.
|
|
let held = |mut events: Vec<egui::Event>| {
|
|
if modifiers != egui::Modifiers::NONE {
|
|
events.insert(0, egui::Event::ModifiersChanged(modifiers));
|
|
}
|
|
events
|
|
};
|
|
let button = |pos, pressed| egui::Event::PointerButton { pos, button: which, pressed, modifiers };
|
|
let mut frames = vec![held(vec![egui::Event::PointerMoved(from)]), held(vec![button(from, true)])];
|
|
for k in 1..=steps {
|
|
frames.push(held(vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))]));
|
|
}
|
|
frames.push(held(vec![button(to, false)]));
|
|
if modifiers != egui::Modifiers::NONE {
|
|
frames.push(vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]);
|
|
}
|
|
self.after_input(frames, json!({ "dragged": [[from.x, from.y], [to.x, to.y]] }), since)
|
|
}
|
|
|
|
/// Click at a point, or on an accesskit widget by id or unique enabled label.
|
|
fn click(&mut self, p: &Value, since: web_time::Instant) -> Result<Handled, String> {
|
|
if let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
|
let point = |key: &str, n: f64| {
|
|
Some(n as f32).filter(|n| n.is_finite()).ok_or_else(|| format!("ui.click: {key} must be a finite coordinate in points"))
|
|
};
|
|
let pos = egui::pos2(point("x", x)?, point("y", y)?);
|
|
let which = pointer_button(p, "ui.click")?;
|
|
let button = |pressed| egui::Event::PointerButton { pos, button: which, pressed, modifiers: egui::Modifiers::NONE };
|
|
// Keep hover/layout warmup, then make an instantaneous click even on slow frames.
|
|
let frames = vec![vec![egui::Event::PointerMoved(pos)], vec![button(true), button(false)]];
|
|
return self.after_input(frames, json!({ "clicked": [x, y] }), since);
|
|
}
|
|
let s = self.shared.lock().map_err(|_| "control state poisoned")?;
|
|
let id = match (p.get("id"), p.get("label").and_then(Value::as_str)) {
|
|
(Some(id), _) => {
|
|
let id: u64 =
|
|
id.as_u64().or_else(|| id.as_str().and_then(|s| s.parse().ok())).ok_or("ui.click: id must be a widget id from ui.inspect")?;
|
|
let id = NodeId(id);
|
|
if !s.nodes.contains_key(&id) {
|
|
return Err(format!("ui.click: no widget with id {} on screen (run ui.inspect again)", id.0));
|
|
}
|
|
id
|
|
}
|
|
(None, Some(label)) => {
|
|
let want = label.to_lowercase();
|
|
let matches: Vec<NodeId> = s
|
|
.nodes
|
|
.iter()
|
|
.filter(|(_, n)| n.supports_action(Action::Click) && !n.is_disabled() && n.label().is_some_and(|l| l.to_lowercase() == want))
|
|
.map(|(id, _)| *id)
|
|
.collect();
|
|
match matches.as_slice() {
|
|
[one] => *one,
|
|
[] => return Err(format!("ui.click: no enabled clickable widget labelled {label:?} (try ui.inspect with query)")),
|
|
many => return Err(format!("ui.click: {} widgets are labelled {label:?}; click one by id", many.len())),
|
|
}
|
|
}
|
|
(None, None) => return Err("ui.click: pass id, label, or x and y".into()),
|
|
};
|
|
let node = &s.nodes[&id];
|
|
if node.is_disabled() {
|
|
return Err(format!("ui.click: widget {} ({}) is disabled", id.0, node.label().unwrap_or("unlabelled")));
|
|
}
|
|
let label = node.label().map(str::to_owned);
|
|
let action = accesskit::ActionRequest { action: Action::Click, target_tree: accesskit::TreeId::ROOT, target_node: id, data: None };
|
|
drop(s);
|
|
let frames = vec![vec![egui::Event::AccessKitActionRequest(action)]];
|
|
self.after_input(frames, json!({ "clicked": id.0.to_string(), "label": label }), since)
|
|
}
|
|
|
|
/// Focus an accesskit widget by id or unique enabled label.
|
|
fn focus(&mut self, p: &Value, since: web_time::Instant) -> Result<Handled, String> {
|
|
only_params(p, "ui.focus", &["id", "label"])?;
|
|
let s = self.shared.lock().map_err(|_| "control state poisoned")?;
|
|
let id = match (p.get("id"), p.get("label")) {
|
|
(Some(id), None) => {
|
|
NodeId(id.as_u64().or_else(|| id.as_str().and_then(|s| s.parse().ok())).ok_or("ui.focus: id must be a widget id from ui.inspect")?)
|
|
}
|
|
(None, Some(Value::String(label))) => {
|
|
let matches: Vec<NodeId> = s
|
|
.nodes
|
|
.iter()
|
|
.filter(|(_, n)| n.supports_action(Action::Focus) && !n.is_disabled() && n.label().is_some_and(|l| l.eq_ignore_ascii_case(label)))
|
|
.map(|(id, _)| *id)
|
|
.collect();
|
|
match matches.as_slice() {
|
|
[one] => *one,
|
|
[] => return Err(format!("ui.focus: no enabled focusable widget labelled {label:?}")),
|
|
many => return Err(format!("ui.focus: {} widgets are labelled {label:?}; use a widget id", many.len())),
|
|
}
|
|
}
|
|
_ => return Err("ui.focus: pass exactly one widget id or label".into()),
|
|
};
|
|
let node = s.nodes.get(&id).ok_or_else(|| format!("ui.focus: no widget with id {} on screen (run ui.inspect again)", id.0))?;
|
|
if node.is_disabled() || !node.supports_action(Action::Focus) {
|
|
return Err(format!("ui.focus: widget {} is disabled or not focusable", id.0));
|
|
}
|
|
let action = accesskit::ActionRequest { action: Action::Focus, target_tree: accesskit::TreeId::ROOT, target_node: id, data: None };
|
|
drop(s);
|
|
let frames = vec![vec![egui::Event::AccessKitActionRequest(action)]];
|
|
self.after_input(frames, json!({ "focus_requested": id.0.to_string() }), since)
|
|
}
|
|
}
|
|
|
|
pub(super) enum Handled {
|
|
Now(Reply),
|
|
/// Answer after this many accepted UI passes, without injecting input.
|
|
AfterFrames(u32, Value),
|
|
AfterInput {
|
|
batches: Vec<u64>,
|
|
frames: u32,
|
|
value: Value,
|
|
},
|
|
Screenshot(Option<egui::Rect>),
|
|
}
|
|
|
|
fn widget(id: NodeId, n: &accesskit::Node, depth: usize, focused: bool) -> Value {
|
|
let rect = n.bounds().map(|b| [b.x0, b.y0, b.x1, b.y1]);
|
|
let mut w = json!({
|
|
// A string: 64-bit ids don't survive JSON parsers that use doubles.
|
|
"id": id.0.to_string(),
|
|
"role": format!("{:?}", n.role()),
|
|
"depth": depth,
|
|
"enabled": !n.is_disabled(),
|
|
"clickable": n.supports_action(Action::Click),
|
|
"focusable": n.supports_action(Action::Focus),
|
|
});
|
|
let mut more = serde_json::Map::new();
|
|
let obj = &mut more;
|
|
if let Some(l) = n.label() {
|
|
obj.insert("label".into(), json!(l));
|
|
}
|
|
if let Some(v) = n.value() {
|
|
obj.insert("value".into(), json!(v));
|
|
}
|
|
if let Some(d) = n.description() {
|
|
obj.insert("description".into(), json!(d));
|
|
}
|
|
if let Some(r) = rect {
|
|
obj.insert("rect".into(), json!(r));
|
|
}
|
|
if let Some(t) = n.toggled() {
|
|
obj.insert("toggled".into(), json!(format!("{t:?}").to_lowercase()));
|
|
}
|
|
if let Some(s) = n.is_selected() {
|
|
obj.insert("selected".into(), json!(s));
|
|
}
|
|
if focused {
|
|
obj.insert("focused".into(), json!(true));
|
|
}
|
|
if let Value::Object(m) = &mut w {
|
|
m.extend(more);
|
|
}
|
|
w
|
|
}
|
|
|
|
fn only_params(p: &Value, method: &str, allowed: &[&str]) -> Result<(), String> {
|
|
// The JSON-RPC transport represents omitted params as null.
|
|
if p.is_null() && allowed.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let object = p.as_object().ok_or_else(|| format!("{method}: params must be an object"))?;
|
|
if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) {
|
|
return Err(format!("{method}: unknown parameter {key:?}"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn pointer_button(p: &Value, method: &str) -> Result<egui::PointerButton, String> {
|
|
match p.get("button").and_then(Value::as_str).unwrap_or("primary") {
|
|
"primary" | "left" => Ok(egui::PointerButton::Primary),
|
|
"secondary" | "right" => Ok(egui::PointerButton::Secondary),
|
|
"middle" => Ok(egui::PointerButton::Middle),
|
|
other => Err(format!("{method}: unknown button {other:?} (primary, secondary, middle)")),
|
|
}
|
|
}
|
|
|
|
/// The events for one synthetic key press. egui updates `InputState.modifiers` only from
|
|
/// `ModifiersChanged`, not from the `modifiers` field on a key event, so the held set goes in
|
|
/// before the press and is released after the key-up. Without this, handlers that read
|
|
/// `i.modifiers` (Shift+Tab, ⌘Enter) would see stale state.
|
|
pub(super) fn key_batches(key: egui::Key, modifiers: egui::Modifiers) -> Vec<Vec<egui::Event>> {
|
|
let key_event = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers };
|
|
let mut press = vec![key_event(true)];
|
|
if modifiers != egui::Modifiers::NONE {
|
|
press.insert(0, egui::Event::ModifiersChanged(modifiers));
|
|
}
|
|
let mut release = vec![key_event(false)];
|
|
if modifiers != egui::Modifiers::NONE {
|
|
release.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
|
}
|
|
vec![press, release]
|
|
}
|
|
|
|
fn modifiers(v: Option<&Value>) -> Result<egui::Modifiers, String> {
|
|
let mut m = egui::Modifiers::NONE;
|
|
for name in v.and_then(Value::as_array).into_iter().flatten() {
|
|
match name.as_str().unwrap_or("") {
|
|
"command" | "cmd" => m.command = true,
|
|
"shift" => m.shift = true,
|
|
"alt" | "option" => m.alt = true,
|
|
"ctrl" | "control" => m.ctrl = true,
|
|
"mac_cmd" => m.mac_cmd = true,
|
|
other => return Err(format!("ui.key: unknown modifier {other:?} (command, shift, alt, ctrl)")),
|
|
}
|
|
}
|
|
// "command" is ⌘ on macOS and Ctrl elsewhere, as egui expects.
|
|
if m.command {
|
|
if cfg!(target_os = "macos") {
|
|
m.mac_cmd = true;
|
|
} else {
|
|
m.ctrl = true;
|
|
}
|
|
}
|
|
Ok(m)
|
|
}
|
|
|
|
fn rect_param(v: &Value) -> Result<egui::Rect, String> {
|
|
let a: Vec<f32> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).map(|f| f as f32).collect()).unwrap_or_default();
|
|
match a.as_slice() {
|
|
[x0, y0, x1, y1] if x1 > x0 && y1 > y0 => Ok(egui::Rect::from_min_max(egui::pos2(*x0, *y0), egui::pos2(*x1, *y1))),
|
|
_ => Err("region must be [x0, y0, x1, y1] in points".into()),
|
|
}
|
|
}
|