mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 12:26:52 -06:00
Found and fixed in a Windows pass of everyday workflows: unsaved documents are now copied in the background (at most every 10 s) and offered back after a crash or forced quit (file.recover); Ctrl+arrow / Ctrl+Backspace / Ctrl+Delete text motion follows Windows and Linux conventions (keymap::text_motion); number-field scrubbing keeps its value; typing in a Tab-focused field works; default linear gradients and their handles behave; Escape closes dialogs without deselecting; clipped panel hints wrap; the unsaved-changes dialog is translated.
615 lines
32 KiB
Rust
615 lines
32 KiB
Rust
//! The control channel's UI-thread side. The app crate accepts JSON lines on a localhost socket
|
||
//! and queues [`ControlRequest`]s; [`drain`] runs them at the start of a frame so replies reflect
|
||
//! the real UI. Methods:
|
||
//!
|
||
//! - `engine.execute {command, params}` — run any command (no dialogs open)
|
||
//! - `engine.commands {}` — every command with its params doc
|
||
//! - `document.inspect {depth?, allPages?}` / `node.get {id, depth?}`
|
||
//! - `ui.inspect {}` — panel/tab/tool state, canvas rect, timings
|
||
//! - `ui.pointer {kind: down|move|up|click|double|hover|drag, x, y (page) | sx, sy (screen), to?, shift?, alt?, cmd?}`
|
||
//! - `ui.key {key, shift?, cmd?, alt?}` — a shortcut as if typed on the canvas
|
||
//! - `ui.type {text}` — type into the text being edited
|
||
//! - `ui.input {events: [{move|down|up: [x, y], button?} | {key, shift?, alt?, cmd?} | {text} | {leave}
|
||
//! | {hold: {alt?, shift?, cmd?}}]}` — raw pointer and key events for the next frame, as if from the
|
||
//! window (panels, popovers); `hold` keeps modifier keys down for the frames that follow (all
|
||
//! false: released)
|
||
//! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog)
|
||
//! - `ui.set {rightTab?, leftTab?, theme?, home?, palette?, focusRegion?, preview?, presentScroll?, …}`
|
||
//! - `ui.screenshot {path}` — PNG of the window (needs a visible window)
|
||
//! - `ui.render {path, scale?, ids?}` — PNG of the document (no UI chrome; works without a window)
|
||
//! - `window.new {path?, index?}` → `{window}` / `window.list {}` / `window.focus {id}` — several
|
||
//! windows (desktop)
|
||
//! - `app.quit {}`
|
||
//!
|
||
//! Any method takes `window` (an id from `window.list`) to run in that window, with its tabs and
|
||
//! panels; without it, requests go to the window last in front.
|
||
|
||
use egui::Context;
|
||
use figcraft_engine::cmd::comment::{Filter, Sort};
|
||
use figcraft_engine::{PointerEvent, PointerKind};
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::FigApp;
|
||
|
||
pub struct ControlReply {
|
||
pub id: Value,
|
||
pub result: Result<Value, String>,
|
||
}
|
||
|
||
pub struct ControlRequest {
|
||
pub id: Value,
|
||
pub method: String,
|
||
pub params: Value,
|
||
pub reply: std::sync::mpsc::Sender<ControlReply>,
|
||
}
|
||
|
||
/// Pending screenshot replies.
|
||
pub struct PendingShot {
|
||
pub id: Value,
|
||
pub path: String,
|
||
pub reply: std::sync::mpsc::Sender<ControlReply>,
|
||
/// When it was asked for (UI clock, seconds).
|
||
pub since: f64,
|
||
/// The window it captures.
|
||
pub viewport: egui::ViewportId,
|
||
}
|
||
|
||
/// How long a screenshot waits for the window to deliver it (the control server gives up then too).
|
||
const SHOT_TIMEOUT: f64 = 60.0;
|
||
|
||
fn pending(ctx: &Context) -> std::sync::Arc<std::sync::Mutex<Vec<PendingShot>>> {
|
||
ctx.data_mut(|d| d.get_temp_mut_or_insert_with(egui::Id::new("pending_shots"), || std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))).clone())
|
||
}
|
||
|
||
pub fn drain(app: &mut FigApp, ctx: &Context) {
|
||
let now = ctx.input(|i| i.time);
|
||
let list = pending(ctx);
|
||
// Finish screenshots that arrived (a window's capture can arrive in any window's input).
|
||
let shots: Vec<(egui::ViewportId, std::sync::Arc<egui::ColorImage>)> = ctx.input(|i| {
|
||
i.events
|
||
.iter()
|
||
.filter_map(|e| match e {
|
||
egui::Event::Screenshot { viewport_id, image, .. } => Some((*viewport_id, image.clone())),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
});
|
||
for (vp, img) in &shots {
|
||
list.lock().unwrap_or_else(std::sync::PoisonError::into_inner).retain(|p| {
|
||
if p.viewport != *vp {
|
||
return true;
|
||
}
|
||
let res = save_color_image(img, &p.path).map(|_| json!({"path": p.path, "width": img.size[0], "height": img.size[1]}));
|
||
let _ = p.reply.send(ControlReply { id: p.id.clone(), result: res });
|
||
false
|
||
});
|
||
}
|
||
let reqs = std::mem::take(&mut app.control_queue);
|
||
for r in reqs {
|
||
// Requests for another window wait for its frame.
|
||
let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused);
|
||
if target != app.windows.current {
|
||
if app.windows.exists(target) {
|
||
ctx.request_repaint_of(crate::windows::viewport(target));
|
||
app.control_queue.push(r);
|
||
} else {
|
||
let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) });
|
||
}
|
||
continue;
|
||
}
|
||
if r.method == "ui.screenshot" {
|
||
let path = r.params.get("path").and_then(Value::as_str).unwrap_or("figcraft-screenshot.png").to_string();
|
||
let viewport = ctx.viewport_id();
|
||
list.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(PendingShot { id: r.id, path, reply: r.reply, since: now, viewport });
|
||
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
|
||
continue;
|
||
}
|
||
let res = handle(app, ctx, &r.method, &r.params);
|
||
let _ = r.reply.send(ControlReply { id: r.id, result: res });
|
||
ctx.request_repaint();
|
||
}
|
||
// The window delivers a screenshot some frames after it was asked for (once the GPU has copied
|
||
// it back), and only while frames are drawn: keep drawing until it arrives (a hidden window
|
||
// never delivers; its requests expire).
|
||
let mut list = list.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||
list.retain(|p| {
|
||
let live = now - p.since < SHOT_TIMEOUT;
|
||
if !live {
|
||
let _ = p.reply.send(ControlReply { id: p.id.clone(), result: Err("the window didn't deliver a screenshot (is it visible?)".into()) });
|
||
}
|
||
live
|
||
});
|
||
if !list.is_empty() {
|
||
ctx.request_repaint();
|
||
}
|
||
}
|
||
|
||
fn save_color_image(img: &egui::ColorImage, path: &str) -> Result<(), String> {
|
||
let [w, h] = img.size;
|
||
let mut rgba = Vec::with_capacity(w * h * 4);
|
||
for p in &img.pixels {
|
||
rgba.extend_from_slice(&p.to_srgba_unmultiplied());
|
||
}
|
||
let buf = image::RgbaImage::from_raw(w as u32, h as u32, rgba).ok_or("bad screenshot buffer")?;
|
||
buf.save(path).map_err(|e| e.to_string())
|
||
}
|
||
|
||
fn b(p: &Value, k: &str) -> bool {
|
||
p.get(k).and_then(Value::as_bool).unwrap_or(false)
|
||
}
|
||
|
||
/// Handle one control method (also used by tests and the headless snapshot tool).
|
||
pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Result<Value, String> {
|
||
match method {
|
||
"engine.execute" => {
|
||
let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?;
|
||
let params = p.get("params").cloned().unwrap_or(json!({}));
|
||
let r = app.session.execute(cmd, ¶ms).map_err(|e| e.to_string());
|
||
// Programmatic calls never open dialogs.
|
||
app.session.ui_requests.retain(|u| !u.kind.starts_with("dialog."));
|
||
app.handle_ui_requests();
|
||
r
|
||
}
|
||
"engine.commands" => Ok(Value::Array(
|
||
figcraft_engine::all_commands()
|
||
.iter()
|
||
.map(|c| json!({"id": c.id, "label": c.label, "params": c.params, "menu": c.menu(), "shortcut": c.shortcut(), "undoable": c.history}))
|
||
.collect(),
|
||
)),
|
||
"document.inspect" => {
|
||
let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8) as usize;
|
||
Ok(figcraft_engine::inspect::document(&app.session, depth, b(p, "allPages")))
|
||
}
|
||
"node.get" => {
|
||
let id = p.get("id").and_then(figcraft_engine::params::id_of).ok_or("`id` required")?;
|
||
let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(1) as usize;
|
||
app.session.doc().map(|d| figcraft_engine::inspect::node_json(d, id, depth)).map_err(|e| e.to_string())
|
||
}
|
||
"window.new" => {
|
||
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
|
||
crate::windows::open(app, ctx, index, p.get("path").and_then(Value::as_str), None).map(|id| json!({"window": id}))
|
||
}
|
||
"window.list" => Ok(crate::windows::list(app)),
|
||
"window.focus" => crate::windows::focus(app, ctx, p.get("id").and_then(Value::as_u64).ok_or("`id` required")?),
|
||
"ui.inspect" => {
|
||
let mut v = app.inspect();
|
||
// The name of the control with the keyboard (what a screen reader announces).
|
||
if let Some(f) = v.get_mut("focus") {
|
||
f["control"] = json!(crate::a11y::focused_name(ctx));
|
||
}
|
||
Ok(v)
|
||
}
|
||
"ui.pointer" => pointer(app, p),
|
||
"ui.key" => {
|
||
let key = p.get("key").and_then(Value::as_str).ok_or("`key` required")?;
|
||
let m = figcraft_engine::keymap::Mods { shift: b(p, "shift"), alt: b(p, "alt"), cmd: b(p, "cmd"), ctrl: b(p, "ctrl") };
|
||
let platform = crate::shortcuts::platform();
|
||
use figcraft_engine::keymap::{Context, lookup, normalize};
|
||
// Text editing keys go to the editor (after the shortcuts live while editing).
|
||
if app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false) {
|
||
if let Some(bd) = lookup(platform, Context::Text, key, m).filter(|_| m.cmd || m.alt || m.ctrl) {
|
||
crate::menus::invoke(app, bd.id);
|
||
return Ok(json!({"command": bd.id}));
|
||
}
|
||
let (word, line) = figcraft_engine::keymap::text_motion(platform, key, m);
|
||
figcraft_engine::textedit::key(&mut app.session, key, m.shift, word, line).map_err(|e| e.to_string())?;
|
||
return Ok(json!({"ok": true}));
|
||
}
|
||
// Vector edit mode and shape builder keys (tools, nudging points, delete, join, Enter).
|
||
if app.session.tool_key(key, m.shift, m.alt, m.cmd).map_err(|e| e.to_string())? {
|
||
return Ok(json!({"ok": true}));
|
||
}
|
||
// The Comment tool's arrows step through the comment threads.
|
||
if app.session.tool.tool == figcraft_engine::ToolId::Comment && m.is_none() && matches!(key, "ArrowUp" | "ArrowDown" | "Up" | "Down") {
|
||
let r = crate::comments::step(app, ctx, if matches!(key, "ArrowUp" | "Up") { -1 } else { 1 });
|
||
return Ok(json!({"comment": r.and_then(|v| v.get("id").cloned())}));
|
||
}
|
||
if let Some(bd) = lookup(platform, Context::Canvas, key, m) {
|
||
crate::shortcuts::run_binding(app, bd.id);
|
||
return Ok(json!({"command": bd.id}));
|
||
}
|
||
let (name, _) = normalize(key).ok_or_else(|| format!("unknown key `{key}`"))?;
|
||
match name {
|
||
"ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" => {
|
||
let (dx, dy) = match name {
|
||
"ArrowLeft" => (-1, 0),
|
||
"ArrowRight" => (1, 0),
|
||
"ArrowUp" => (0, -1),
|
||
_ => (0, 1),
|
||
};
|
||
let (cmd, params) = if m.cmd || m.ctrl {
|
||
("transform.nudgeSize", json!({"dw": dx, "dh": dy, "big": m.shift}))
|
||
} else {
|
||
("transform.nudge", json!({"dx": dx, "dy": dy, "big": m.shift}))
|
||
};
|
||
app.session.execute(cmd, ¶ms).map_err(|e| e.to_string())
|
||
}
|
||
_ => {
|
||
// Number keys: opacity (two in quick succession make an exact percentage).
|
||
let now = ctx.input(|i| i.time);
|
||
match egui::Key::from_name(name) {
|
||
Some(k) if m.is_none() && crate::shortcuts::opacity_digit(app, k, now) => Ok(json!({"opacity": true})),
|
||
_ => Err(format!("no shortcut `{key}`")),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
"ui.input" => {
|
||
let list = p
|
||
.get("events")
|
||
.and_then(Value::as_array)
|
||
.ok_or("`events` required: [{move|down|up: [x, y]} | {key, shift?, alt?, cmd?} | {text} | {leave: true}]")?;
|
||
let pos = |v: &Value| -> Option<egui::Pos2> {
|
||
let a = v.as_array()?;
|
||
Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32))
|
||
};
|
||
let mut events = Vec::new();
|
||
for e in list.iter().take(256) {
|
||
let button = if e.get("button").and_then(Value::as_str) == Some("secondary") {
|
||
egui::PointerButton::Secondary
|
||
} else {
|
||
egui::PointerButton::Primary
|
||
};
|
||
let command = b(e, "cmd") || b(e, "ctrl");
|
||
let mods = egui::Modifiers { alt: b(e, "alt"), ctrl: command, shift: b(e, "shift"), mac_cmd: false, command };
|
||
if let Some(p) = e.get("move").and_then(pos) {
|
||
events.push(egui::Event::PointerMoved(p));
|
||
} else if let Some((p, pressed)) =
|
||
e.get("down").and_then(pos).map(|p| (p, true)).or_else(|| e.get("up").and_then(pos).map(|p| (p, false)))
|
||
{
|
||
events.push(egui::Event::PointerMoved(p));
|
||
events.push(egui::Event::PointerButton { pos: p, button, pressed, modifiers: mods });
|
||
} else if let Some(k) = e.get("key").and_then(Value::as_str) {
|
||
let key = egui::Key::from_name(k).ok_or_else(|| format!("unknown key `{k}`"))?;
|
||
for pressed in [true, false] {
|
||
events.push(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: mods });
|
||
}
|
||
} else if let Some(t) = e.get("text").and_then(Value::as_str) {
|
||
events.push(egui::Event::Text(t.to_string()));
|
||
} else if let Some(d) = e.get("scroll").and_then(pos) {
|
||
// Content moves by `d` points (negative y scrolls down, like a wheel turned towards you).
|
||
events.push(egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: d.to_vec2(),
|
||
phase: egui::TouchPhase::Move,
|
||
modifiers: mods,
|
||
});
|
||
} else if b(e, "leave") {
|
||
events.push(egui::Event::PointerGone);
|
||
} else if let Some(h) = e.get("hold") {
|
||
let held = egui::Modifiers {
|
||
alt: b(h, "alt"),
|
||
ctrl: b(h, "cmd") || b(h, "ctrl"),
|
||
shift: b(h, "shift"),
|
||
mac_cmd: false,
|
||
command: b(h, "cmd") || b(h, "ctrl"),
|
||
};
|
||
events.push(egui::Event::ModifiersChanged(held));
|
||
} else {
|
||
return Err(format!("unknown input event {e}"));
|
||
}
|
||
}
|
||
app.control_input.extend(events);
|
||
ctx.request_repaint();
|
||
Ok(json!({"queued": list.len().min(256)}))
|
||
}
|
||
"ui.type" => {
|
||
let text = p.get("text").and_then(Value::as_str).ok_or("`text` required")?;
|
||
figcraft_engine::textedit::insert(&mut app.session, text).map_err(|e| e.to_string())?;
|
||
Ok(json!({"ok": true}))
|
||
}
|
||
"ui.menu.invoke" => {
|
||
let id = p.get("id").and_then(Value::as_str).ok_or("`id` required")?;
|
||
crate::menus::invoke(app, id);
|
||
Ok(json!({"ok": true, "lastError": app.ui.last_error}))
|
||
}
|
||
"ui.set" => {
|
||
if let Some(t) = p.get("rightTab").and_then(Value::as_str) {
|
||
app.ui.right_tab = match t {
|
||
"prototype" => crate::RightTab::Prototype,
|
||
"inspect" => crate::RightTab::Inspect,
|
||
_ => crate::RightTab::Design,
|
||
};
|
||
}
|
||
if let Some(t) = p.get("leftTab").and_then(Value::as_str) {
|
||
app.ui.left_tab = if t == "assets" { crate::LeftTab::Assets } else { crate::LeftTab::File };
|
||
}
|
||
if let Some(t) = p.get("theme").and_then(Value::as_str) {
|
||
app.session.prefs.theme = t.to_string();
|
||
}
|
||
if let Some(h) = p.get("home").and_then(Value::as_bool) {
|
||
app.ui.home = h;
|
||
}
|
||
if let Some(h) = p.get("palette").and_then(Value::as_bool) {
|
||
app.ui.palette = if h { Some(String::new()) } else { None };
|
||
}
|
||
// true / false, or a top-level menu to open it at ("Plugins").
|
||
match p.get("mainMenu") {
|
||
Some(Value::Bool(h)) => app.ui.main_menu = *h,
|
||
Some(Value::String(top)) => {
|
||
app.ui.main_menu = true;
|
||
crate::menus::open_main_menu(ctx, top);
|
||
}
|
||
_ => {}
|
||
}
|
||
if let Some(h) = p.get("pluginsWindow").and_then(Value::as_bool) {
|
||
app.ui.plugins_window = h;
|
||
}
|
||
// A plugin command's parameters form: {id, command?}; null closes it.
|
||
match p.get("pluginForm") {
|
||
Some(Value::Null) => app.ui.plugin_form = None,
|
||
Some(v @ Value::Object(_)) => {
|
||
let id = v.get("id").and_then(Value::as_str).ok_or("pluginForm: {id, command?}")?;
|
||
let command = v.get("command").and_then(Value::as_str).unwrap_or_default();
|
||
crate::plugins_ui::start(app, id, command);
|
||
}
|
||
_ => {}
|
||
}
|
||
// Give a region the keyboard as F6 would: toolbar, layers, canvas or properties.
|
||
if let Some(r) = p.get("focusRegion").and_then(Value::as_str) {
|
||
app.ui.nav.go(crate::a11y::Region::parse(r).ok_or_else(|| format!("unknown region `{r}`"))?);
|
||
}
|
||
// A right-click on the canvas at screen [sx, sy] (null closes the menu);
|
||
// `contextSelectLayer` opens its Select layer submenu.
|
||
match p.get("contextMenu") {
|
||
Some(Value::Array(at)) => {
|
||
let (x, y) = (at.first().and_then(Value::as_f64).unwrap_or(0.0), at.get(1).and_then(Value::as_f64).unwrap_or(0.0));
|
||
crate::canvas::open_context_menu(app, egui::pos2(x as f32, y as f32), b(p, "cmd"));
|
||
}
|
||
Some(Value::Null) => app.ui.context_menu = None,
|
||
_ => {}
|
||
}
|
||
if let Some(v) = p.get("contextSelectLayer").and_then(Value::as_bool) {
|
||
crate::menus::open_select_layer(ctx, v);
|
||
}
|
||
if let Some(h) = p.get("iconGallery").and_then(Value::as_bool) {
|
||
app.ui.icon_gallery = h;
|
||
}
|
||
// Comment thread popover: an id opens it, null closes it.
|
||
if let Some(v) = p.get("commentOpen") {
|
||
match v.as_u64() {
|
||
Some(id) => app.ui.comments.show(id),
|
||
None => app.ui.comments.close(),
|
||
}
|
||
}
|
||
if let Some(v) = p.get("commentDraft") {
|
||
match (v.get("x").and_then(Value::as_f64), v.get("y").and_then(Value::as_f64)) {
|
||
(Some(x), Some(y)) => crate::comments::start_draft(app, x, y),
|
||
_ => app.ui.comments.draft = None,
|
||
}
|
||
}
|
||
if let Some(v) = p.get("showResolvedComments").and_then(Value::as_bool) {
|
||
app.ui.comments.filter = if v { Filter::All } else { Filter::Open };
|
||
}
|
||
if let Some(v) = p.get("commentFilter").and_then(Value::as_str) {
|
||
app.ui.comments.filter = Filter::parse(v).ok_or("commentFilter: open|resolved|mine|unread|all")?;
|
||
}
|
||
if let Some(v) = p.get("commentSort").and_then(Value::as_str) {
|
||
app.ui.comments.sort = Sort::parse(v).ok_or("commentSort: date|page")?;
|
||
}
|
||
if let Some(v) = p.get("commentSearch").and_then(Value::as_str) {
|
||
app.ui.comments.search = v.to_string();
|
||
}
|
||
// The open thread's reply field, with the cursor at its end (an `@` there offers names).
|
||
if let Some(v) = p.get("commentReply").and_then(Value::as_str) {
|
||
app.ui.comments.reply = v.to_string();
|
||
if let Some(id) = app.ui.comments.open {
|
||
crate::comments::focus_reply(ctx, id, v.chars().count());
|
||
}
|
||
}
|
||
// Edit a message of the open thread: its index (0: the first message); null stops.
|
||
if let Some(v) = p.get("commentEdit") {
|
||
app.ui.comments.editing = match (app.ui.comments.open, v.as_u64()) {
|
||
(Some(id), Some(i)) => {
|
||
let text = app.session.doc().ok().and_then(|d| d.comment(id)).and_then(|c| c.message(i as usize)).map(|m| m.text.clone());
|
||
Some((id, i as usize, text.ok_or("commentEdit: no such message")?))
|
||
}
|
||
_ => None,
|
||
};
|
||
}
|
||
if let Some(v) = p.get("presentComments").and_then(Value::as_bool) {
|
||
app.ui.present_view.comments = v;
|
||
if let Some(pres) = app.ui.present.as_mut() {
|
||
pres.view.comments = v;
|
||
}
|
||
}
|
||
// Colour picker on the selection's paint `{fills?, index?}`; null closes it.
|
||
if let Some(v) = p.get("colorPicker") {
|
||
app.ui.picker = v.is_object().then(|| crate::colorpicker::PickerTarget::Paint {
|
||
fills: v.get("fills").and_then(Value::as_bool).unwrap_or(true),
|
||
index: v.get("index").and_then(Value::as_u64).unwrap_or(0).min(1000) as usize,
|
||
ids: app.session.selection(),
|
||
});
|
||
}
|
||
// The Variables editor: true / a collection (id or name) / {library, collection?} (an
|
||
// enabled library's, by id or name) opens it, false closes it.
|
||
match p.get("variables") {
|
||
Some(Value::Bool(open)) => app.ui.variables = open.then(Default::default),
|
||
Some(Value::String(c)) => app.ui.variables = Some(crate::variables_panel::Editor::on(Some(c.clone()))),
|
||
Some(v @ Value::Object(_)) => {
|
||
let text = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
|
||
app.ui.variables = Some(match text("library") {
|
||
Some(lib) => crate::variables_panel::Editor::in_library(lib, text("collection")),
|
||
None => crate::variables_panel::Editor::on(text("collection")),
|
||
});
|
||
}
|
||
_ => {}
|
||
}
|
||
// Layers panel search: a string opens it with that filter, null closes it.
|
||
if let Some(v) = p.get("layerFilter") {
|
||
app.ui.layer_search = v.is_string();
|
||
app.ui.layer_filter = v.as_str().unwrap_or_default().chars().take(200).collect();
|
||
}
|
||
if p.get("collapseLayers").and_then(Value::as_bool) == Some(true) {
|
||
app.ui.expanded.clear();
|
||
}
|
||
// The interaction editor popover in the Prototype panel: [layer id, index]; null closes it.
|
||
match p.get("protoEditor") {
|
||
Some(Value::Null) => crate::proto_panel::open_editor(ctx, None),
|
||
Some(Value::Array(a)) => {
|
||
let id = a.first().and_then(Value::as_u64).ok_or("protoEditor: [layer id, interaction index]")?;
|
||
let k = a.get(1).and_then(Value::as_u64).unwrap_or(0) as usize;
|
||
crate::proto_panel::open_editor(ctx, Some((figcraft_doc::NodeId(id), k)));
|
||
}
|
||
_ => {}
|
||
}
|
||
// The prototype preview pane: true / a frame id opens it, false or null closes it.
|
||
match p.get("preview") {
|
||
Some(Value::Bool(false) | Value::Null) => app.ui.preview = None,
|
||
Some(v @ (Value::Bool(true) | Value::Number(_))) => {
|
||
let params = if v.is_number() { json!({"start": v}) } else { json!({}) };
|
||
app.session.execute("proto.preview", ¶ms).map_err(|e| e.to_string())?;
|
||
app.handle_ui_requests();
|
||
}
|
||
_ => {}
|
||
}
|
||
// Scroll the presented screen (the full-window player, else the preview pane) to [x, y].
|
||
if let Some(s) = p.get("presentScroll").and_then(Value::as_array) {
|
||
let at = |i: usize| s.get(i).and_then(Value::as_f64).unwrap_or(0.0);
|
||
let player = match (app.ui.present.as_mut(), app.ui.preview.as_mut()) {
|
||
(Some(p), _) => Some(p),
|
||
(None, Some(pane)) => Some(&mut pane.player),
|
||
_ => None,
|
||
};
|
||
player.ok_or("nothing is presented")?.scroll_to(at(0), at(1));
|
||
}
|
||
// The style picker: a slot (fill, stroke, effect, grid, text) opens it for the selection, null
|
||
// closes it; `stylePickerEdit` (a style id or name) opens its editor; `stylePickerView`
|
||
// is `list` or `grid`.
|
||
if let Some(v) = p.get("stylePicker") {
|
||
match v.as_str().and_then(crate::style_picker::StyleSlot::parse) {
|
||
Some(slot) => crate::style_picker::open_for_selection(app, slot, p.get("stylePickerEdit").and_then(Value::as_str)),
|
||
None => app.ui.style_picker = None,
|
||
}
|
||
}
|
||
if let Some(v) = p.get("stylePickerView").and_then(Value::as_str) {
|
||
app.ui.style_grid = v == "grid";
|
||
}
|
||
// The Color profile dialog: a profile id opens it with that profile chosen, null closes it.
|
||
if let Some(v) = p.get("colorProfileDialog") {
|
||
app.ui.color_profile = v.as_str().and_then(figcraft_color::ColorProfile::parse);
|
||
}
|
||
if let Some(v) = p.get("exportPreview").and_then(Value::as_bool) {
|
||
app.ui.export_thumb = v;
|
||
}
|
||
// Dev Mode's compare view: a ready-for-dev frame or section opens it, null closes it;
|
||
// `devCompareMode` is sideBySide or overlay, `devCompareSplit` the overlay slider (0–1).
|
||
if let Some(v) = p.get("devCompare") {
|
||
app.ui.dev.compare = figcraft_engine::params::id_of(v).map(crate::dev_compare::CompareView::new);
|
||
}
|
||
if let Some(c) = app.ui.dev.compare.as_mut() {
|
||
match p.get("devCompareMode").and_then(Value::as_str) {
|
||
Some("overlay") => c.mode = crate::dev_compare::Mode::Overlay,
|
||
Some("sideBySide") => c.mode = crate::dev_compare::Mode::SideBySide,
|
||
Some(_) => return Err("devCompareMode: sideBySide|overlay".into()),
|
||
None => {}
|
||
}
|
||
if let Some(x) = p.get("devCompareSplit").and_then(Value::as_f64).filter(|x| x.is_finite()) {
|
||
c.split = x.clamp(0.0, 1.0) as f32;
|
||
}
|
||
}
|
||
// The branch review dialog: a branch id opens it, null closes it; `branchReviewPicks`
|
||
// chooses sides ({<conflict key>: "branch" | "main"}).
|
||
if let Some(v) = p.get("branchReview") {
|
||
match v.as_str() {
|
||
Some(id) => crate::branch_ui::open(app, Some(id)),
|
||
None => app.ui.branch_review = None,
|
||
}
|
||
}
|
||
if let (Some(r), Some(picks)) = (app.ui.branch_review.as_mut(), p.get("branchReviewPicks").and_then(Value::as_object)) {
|
||
for (k, v) in picks {
|
||
let side = v.as_str().and_then(figcraft_engine::branches::Side::parse).ok_or("branchReviewPicks: \"branch\" or \"main\"")?;
|
||
r.picks.insert(k.clone(), side);
|
||
}
|
||
}
|
||
// Which annotations the canvas shows: all, none (uncategorised) or a category.
|
||
if let Some(v) = p.get("annotationFilter").and_then(Value::as_str) {
|
||
let doc = app.session.doc().map_err(|e| e.to_string())?;
|
||
app.ui.dev.categories = crate::dev_mode::CategoryFilter::parse(doc, v).ok_or("annotationFilter: all|none|a category id or name")?;
|
||
}
|
||
if b(p, "resetFrameStats") {
|
||
app.frame_stats.reset();
|
||
}
|
||
Ok(app.inspect())
|
||
}
|
||
"ui.render" => {
|
||
let path = p.get("path").and_then(Value::as_str).ok_or("`path` required")?;
|
||
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0);
|
||
let ids: Vec<figcraft_doc::NodeId> =
|
||
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(figcraft_engine::params::id_of).collect()).unwrap_or_default();
|
||
let mut params = json!({"format": "png", "scale": scale, "path": path});
|
||
if !ids.is_empty() {
|
||
params["ids"] = json!(ids);
|
||
}
|
||
app.session.execute("file.export", ¶ms).map_err(|e| e.to_string())
|
||
}
|
||
"app.quit" => {
|
||
app.ui.quit = true;
|
||
ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Close);
|
||
Ok(json!({"ok": true}))
|
||
}
|
||
other => Err(format!("unknown method `{other}`")),
|
||
}
|
||
}
|
||
|
||
fn pointer(app: &mut FigApp, p: &Value) -> Result<Value, String> {
|
||
let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0);
|
||
let at = |x: Option<f64>, y: Option<f64>, sx: Option<f64>, sy: Option<f64>| -> Option<(f64, f64)> {
|
||
match (x, y, sx, sy) {
|
||
(Some(x), Some(y), _, _) => Some((x, y)),
|
||
(_, _, Some(sx), Some(sy)) => {
|
||
let pt = app.to_page(egui::pos2(sx as f32, sy as f32));
|
||
Some((pt.x, pt.y))
|
||
}
|
||
_ => None,
|
||
}
|
||
};
|
||
let f = |k: &str| p.get(k).and_then(Value::as_f64);
|
||
let (x, y) = at(f("x"), f("y"), f("sx"), f("sy")).ok_or("pass x, y (page) or sx, sy (screen)")?;
|
||
let pressure = |k: &str| f(k).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0) as f32);
|
||
let mk = |kind: PointerKind, x: f64, y: f64| PointerEvent {
|
||
kind,
|
||
x,
|
||
y,
|
||
sx: x * zoom,
|
||
sy: y * zoom,
|
||
shift: b(p, "shift"),
|
||
alt: b(p, "alt"),
|
||
cmd: b(p, "cmd"),
|
||
ctrl: b(p, "ctrl"),
|
||
zoom,
|
||
pressure: pressure("pressure"),
|
||
};
|
||
// A drag's pressure goes from `pressure` to `pressureTo`.
|
||
let (pr0, pr1) = (pressure("pressure"), pressure("pressureTo").or(pressure("pressure")));
|
||
let kind = p.get("kind").and_then(Value::as_str).unwrap_or("click");
|
||
let run = |app: &mut FigApp, ev: PointerEvent| app.session.pointer(&ev).map_err(|e| e.to_string());
|
||
match kind {
|
||
"down" => run(app, mk(PointerKind::Down, x, y))?,
|
||
"move" => run(app, mk(PointerKind::Move, x, y))?,
|
||
"up" => run(app, mk(PointerKind::Up, x, y))?,
|
||
"hover" => run(app, mk(PointerKind::Hover, x, y))?,
|
||
"double" => run(app, mk(PointerKind::DoubleClick, x, y))?,
|
||
"click" => {
|
||
run(app, mk(PointerKind::Down, x, y))?;
|
||
run(app, mk(PointerKind::Up, x, y))?;
|
||
}
|
||
"drag" => {
|
||
let to = p.get("to").and_then(Value::as_array).ok_or("drag needs `to: [x, y]`")?;
|
||
let tx = to.first().and_then(Value::as_f64).ok_or("bad `to`")?;
|
||
let ty = to.get(1).and_then(Value::as_f64).ok_or("bad `to`")?;
|
||
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200);
|
||
run(app, mk(PointerKind::Down, x, y))?;
|
||
for i in 1..=steps {
|
||
let t = i as f64 / steps as f64;
|
||
let pressure = pr0.zip(pr1).map(|(a, b)| a + (b - a) * t as f32);
|
||
run(app, PointerEvent { pressure, ..mk(PointerKind::Move, x + (tx - x) * t, y + (ty - y) * t) })?;
|
||
}
|
||
run(app, PointerEvent { pressure: pr1, ..mk(PointerKind::Up, tx, ty) })?;
|
||
}
|
||
other => return Err(format!("unknown pointer kind `{other}`")),
|
||
}
|
||
app.handle_ui_requests();
|
||
Ok(json!({"selection": app.session.selection(), "tool": app.session.tool.tool.id()}))
|
||
}
|