mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
570 lines
30 KiB
Rust
570 lines
30 KiB
Rust
//! Programmatic control of the running app (agents, tests, MCP).
|
|
//!
|
|
//! Methods (JSON-lines over the host's transport):
|
|
//! - `engine.execute {command, params}` / `ui.menu.invoke`: run any engine or UI command
|
|
//! - `engine.commands`: engine + UI commands with enablement
|
|
//! - `ui.inspect`: UI state, panels, view, canvas/image rects, perf
|
|
//! - `ui.widgets {filter?}`: every registered widget id → rect (sliders, buttons, thumbnails…)
|
|
//! - `ui.clickWidget {id, count?}`: click a widget by automation id (real egui input)
|
|
//! - `ui.dragWidget {id, toX?, toY?, dx?, dy?}`: drag from a widget's centre
|
|
//! - `ui.move/click/drag {x, y, …}`: raw pointer input in screen points
|
|
//! - `ui.pointer {events: [{kind: down|drag|up, x, y}], space: "image"}`: gestures in normalized
|
|
//! image coordinates (brush strokes, crop handles…) — mapped to screen and injected as real input
|
|
//! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}` / `ui.scroll {dx, dy}`
|
|
//! - `ui.set {view?, panel?, filmstrip?, leftPanel?, zoom?, …}` / `ui.dialog.confirm|cancel`
|
|
//! - `ui.screenshot {path?, headless?}` (headless: CPU-rendered, no compositor needed), `ui.resize {width, height}`, `ui.render {id?, size?, path?}`, `app.quit`
|
|
|
|
use std::sync::mpsc::Sender;
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::LightcraftApp;
|
|
|
|
pub type ControlResponse = Value;
|
|
|
|
pub struct ControlRequest {
|
|
pub method: String,
|
|
pub params: Value,
|
|
pub reply: Sender<ControlResponse>,
|
|
/// Who has the request: [`PENDING`], [`TAKEN`] (the app runs it) or [`ABANDONED`] (the
|
|
/// sender gave up on it, so the app must not run it late: issue #718).
|
|
pub state: std::sync::Arc<std::sync::atomic::AtomicU8>,
|
|
}
|
|
|
|
/// Queued, not yet run.
|
|
pub const PENDING: u8 = 0;
|
|
/// The app has started it: it will answer.
|
|
pub const TAKEN: u8 = 1;
|
|
/// Its sender gave up waiting (the window drew no frames): it is dropped unrun.
|
|
pub const ABANDONED: u8 = 2;
|
|
|
|
impl ControlRequest {
|
|
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
(Self { method: method.into(), params, reply: tx, state: Default::default() }, rx)
|
|
}
|
|
|
|
/// Claim the request for running; false when its sender already gave up on it.
|
|
pub fn take(&self) -> bool {
|
|
use std::sync::atomic::Ordering;
|
|
self.state.compare_exchange(PENDING, TAKEN, Ordering::SeqCst, Ordering::SeqCst).is_ok() || self.state.load(Ordering::SeqCst) == TAKEN
|
|
}
|
|
|
|
/// Give up on the request; false when the app has already started it (it will answer).
|
|
pub fn abandon(state: &std::sync::atomic::AtomicU8) -> bool {
|
|
use std::sync::atomic::Ordering;
|
|
state.compare_exchange(PENDING, ABANDONED, Ordering::SeqCst, Ordering::SeqCst).is_ok() || state.load(Ordering::SeqCst) == ABANDONED
|
|
}
|
|
}
|
|
|
|
pub enum Outcome {
|
|
Done(Value),
|
|
Screenshot { path: Option<String>, headless: bool },
|
|
}
|
|
|
|
fn ok(v: Value) -> 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<Value, String>) -> Outcome {
|
|
match r {
|
|
Ok(v) => ok(v),
|
|
Err(e) => err(e),
|
|
}
|
|
}
|
|
|
|
pub fn all_commands(app: &LightcraftApp) -> Value {
|
|
let keymap = &app.ui.settings.keymap;
|
|
let mut v: Vec<Value> = app
|
|
.session
|
|
.commands()
|
|
.into_iter()
|
|
.map(|c| {
|
|
let mut v = serde_json::to_value(&c).unwrap_or_default();
|
|
if let Some(o) = v.as_object_mut() {
|
|
o.insert("shortcut".into(), json!(crate::shortcuts::shortcut_of(keymap, c.id)));
|
|
}
|
|
v
|
|
})
|
|
.collect();
|
|
for (id, label, sc, menu) in crate::menus::ui_commands() {
|
|
let sc = crate::shortcuts::binding(keymap, id, *sc);
|
|
v.push(json!({"id": id, "label": label, "shortcut": sc, "menu": [menu], "enabled": crate::menus::ui_enabled(app, id), "ui": true}));
|
|
}
|
|
Value::Array(v)
|
|
}
|
|
|
|
fn rect_json(r: egui::Rect) -> Value {
|
|
json!([r.left(), r.top(), r.width(), r.height()])
|
|
}
|
|
|
|
pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
|
|
let r = ctx.content_rect();
|
|
json!({
|
|
"ui": serde_json::to_value(&app.ui).unwrap_or_default(),
|
|
"window": [r.width(), r.height()],
|
|
"pixelsPerPoint": ctx.pixels_per_point(),
|
|
"canvasRect": app.canvas_rect.map(rect_json),
|
|
"imageRect": app.image_rect.map(rect_json),
|
|
"scroll": {"grid": app.grid_scroll, "filmstrip": app.film_scroll},
|
|
"active": app.session.active().map(|p| p.0),
|
|
"selection": app.session.selection.ids.iter().map(|p| p.0).collect::<Vec<_>>(),
|
|
"activeMask": app.session.active_mask,
|
|
"widgetCount": app.widgets.len(),
|
|
"perf": {"frameMs": app.perf.frame_ms, "logicMs": app.perf.logic_ms, "updateMs": app.perf.update_ms, "maxUpdateMs": app.perf.max_update_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "pendingSlots": app.renderer.pending_slots(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(), "gpuReason": lightcraft_engine::gpu::unavailable_reason(), "gpuFallback": lightcraft_engine::gpu::last_fallback()},
|
|
"loupe": app.loupe_shown.map(|(p, src)| json!({
|
|
"photo": p.0,
|
|
"source": src,
|
|
"pending": app.renderer.is_pending(crate::render::Slot::Main),
|
|
// why its render failed (the loupe then shows a stand-in, or nothing, for good)
|
|
"failed": app.renderer.failure_of(crate::render::Slot::Main, p),
|
|
// zoomed past the whole-frame render: the window rendered at no more than 100 % (pixels of its frame)
|
|
"region": app.region_view.map(|r| json!({"full": [r.full.0, r.full.1], "window": [r.window.x, r.window.y, r.window.w, r.window.h], "pending": app.renderer.is_pending(crate::render::Slot::Region)})),
|
|
// …and the same for the Before side of a Before/After view
|
|
"regionBefore": app.region_before_view.map(|r| json!({"full": [r.full.0, r.full.1], "window": [r.window.x, r.window.y, r.window.w, r.window.h], "pending": app.renderer.is_pending(crate::render::Slot::RegionBefore)})),
|
|
})),
|
|
"hoverPreview": app.hover_preview.as_ref().map(|h| h.label.clone()),
|
|
"status": app.ui.status,
|
|
"copied": app.copied,
|
|
"notices": app.notices,
|
|
"quitPrompt": app.quit_prompt.as_ref().map(crate::QuitPrompt::text),
|
|
// how hard the background face scan may work right now, and why it is judged so (see `panels::faces::scan_pace`)
|
|
"faceScan": {"pace": app.caches.faces_pace, "focused": app.caches.faces_in_front, "pending": app.caches.faces_pending, "indexed": app.caches.faces_indexed},
|
|
"unsaved": app.session.unsaved().map(|(n, e)| json!({"ops": n, "error": e})),
|
|
"libraryProblem": app.library_problem.as_ref().map(crate::panels::library_problem::LibraryProblem::to_json),
|
|
"scan": app.scan.as_ref().map(crate::import::ScanTask::status),
|
|
"export": {"running": app.export.as_ref().map(crate::export_task::ExportTask::status), "last": app.last_export_result},
|
|
"import": app.import.as_ref().map(crate::import::ImportTask::status),
|
|
"tasks": app.tasks.labels(),
|
|
// every long-running task in flight, as the activity stack shows them (`activity.list`)
|
|
"activity": app.session.activity.list(),
|
|
// commands waiting on a native file dialog shown off the UI thread (`pick`)
|
|
"fileDialogs": app.pending_picks.iter().map(|p| p.command.clone()).collect::<Vec<_>>(),
|
|
"memory": memory(app),
|
|
})
|
|
}
|
|
|
|
/// Bytes held by the engine's caches and the renderer's (`ui.inspect` → `memory`).
|
|
pub fn memory(app: &LightcraftApp) -> Value {
|
|
let mut v = serde_json::to_value(app.session.memory_report()).unwrap_or_default();
|
|
if let (Some(o), Value::Object(r)) = (v.as_object_mut(), app.renderer.memory()) {
|
|
o.extend(r);
|
|
}
|
|
v
|
|
}
|
|
|
|
fn modifiers(p: &Value) -> egui::Modifiers {
|
|
let b = |n: &str| p.get(n).and_then(Value::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") }
|
|
}
|
|
|
|
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" | "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),
|
|
"\\" | "backslash" => Some(egui::Key::Backslash),
|
|
"/" | "slash" => Some(egui::Key::Slash),
|
|
"[" => Some(egui::Key::OpenBracket),
|
|
"]" => Some(egui::Key::CloseBracket),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
fn push_drag(app: &mut LightcraftApp, a: egui::Pos2, b: egui::Pos2, steps: u64, m: egui::Modifiers) {
|
|
app.synthetic.push(egui::Event::PointerMoved(a));
|
|
app.synthetic.push(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(egui::Event::PointerMoved(a + (b - a) * t));
|
|
}
|
|
app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m });
|
|
}
|
|
|
|
fn widget_rect(app: &LightcraftApp, id: &str) -> Option<egui::Rect> {
|
|
app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r)
|
|
}
|
|
|
|
pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
|
let p = &req.params;
|
|
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
|
let f = |k: &str| p.get(k).and_then(Value::as_f64);
|
|
match req.method.as_str() {
|
|
"engine.execute" | "ui.menu.invoke" | "command" => {
|
|
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
|
|
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
|
|
let r = app.run(id, params);
|
|
ctx.request_repaint();
|
|
wrap(r)
|
|
}
|
|
"engine.commands" => ok(all_commands(app)),
|
|
"ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()),
|
|
"ui.menu.tree" => {
|
|
ok(Value::Array(crate::menubar::menu_bar(app).into_iter().map(|(title, items)| json!({"label": title, "children": items})).collect()))
|
|
}
|
|
"ui.inspect" => ok(inspect(app, ctx)),
|
|
"ui.widgets" => {
|
|
let filter = s("filter").unwrap_or("");
|
|
ok(Value::Array(
|
|
app.widgets.iter().filter(|(id, _)| id.contains(filter)).map(|(id, r)| json!({"id": id, "rect": rect_json(*r)})).collect(),
|
|
))
|
|
}
|
|
"ui.clickWidget" | "ui.dragWidget" | "ui.hoverWidget" => {
|
|
let Some(id) = s("id") else { return err("missing `id`") };
|
|
let Some(r) = widget_rect(app, id) else { return err(format!("no widget `{id}` on screen (see ui.widgets)")) };
|
|
let m = modifiers(p);
|
|
// optional relative position inside the widget (0..1)
|
|
let at = egui::pos2(r.left() + r.width() * f("fx").unwrap_or(0.5) as f32, r.top() + r.height() * f("fy").unwrap_or(0.5) as f32);
|
|
if req.method == "ui.hoverWidget" {
|
|
app.synthetic.push(egui::Event::PointerMoved(at));
|
|
} else if req.method == "ui.clickWidget" {
|
|
let n = p.get("count").and_then(Value::as_u64).unwrap_or(1);
|
|
let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary };
|
|
app.synthetic.push(egui::Event::PointerMoved(at));
|
|
for _ in 0..n {
|
|
app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers: m });
|
|
app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers: m });
|
|
}
|
|
} else {
|
|
let to = egui::pos2(
|
|
f("toX").map(|v| v as f32).unwrap_or(at.x + f("dx").unwrap_or(0.0) as f32),
|
|
f("toY").map(|v| v as f32).unwrap_or(at.y + f("dy").unwrap_or(0.0) as f32),
|
|
);
|
|
push_drag(app, at, to, p.get("steps").and_then(Value::as_u64).unwrap_or(10), m);
|
|
}
|
|
ctx.request_repaint();
|
|
ok(json!({"rect": rect_json(r)}))
|
|
}
|
|
"ui.move" => {
|
|
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x").unwrap_or(0.0) as f32, f("y").unwrap_or(0.0) as f32)));
|
|
ctx.request_repaint();
|
|
ok(Value::Null)
|
|
}
|
|
"ui.click" | "ui.drag" => {
|
|
let a = egui::pos2(f("x").unwrap_or(0.0) as f32, f("y").unwrap_or(0.0) as f32);
|
|
let m = modifiers(p);
|
|
let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary };
|
|
if req.method == "ui.drag" {
|
|
let b = egui::pos2(f("toX").unwrap_or(0.0) as f32, f("toY").unwrap_or(0.0) as f32);
|
|
push_drag(app, a, b, p.get("steps").and_then(Value::as_u64).unwrap_or(10), m);
|
|
} else {
|
|
app.synthetic.push(egui::Event::PointerMoved(a));
|
|
for _ in 0..p.get("count").and_then(Value::as_u64).unwrap_or(1) {
|
|
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers: m });
|
|
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: false, modifiers: m });
|
|
}
|
|
}
|
|
ctx.request_repaint();
|
|
ok(Value::Null)
|
|
}
|
|
"ui.pointer" => {
|
|
// events in normalized image coordinates of the displayed photo frame
|
|
let Some(img) = app.image_rect else { return err("no image on screen (switch to Detail view)") };
|
|
let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") };
|
|
let m = modifiers(p);
|
|
for e in events {
|
|
let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.5) as f32;
|
|
let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.5) as f32;
|
|
let pos = egui::pos2(img.left() + x * img.width(), img.top() + y * img.height());
|
|
match e.get("kind").and_then(Value::as_str).unwrap_or("move") {
|
|
"down" => {
|
|
app.synthetic.push(egui::Event::PointerMoved(pos));
|
|
app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
|
|
}
|
|
"up" => {
|
|
app.synthetic.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: m })
|
|
}
|
|
_ => app.synthetic.push(egui::Event::PointerMoved(pos)),
|
|
}
|
|
}
|
|
ctx.request_repaint();
|
|
ok(json!({"events": events.len()}))
|
|
}
|
|
"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(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m });
|
|
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m });
|
|
ctx.request_repaint();
|
|
ok(Value::Null)
|
|
}
|
|
"ui.text" => {
|
|
app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string()));
|
|
ctx.request_repaint();
|
|
ok(Value::Null)
|
|
}
|
|
// what the system does for ⌘X / ⌘C / ⌘V in a text field: paste `text`, or (without it)
|
|
// what the system clipboard holds
|
|
"ui.clipboard" => {
|
|
// outside a text field ⌘C / ⌘V are the app's shortcuts (and the macOS menu bar's):
|
|
// agents run those as commands (`develop.copy`, `develop.paste`)
|
|
if !ctx.text_edit_focused() {
|
|
return err("ui.clipboard: no text field has the focus (click one first)");
|
|
}
|
|
match s("action") {
|
|
Some("cut") => app.synthetic.push(egui::Event::Cut),
|
|
Some("copy") => app.synthetic.push(egui::Event::Copy),
|
|
Some("paste") => match s("text") {
|
|
Some(text) => app.synthetic.push(egui::Event::Paste(text.to_string())),
|
|
None => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste),
|
|
},
|
|
_ => return err("ui.clipboard: `action` is cut, copy or paste"),
|
|
}
|
|
ctx.request_repaint();
|
|
ok(Value::Null)
|
|
}
|
|
"ui.scroll" => {
|
|
app.synthetic.push(egui::Event::MouseWheel {
|
|
unit: egui::MouseWheelUnit::Point,
|
|
delta: egui::vec2(f("dx").unwrap_or(0.0) as f32, f("dy").unwrap_or(0.0) as f32),
|
|
phase: egui::TouchPhase::Move,
|
|
modifiers: modifiers(p),
|
|
});
|
|
ctx.request_repaint();
|
|
ok(Value::Null)
|
|
}
|
|
"ui.zoom" => {
|
|
let Some(factor) = f("factor").map(|v| v as f32).filter(|v| v.is_finite() && *v > 0.0) else {
|
|
return err("ui.zoom: factor must be a finite positive number (1 = unchanged)");
|
|
};
|
|
app.synthetic.push(egui::Event::Zoom(factor));
|
|
ctx.request_repaint();
|
|
ok(Value::Null)
|
|
}
|
|
"ui.set" => {
|
|
// an arrangement of the sidebar is checked, not made whole as a settings file's is
|
|
let sidebar = match p.get("sidebar").map(crate::sidebar::SidebarLayout::parse).transpose() {
|
|
Ok(s) => s,
|
|
Err(e) => return err(e),
|
|
};
|
|
let mut v = serde_json::to_value(&app.ui).unwrap_or_default();
|
|
lightcraft_develop::presets::deep_merge(&mut v, p);
|
|
match serde_json::from_value::<crate::UiState>(v) {
|
|
Ok(mut u) => {
|
|
if let Err(error) = crate::docking::set_widths(&mut u, p) {
|
|
return err(error);
|
|
}
|
|
if let Some(sidebar) = sidebar {
|
|
u.sidebar = sidebar;
|
|
}
|
|
// `collapsedSidebar`, as scripts from before `sidebar` set it: the folded sections
|
|
if let Some(ids) = p.get("collapsedSidebar").and_then(Value::as_array) {
|
|
use crate::sidebar::SidebarSection;
|
|
if p.get("sidebar").is_some() {
|
|
return err("ui.set: give `sidebar` or the older `collapsedSidebar`, not both".to_string());
|
|
}
|
|
if let Some(unknown) = ids.iter().find(|id| id.as_str().and_then(SidebarSection::from_id).is_none()) {
|
|
return err(format!("ui.set: `collapsedSidebar`: unknown section {unknown} ({})", SidebarSection::ids()));
|
|
}
|
|
for section in SidebarSection::ALL {
|
|
u.sidebar.set_collapsed(section, ids.iter().any(|id| id.as_str() == Some(section.id())));
|
|
}
|
|
}
|
|
u.collapsed_sidebar.clear();
|
|
u.toast = app.ui.toast.clone();
|
|
u.dialog = app.ui.dialog.clone();
|
|
u.status = app.ui.status.clone();
|
|
crate::docking::cancel_replaced_gesture(app, u.docking.as_ref());
|
|
app.ui = u;
|
|
crate::i18n::set_language(app.ui.language);
|
|
ctx.request_repaint();
|
|
ok(serde_json::to_value(&app.ui).unwrap_or_default())
|
|
}
|
|
Err(e) => err(e),
|
|
}
|
|
}
|
|
"ui.dialog.confirm" => match app.ui.dialog.take() {
|
|
Some(d) => {
|
|
let r = crate::panels::dialogs::confirm_dialog(app, &d);
|
|
// the import review and the rule editor stay open on an error, as with their buttons
|
|
if (r.is_err()
|
|
&& matches!(
|
|
d,
|
|
crate::state::Dialog::Import { .. } | crate::state::Dialog::SamModel { .. } | crate::state::Dialog::SmartRules { .. }
|
|
))
|
|
|| (r.is_ok() && crate::panels::dialogs::keeps_open(app, &d))
|
|
{
|
|
app.ui.dialog = Some(d);
|
|
}
|
|
wrap(r)
|
|
}
|
|
None => err("no dialog open"),
|
|
},
|
|
"ui.dialog.cancel" => {
|
|
app.ui.dialog = None;
|
|
ok(Value::Null)
|
|
}
|
|
"ui.resize" => {
|
|
for name in ["width", "height"] {
|
|
if p.get(name).is_some() && f(name).is_none() {
|
|
return err(format!("ui.resize: {name} must be a number"));
|
|
}
|
|
}
|
|
let w = f("width").unwrap_or(1600.0) as f32;
|
|
let h = f("height").unwrap_or(1000.0) as f32;
|
|
let zoom = ctx.zoom_factor();
|
|
let native_scale = ctx.input(|i| i.viewport().native_pixels_per_point).unwrap_or_else(|| ctx.pixels_per_point() / zoom);
|
|
if let Err(e) = crate::headless::resized_viewport([w, h], native_scale, zoom) {
|
|
return err(e);
|
|
}
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h)));
|
|
ok(Value::Null)
|
|
}
|
|
"ui.zoomFactor" => match f("factor") {
|
|
// egui's interface scale (its keyboard shortcuts are off: Cmd+= / Cmd+- zoom the photo)
|
|
Some(factor) if factor.is_finite() && (0.5..=3.0).contains(&factor) => {
|
|
ctx.set_zoom_factor(factor as f32);
|
|
ok(Value::Null)
|
|
}
|
|
_ => err("ui.zoomFactor: factor must be a number from 0.5 to 3"),
|
|
},
|
|
"ui.screenshot" => {
|
|
// never over a photo's original
|
|
if let Some(Err(e)) = s("path").map(|path| app.session.check_write_target(path)) {
|
|
return err(e);
|
|
}
|
|
ctx.request_repaint();
|
|
Outcome::Screenshot { path: s("path").map(str::to_string), headless: p.get("headless").and_then(Value::as_bool).unwrap_or(false) }
|
|
}
|
|
"ui.render" => {
|
|
let id = p.get("id").and_then(Value::as_u64).map(lightcraft_catalog::PhotoId).or(app.session.active());
|
|
let Some(id) = id else { return err("no photo") };
|
|
if let Some(Err(e)) = s("path").map(|path| app.session.check_write_target(path)) {
|
|
return err(e);
|
|
}
|
|
let size = p.get("size").and_then(Value::as_u64).unwrap_or(1600) as usize;
|
|
match app.session.render_now(id, size, size) {
|
|
Ok(r) => match (s("path"), app.services.png.as_ref()) {
|
|
(Some(path), Some(png)) => {
|
|
let bytes = png(&r.image);
|
|
match app.services.write.as_mut() {
|
|
Some(w) => wrap(w(path, &bytes).map(|_| json!({"path": path, "width": r.image.width, "height": r.image.height}))),
|
|
None => err("no writer"),
|
|
}
|
|
}
|
|
_ => ok(json!({"width": r.image.width, "height": r.image.height, "histogram": r.histogram})),
|
|
},
|
|
Err(e) => err(e),
|
|
}
|
|
}
|
|
"app.quit" => {
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
|
ok(Value::Null)
|
|
}
|
|
other => err(format!("unknown method `{other}`")),
|
|
}
|
|
}
|
|
|
|
/// Shown when an export has nowhere to go (no folder typed or chosen, and no home folder to default to).
|
|
pub const NO_EXPORT_FOLDER: &str = "Choose an export folder first.";
|
|
|
|
/// Default export folder: `~/Pictures/LightCraft Exports` (`%USERPROFILE%\Pictures\LightCraft Exports`
|
|
/// on Windows, where `HOME` usually isn't set). Empty when no home folder is known.
|
|
pub fn default_export_dir() -> String {
|
|
let home = if cfg!(windows) { std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")) } else { std::env::var_os("HOME") };
|
|
home.filter(|h| !h.is_empty())
|
|
.map(|h| std::path::PathBuf::from(h).join("Pictures").join("LightCraft Exports").to_string_lossy().into_owned())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Export the selected photos (UI command `app.export`). Params: see
|
|
/// [`lightcraft_engine::export::ExportOptions::from_json`], plus `dir` (output folder) or `path`
|
|
/// (exact output file, single photo), `ids` (default: the selection, else the active photo).
|
|
pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
|
use lightcraft_engine::export::{Destination, ExportOptions, export_batch};
|
|
let p = &app.session.export_params(p)?;
|
|
let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?;
|
|
if let (Some(path), None) = (p.get("path").and_then(Value::as_str), p.get("format")) {
|
|
let ext = path.rsplit_once('.').map_or("", |(_, e)| e);
|
|
opts.format = lightcraft_engine::export::ExportFormat::parse(ext).unwrap_or(opts.format);
|
|
}
|
|
let ids: Vec<_> = match p.get("ids").and_then(Value::as_array) {
|
|
Some(a) => a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect(),
|
|
None if !app.session.selection.ids.is_empty() => {
|
|
// Batch order (and `{seq}`) follows the grid order.
|
|
let sel: std::collections::HashSet<_> = app.session.selection.ids.iter().copied().collect();
|
|
let mut v: Vec<_> = app.session.visible().iter().copied().filter(|id| sel.contains(id)).collect();
|
|
let shown: std::collections::HashSet<_> = v.iter().copied().collect();
|
|
v.extend(app.session.selection.ids.iter().filter(|id| !shown.contains(id)));
|
|
v
|
|
}
|
|
None => app.session.active().into_iter().collect(),
|
|
};
|
|
if ids.is_empty() {
|
|
return Err("no photo selected".into());
|
|
}
|
|
// An exact output file needs no folder. A folder given but left blank (the Export dialog's Folder
|
|
// field cleared) is an error, like Lightroom refusing to export to an unspecified folder, rather
|
|
// than a silent write into the working directory. No folder at all (e.g. File → Export with
|
|
// Preset): the last export's, else the default.
|
|
let exact = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).map(str::to_string);
|
|
let dir = match p.get("dir").and_then(Value::as_str) {
|
|
Some(d) => d.trim().to_string(),
|
|
None => {
|
|
let last_dir =
|
|
app.session.last_export.as_ref().and_then(|l| l.get("dir")).and_then(Value::as_str).map(str::trim).filter(|d| !d.is_empty());
|
|
last_dir.map(str::to_string).unwrap_or_else(default_export_dir)
|
|
}
|
|
};
|
|
if dir.is_empty() && exact.is_none() {
|
|
return Err(NO_EXPORT_FOLDER.into());
|
|
}
|
|
let to = Destination { dir: dir.clone(), exact };
|
|
// Add to Apple Photos: refused here, before anything is written, when it can't be done
|
|
let after = app.session.after_export(&opts)?;
|
|
let background = p.get("background").and_then(Value::as_bool).unwrap_or(false) && app.services.write_shared.is_some();
|
|
let out = if background {
|
|
let items = lightcraft_engine::export::prepare_batch(&mut app.session, &ids, &opts)?;
|
|
crate::export_task::start(app, items, opts, to, after)?
|
|
} else {
|
|
let w = app.services.write.as_mut().ok_or("no writer")?;
|
|
let files = export_batch(&mut app.session, &ids, &opts, &to, &mut |path, bytes| w(path, bytes), &|path| std::path::Path::new(path).exists())?;
|
|
let mut out = json!({"files": files});
|
|
// Photos may take minutes (and waits for the permission prompt): in the desktop app on a
|
|
// worker, never on this (UI) thread; the app announces the outcome and
|
|
// `export.photosImports` reports it. A window-less run (snapshot scripts) waits.
|
|
if let Some(photos) = after.finish(&app.session, &files) {
|
|
out["applePhotos"] = photos;
|
|
}
|
|
out
|
|
};
|
|
// remember for Export with Previous (and to prefill the dialog)
|
|
let mut last = p.clone();
|
|
if let Some(o) = last.as_object_mut() {
|
|
o.remove("ids");
|
|
o.remove("path");
|
|
o.insert("dir".into(), json!(dir));
|
|
}
|
|
app.session.last_export = Some(last);
|
|
let _ = app.session.save_prefs();
|
|
Ok(out)
|
|
}
|
|
|
|
pub fn save_screenshot(app: &mut LightcraftApp, image: &egui::ColorImage, path: Option<&str>) -> Value {
|
|
let [w, h] = image.size;
|
|
let Some(path) = path else {
|
|
return json!({"ok": true, "result": {"width": w, "height": h}});
|
|
};
|
|
let rgba: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
|
|
let Some(img) = lightcraft_raster::Rgba8::from_bytes(w, h, &rgba) else {
|
|
return json!({"ok": false, "error": "bad screenshot buffer"});
|
|
};
|
|
let Some(png) = app.services.png.as_ref() else { return json!({"ok": false, "error": "no encoder"}) };
|
|
let bytes = png(&img);
|
|
match app.services.write.as_mut() {
|
|
Some(wr) => match wr(path, &bytes) {
|
|
Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}),
|
|
Err(e) => json!({"ok": false, "error": e}),
|
|
},
|
|
None => json!({"ok": false, "error": "no writer configured"}),
|
|
}
|
|
}
|