Files
figcraft/crates/ui-egui/src/control.rs
T
BFlatandClaude Opus 5.5 d3dbc6f495 M3: paint: colour profiles (Display P3), image adjustment presets, style picker (#54)
* M3: colour profiles: sRGB or Display P3 per file, convert or keep values, tagged exports

- color: ColorProfile (srgb, displayP3) with conversions derived from the primaries (shared D65
  white and sRGB curve; out-of-gamut channels clip), table-driven pixel conversion, a generated
  ICC v4 matrix/TRC profile and PNG cICP code points.
- doc: Document.color_profile (serde default sRGB, written only when set); every colour value
  (paints, text runs, effects, grids, page and prototype backgrounds, overlay backdrops, styles,
  colour variables, document colours) can be mapped; set_color_profile converts or reinterprets.
- engine: file.colorProfile {profile?, convert?} (File > Color profile...; without a profile it
  opens the dialog); document.inspect reports colorProfile.
- render: images are taken as sRGB and managed into the file's space; encode_export tags PNG
  (sRGB chunk, or iCCP + cICP for Display P3) and embeds the ICC profile in Display P3 JPG/WebP;
  SVG and PDF export Display P3 files converted to sRGB. The CLI exports the same way.
- ui: Color profile dialog (keep appearance / keep values) and a Color profile row on the page
  panel; the canvas, presenter, thumbnails, swatches and colour picker show a Display P3 file's
  colours converted to the screen's sRGB, and Copy as PNG converts too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: image adjustment presets: built-in, saved in the file, reset

- color: ImageFilters::PRESETS (Vivid, Muted, Warm, Cool, Black & white, Fade, Reset), lookup by
  id or label, and sanitized values.
- doc: Document.image_presets (name + adjustments; written only when there are some).
- engine: image.adjust {ids?, target?, index?, preset?, filters?} sets the adjustments of image
  paints from a preset (built-in or saved) with values on top, one undo step; image.savePreset,
  image.deletePreset and image.presets.
- ui: the colour picker's image section gets a presets menu named after the preset in effect
  ("Custom" otherwise), saved presets with delete, "Save as preset...", a Reset button, and
  aligned adjustment sliders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: style picker: search, folders, list or swatch grid, edit, create, detach

The Fill, Stroke, Effects and Layout grid sections' Styles button opens a popover laid out like
the variable picker (crates/ui-egui/src/style_picker.rs):

- search over style names; styles grouped by the folders of their `/`-separated names, which
  collapse (searching opens them); colour styles as a list or a grid of swatches;
- click applies, a check marks the applied style; the row's edit button (or a right-click) edits
  the name, description and values: colour style paints open the colour picker on the style,
  effect and grid styles update from the selection; delete;
- `+` creates a style from the selection's current values and opens it for naming; detach;
- colour slots still list colour variables to bind;
- the applied style chip opens the picker; new icons Adjust, ViewList, ViewGrid (drawn in code);
- control channel: ui.set {stylePicker, stylePickerEdit, stylePickerView}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:38:17 +07:00

371 lines
18 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.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog)
//! - `ui.set {rightTab?, leftTab?, theme?, home?, palette?, 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)
//! - `app.quit {}`
use egui::Context;
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>,
}
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) {
// Finish screenshots that arrived.
let shots: Vec<std::sync::Arc<egui::ColorImage>> = ctx.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Screenshot { image, .. } => Some(image.clone()),
_ => None,
})
.collect()
});
if let Some(img) = shots.last() {
let list = pending(ctx);
let mut list = list.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
for p in list.drain(..) {
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, result: res });
}
}
let reqs = std::mem::take(&mut app.control_queue);
for r in reqs {
if r.method == "ui.screenshot" {
let path = r.params.get("path").and_then(Value::as_str).unwrap_or("figcraft-screenshot.png").to_string();
pending(ctx).lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(PendingShot { id: r.id, path, reply: r.reply });
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
ctx.request_repaint();
continue;
}
let res = handle(app, ctx, &r.method, &r.params);
let _ = r.reply.send(ControlReply { id: r.id, result: res });
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, &params).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())
}
"ui.inspect" => Ok(app.inspect()),
"ui.pointer" => pointer(app, p),
"ui.key" => {
let key = p.get("key").and_then(Value::as_str).ok_or("`key` required")?;
let mut spec = String::new();
for (k, m) in [("shift", "Shift"), ("alt", "Alt"), ("cmd", "Cmd"), ("ctrl", "Ctrl")] {
if b(p, k) {
spec.push_str(m);
spec.push('+');
}
}
spec.push_str(key);
// Text editing keys go to the editor.
if app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false) {
figcraft_engine::textedit::key(&mut app.session, key, b(p, "shift"), b(p, "alt"), b(p, "cmd")).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, b(p, "shift"), b(p, "alt"), b(p, "cmd")).map_err(|e| e.to_string())? {
return Ok(json!({"ok": true}));
}
match crate::shortcuts::parse(&spec) {
Some(want) => {
let id = figcraft_engine::catalog::MENU
.iter()
.find(|m| !m.shortcut.is_empty() && crate::shortcuts::parse(m.shortcut) == Some(want))
.map(|m| m.id);
match id {
Some(id) => {
if id == "select.none" {
app.session.escape().map_err(|e| e.to_string())?;
} else {
crate::menus::invoke(app, id);
}
Ok(json!({"command": id}))
}
None => match key {
"Escape" => app.session.escape().map(|_| json!({"ok": true})).map_err(|e| e.to_string()),
"Delete" | "Backspace" => app.session.execute("edit.delete", &json!({})).map_err(|e| e.to_string()),
"Enter" => {
let _ = app.session.pen_finish();
Ok(json!({"ok": true}))
}
"ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" => {
let (dx, dy) = match key {
"ArrowLeft" => (-1, 0),
"ArrowRight" => (1, 0),
"ArrowUp" => (0, -1),
_ => (0, 1),
};
let (cmd, params) = if b(p, "cmd") {
("transform.nudgeSize", json!({"dw": dx, "dh": dy, "big": b(p, "shift")}))
} else {
("transform.nudge", json!({"dx": dx, "dy": dy, "big": b(p, "shift")}))
};
app.session.execute(cmd, &params).map_err(|e| e.to_string())
}
_ => Err(format!("no shortcut `{spec}`")),
},
}
}
None => Err(format!("unknown key `{key}`")),
}
}
"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 };
}
if let Some(h) = p.get("mainMenu").and_then(Value::as_bool) {
app.ui.main_menu = h;
}
// Comment thread popover: an id opens it, null closes it.
if let Some(v) = p.get("commentOpen") {
app.ui.comments.open = v.as_u64();
app.ui.comments.draft = None;
}
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.show_resolved = 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) 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()))),
_ => {}
}
// 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 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", &params).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) 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;
}
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", &params).map_err(|e| e.to_string())
}
"app.quit" => {
app.ui.quit = true;
ctx.send_viewport_cmd(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 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,
};
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;
run(app, mk(PointerKind::Move, x + (tx - x) * t, y + (ty - y) * t))?;
}
run(app, 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()}))
}