M0.7/M0.8: Lightroom-style egui UI (top bar, My Photos, photo/square grid, loupe + filmstrip, Edit panel with histogram/curves/mixer/grading, crop/masking/remove/presets/info/keywords/versions/history, dialogs, shortcuts) + control channel + desktop app; engine file loading via codecs/raw

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 22:34:03 -04:00
parent b0d435f223
commit a7ea39573b
29 changed files with 4618 additions and 178 deletions

3038
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[workspace]
resolver = "3"
members = ["crates/*", "xtask"]
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.1.0"

View File

@ -17,7 +17,7 @@ cargo xtask ci # fmt, clippy, tests, lay
See [ROADMAP.md](ROADMAP.md) for milestones and estimates.
## The Craft family
## Crafting Apps
Clean-room, pure-Rust creative tools from the same workshop — native on macOS, Windows and Linux, and in the browser.

View File

@ -0,0 +1,23 @@
[package]
name = "lightcraft"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
default-run = "lightcraft"
[lints]
workspace = true
[dependencies]
lightcraft-engine = { workspace = true }
lightcraft-ui-egui = { workspace = true }
lightcraft-codecs = { workspace = true }
lightcraft-raster = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }

View File

@ -0,0 +1,62 @@
//! Loopback JSON-lines control server: one request per line, one reply per line.
//! This is the transport the MCP server (`lightcraft-cli mcp --connect`) wraps.
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use lightcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
eprintln!("lightcraft: control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
}
};
eprintln!("lightcraft: control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
let ctx = ctx.clone();
std::thread::spawn(move || serve(stream, tx, ctx));
}
});
rx
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
for line in BufReader::new(read).lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
r
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
};
if writeln!(out, "{reply}").is_err() {
break;
}
}
}

152
apps/lightcraft/src/main.rs Normal file
View File

@ -0,0 +1,152 @@
//! LightCraft desktop app.
//!
//! Usage: `lightcraft [--control <port>] [--demo] [files or folders…]`
//!
//! `--control <port>` (or `LIGHTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `lightcraft_ui_egui::control` for the methods.
mod control_server;
use lightcraft_engine::Session;
use lightcraft_ui_egui::{LightcraftApp, Services, UiState};
struct App(LightcraftApp);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.0.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
}
fn on_exit(&mut self) {
save_prefs(&self.0);
}
}
fn config_dir() -> Option<std::path::PathBuf> {
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/LightCraft"))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("LightCraft"))
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.map(|c| c.join("lightcraft"))
}
}
fn load_prefs(app: &mut LightcraftApp) {
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return;
}
if let Some(p) = config_dir().map(|d| d.join("ui.json"))
&& let Ok(bytes) = std::fs::read(&p)
&& let Ok(ui) = serde_json::from_slice::<UiState>(&bytes)
{
app.ui = ui.sanitized();
}
}
fn save_prefs(app: &LightcraftApp) {
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
return;
}
if let Some(d) = config_dir() {
let _ = std::fs::create_dir_all(&d);
if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) {
let _ = std::fs::write(d.join("ui.json"), bytes);
}
}
}
fn services() -> Services {
Services {
pick_files: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("Photos", &["jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "nef", "arw", "psd", "jxl", "gif", "bmp"])
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
})),
}
}
/// Expand folders (recursively) into photo files.
fn collect(path: &std::path::Path, out: &mut Vec<String>) {
if path.is_dir() {
if let Ok(rd) = std::fs::read_dir(path) {
let mut v: Vec<_> = rd.flatten().map(|e| e.path()).collect();
v.sort();
for p in v {
collect(&p, out);
}
}
} else if path.extension().is_some_and(|e| {
matches!(
e.to_string_lossy().to_lowercase().as_str(),
"jpg" | "jpeg" | "png" | "tif" | "tiff" | "webp" | "dng" | "cr2" | "nef" | "arw" | "psd" | "jxl" | "gif" | "bmp"
)
}) {
out.push(path.to_string_lossy().to_string());
}
}
fn main() -> eframe::Result {
let mut control_port: Option<u16> = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut demo = true;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--no-demo" => demo = false,
"--version" => {
println!("lightcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
_ => collect(std::path::Path::new(&a), &mut files),
}
}
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("LightCraft")
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([900.0, 560.0])
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false),
..Default::default()
};
eframe::run_native(
"LightCraft",
options,
Box::new(move |cc| {
let session = if demo && files.is_empty() { Session::with_demo() } else { Session::new() }.with_fs();
let mut app = LightcraftApp::new(session, services());
load_prefs(&mut app);
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
if !files.is_empty() {
let _ = app.run("library.import", serde_json::json!({"paths": files}));
app.ui.view = lightcraft_ui_egui::state::ViewMode::PhotoGrid;
}
Ok(Box::new(App(app)))
}),
)
}

View File

@ -140,6 +140,9 @@ pub struct Photo {
/// Video duration in seconds.
#[serde(default)]
pub duration: Option<f64>,
/// Raw files: the camera's as-shot white balance (Kelvin, tint).
#[serde(default)]
pub as_shot_wb: Option<(f64, f64)>,
}
impl Photo {
@ -165,6 +168,7 @@ impl Photo {
history: Vec::new(),
deleted: false,
duration: None,
as_shot_wb: None,
}
}
pub fn is_edited(&self) -> bool {

View File

@ -16,6 +16,9 @@ lightcraft-develop = { workspace = true }
lightcraft-scenes = { workspace = true }
lightcraft-pipeline = { workspace = true }
lightcraft-catalog = { workspace = true }
lightcraft-codecs = { workspace = true }
lightcraft-raw = { workspace = true }
lightcraft-meta = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

136
crates/engine/src/files.rs Normal file
View File

@ -0,0 +1,136 @@
//! Real files: probing (dimensions + metadata for import) and loading (decode → linear Rec.2020,
//! oriented, downscaled to the requested level). Standard formats go through `lightcraft-codecs`,
//! camera raws through `lightcraft-raw` (demosaic + DNG colour model).
use std::sync::Arc;
use lightcraft_catalog::{MediaKind, Meta};
use lightcraft_color::cct::xy_to_temp_tint;
use lightcraft_geom::Orientation;
use lightcraft_pipeline::SourceInfo;
use lightcraft_raster::Rgb32f;
use lightcraft_raster::resample::{Filter, fit};
use crate::media::{FileLoader, FileProbe, ProbeInfo};
fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
let shutter = m.exposure_time.map(|t| if t >= 1.0 { format!("{t:.0}") } else { format!("1/{:.0}", 1.0 / t) }).unwrap_or_default();
let camera = [m.make.clone().unwrap_or_default(), m.model.clone().unwrap_or_default()].join(" ").trim().to_string();
let meta = Meta {
camera,
lens: m.lens_model.clone().unwrap_or_default(),
focal_mm: m.focal_length.map(|f| f as f32),
aperture: m.f_number.map(|f| f as f32),
shutter,
iso: m.iso,
location: String::new(),
gps: m.gps.as_ref().map(|g| (g.latitude, g.longitude)),
title: m.title.clone().unwrap_or_default(),
caption: m.caption.clone().unwrap_or_default(),
copyright: m.copyright.clone().unwrap_or_default(),
creator: m.artist.clone().unwrap_or_default(),
keywords: m.keywords.clone(),
};
(meta, m.capture_time.as_ref().map(|d| d.to_iso()))
}
fn ext_upper(name: &str) -> String {
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default()
}
/// Probe a file's bytes: kind, dimensions (oriented), metadata.
pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
let m = lightcraft_meta::extract(bytes);
let (meta, captured) = meta_of(&m);
if lightcraft_raw::probe(bytes).is_some() {
let raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
let (mut w, mut h) = (raw.crop.width.max(1) as u32, raw.crop.height.max(1) as u32);
if w <= 1 || h <= 1 {
(w, h) = (raw.active_area.width as u32, raw.active_area.height as u32);
}
if raw.orientation.swaps_axes() {
std::mem::swap(&mut w, &mut h);
}
let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy(&raw));
let as_shot_wb = Some((t.round(), tint.round()));
return Ok(ProbeInfo {
width: w,
height: h,
format: ext_upper(name),
kind: MediaKind::Raw,
file_size: bytes.len() as u64,
captured,
meta,
as_shot_wb,
});
}
let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?;
if !fmt.can_decode() {
return Err(format!("{fmt:?} files are not supported yet"));
}
let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).map_err(|e| e.to_string())?;
let o = Orientation::from_exif(d.orientation);
let (mut w, mut h) = (d.source_width, d.source_height);
if o.swaps_axes() {
std::mem::swap(&mut w, &mut h);
}
let format = match ext_upper(name).as_str() {
"JPG" | "JPEG" => "JPEG".to_string(),
"" => format!("{fmt:?}").to_uppercase(),
e => e.to_string(),
};
Ok(ProbeInfo { width: w, height: h, format, kind: MediaKind::Image, file_size: bytes.len() as u64, captured, meta, as_shot_wb: None })
}
/// Decode a file into a linear Rec.2020 image no larger than `max_edge`, oriented.
pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> {
if lightcraft_raw::probe(bytes).is_some() {
let raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd };
let mut img = raw.develop(method).map_err(|e| e.to_string())?;
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
let t = lightcraft_raw::color::camera_transform(&raw, xy);
lightcraft_raw::highlight::reconstruct(&mut img, t.wb, 0.99);
let m = t.matrix.to_f32();
let gain = 2f32.powf(t.baseline_exposure as f32);
let wb = t.wb;
let img = img.map(|p| {
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
[
((m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2]) * gain).max(0.0),
((m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2]) * gain).max(0.0),
((m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2]) * gain).max(0.0),
]
});
let img = fit(&img, max_edge, max_edge, Filter::Box).oriented(raw.orientation);
let (temp, tint) = xy_to_temp_tint(xy);
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp.round(), as_shot_tint: tint.round() }));
}
let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).map_err(|e| e.to_string())?;
let img = d.to_working();
let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img };
Ok((img.oriented(Orientation::from_exif(d.orientation)), SourceInfo::default()))
}
/// Filesystem-backed hooks (native). On the web the host installs bytes-based hooks instead.
pub fn fs_hooks() -> (FileLoader, FileProbe) {
let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| {
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
load_bytes(&bytes, max_edge)
});
let probe: FileProbe = Arc::new(|path: &str| {
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
probe_bytes(path, &bytes)
});
(loader, probe)
}
impl crate::Session {
/// Install the filesystem file hooks (desktop, CLI, MCP headless).
pub fn with_fs(mut self) -> Self {
let (l, p) = fs_hooks();
self.media.file_loader = Some(l);
self.media.file_probe = Some(p);
self
}
}

View File

@ -12,6 +12,7 @@
pub mod cmd;
pub mod demo;
pub mod files;
pub mod media;
pub mod presets;
mod view;

View File

@ -200,6 +200,7 @@ pub struct ProbeInfo {
pub file_size: u64,
pub captured: Option<String>,
pub meta: lightcraft_catalog::Meta,
pub as_shot_wb: Option<(f64, f64)>,
}
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>;
@ -234,6 +235,10 @@ pub fn import_paths(s: &mut crate::Session, paths: &[String]) -> crate::Result<V
p.file_size = info.file_size;
p.captured = info.captured;
p.meta = info.meta;
p.as_shot_wb = info.as_shot_wb;
if let Some((t, tint)) = info.as_shot_wb {
p.develop = std::sync::Arc::new(lightcraft_develop::DevelopSettings::for_raw(t, tint));
}
ids.push(id);
ops.push(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) });
}

View File

@ -0,0 +1,325 @@
//! 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?}`, `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>,
}
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 }, rx)
}
}
pub enum Outcome {
Done(Value),
Screenshot { path: Option<String> },
}
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 mut v: Vec<Value> = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
for (id, label, sc, menu) in crate::menus::UI_COMMANDS {
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),
"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, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersDone": app.renderer.completed},
"status": app.ui.status,
})
}
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),
_ => 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.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" => {
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.clickWidget" {
let n = p.get("count").and_then(Value::as_u64).unwrap_or(1);
app.synthetic.push(egui::Event::PointerMoved(at));
for _ in 0..n {
app.synthetic.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
app.synthetic.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, 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)
}
"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.set" => {
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) => {
u.toast = app.ui.toast.clone();
u.dialog = app.ui.dialog.clone();
u.status = app.ui.status.clone();
app.ui = u;
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) => wrap(crate::panels::dialogs::confirm_dialog(app, &d)),
None => err("no dialog open"),
},
"ui.dialog.cancel" => {
app.ui.dialog = None;
ok(Value::Null)
}
"ui.resize" => {
let w = f("width").unwrap_or(1600.0) as f32;
let h = f("height").unwrap_or(1000.0) as f32;
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h)));
ok(Value::Null)
}
"ui.screenshot" => {
ctx.request_repaint();
Outcome::Screenshot { path: s("path").map(str::to_string) }
}
"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") };
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}`")),
}
}
/// Export the active photo (UI command `app.export`): renders at `longEdge` and writes a PNG/JPEG via services.
pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
let id = app.session.active().ok_or("no photo selected")?;
let size = p.get("longEdge").and_then(Value::as_u64).unwrap_or(3000) as usize;
let r = app.session.render_now(id, size, size)?;
let name = app.session.catalog.photo(id).map(|p| p.file_name.clone()).unwrap_or_else(|| "export".into());
let stem = name.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or(name);
let path = p.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| format!("{stem}-lightcraft.png"));
let png = app.services.png.as_ref().ok_or("no encoder")?;
let bytes = png(&r.image);
let w = app.services.write.as_mut().ok_or("no writer")?;
w(&path, &bytes)?;
Ok(json!({"path": path, "width": r.image.width, "height": r.image.height}))
}
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"}),
}
}

View File

@ -381,7 +381,14 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) {
Invert => {
pen.circle(10.0, 10.0, 7.0);
pen.p.add(Shape::convex_polygon(
(0..=16).map(|i| pen.pt(10.0 + 7.0 * ((90.0 + i as f32 * 180.0 / 16.0f32).to_radians()).cos(), 10.0 + 7.0 * ((90.0 + i as f32 * 180.0 / 16.0f32).to_radians()).sin())).collect(),
(0..=16)
.map(|i| {
pen.pt(
10.0 + 7.0 * ((90.0 + i as f32 * 180.0 / 16.0f32).to_radians()).cos(),
10.0 + 7.0 * ((90.0 + i as f32 * 180.0 / 16.0f32).to_radians()).sin(),
)
})
.collect(),
color,
Stroke::NONE,
));

View File

@ -208,7 +208,8 @@ impl LightcraftApp {
}
#[cfg(not(target_arch = "wasm32"))]
{
let dropped: Vec<String> = ctx.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.path.as_ref().map(|p| p.to_string_lossy().to_string())).collect());
let dropped: Vec<String> =
ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect());
if !dropped.is_empty() {
let _ = self.run("library.import", serde_json::json!({"paths": dropped}));
}
@ -227,12 +228,6 @@ impl LightcraftApp {
if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() {
raw.events.push(egui::Event::PointerMoved(*p));
}
if let Some(m) = self.synthetic.iter().take(n).find_map(|e| match e {
egui::Event::Key { modifiers, .. } | egui::Event::PointerButton { modifiers, .. } => Some(*modifiers),
_ => None,
}) {
raw.modifiers = m;
}
raw.events.extend(self.synthetic.drain(..n));
}
@ -282,3 +277,8 @@ pub fn now_ms() -> f64 {
0.0
}
}
/// Whether the settings render in black & white.
pub fn is_bw(d: &lightcraft_develop::DevelopSettings) -> bool {
d.treatment == lightcraft_develop::Treatment::Bw || d.profile.id == "lc.mono"
}

347
crates/ui-egui/src/menus.rs Normal file
View File

@ -0,0 +1,347 @@
//! UI-level commands (views, panels, zoom, tools, dialogs) and the menu model shared by the native
//! menu bar, the shortcut handler and the control channel.
use serde::Serialize;
use serde_json::{Value, json};
use crate::LightcraftApp;
use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom};
/// (id, label, shortcut, menu path)
pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static str);
pub const UI_COMMANDS: &[UiCommand] = &[
("view.photoGrid", "Photo Grid", Some("G"), "View"),
("view.squareGrid", "Square Grid", Some("Shift+G"), "View"),
("view.detail", "Detail", Some("D"), "View"),
("view.filmstrip", "Filmstrip", Some("/"), "View"),
("view.leftPanel", "My Photos Panel", Some("Cmd+Shift+L"), "View"),
("view.beforeAfter", "Compare Before and After", Some("Y"), "View"),
("view.beforeAfterSplit", "Before/After Split", Some("Shift+Y"), "View"),
("view.showOriginal", "Show Original", Some("\\"), "View"),
("view.zoomFit", "Zoom to Fit", Some("Cmd+0"), "View"),
("view.zoom100", "Zoom 100%", Some("Cmd+1"), "View"),
("view.zoomToggle", "Toggle Zoom", Some("Z"), "View"),
("view.zoomIn", "Zoom In", Some("Cmd+="), "View"),
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"),
("view.clipping", "Show Clipping", Some("J"), "View"),
("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"),
("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"),
("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"),
("view.back", "Back to Grid", Some("Escape"), "View"),
("view.filterBar", "Filter", None, "View"),
("panel.edit", "Edit", Some("E"), "View"),
("panel.crop", "Crop & Rotate", Some("C"), "View"),
("panel.remove", "Remove", Some("H"), "View"),
("panel.masking", "Masking", Some("M"), "View"),
("panel.redeye", "Red Eye", None, "View"),
("panel.presets", "Presets", Some("Shift+P"), "View"),
("panel.info", "Info", Some("I"), "View"),
("panel.keywords", "Keywords", Some("K"), "View"),
("panel.versions", "Versions", Some("Shift+V"), "View"),
("panel.activity", "History", Some("Shift+Y"), "View"),
("panel.close", "Close Panel", None, "View"),
("section.light", "Light", Some("Cmd+Alt+1"), "View"),
("section.color", "Color", Some("Cmd+Alt+2"), "View"),
("section.effects", "Effects", Some("Cmd+Alt+3"), "View"),
("section.detail", "Detail", Some("Cmd+Alt+4"), "View"),
("section.optics", "Optics", Some("Cmd+Alt+5"), "View"),
("tool.brush", "Brush", Some("B"), "View"),
("tool.linear", "Linear Gradient", Some("L"), "View"),
("tool.radial", "Radial Gradient", Some("R"), "View"),
("tool.wbPicker", "White Balance Selector", Some("W"), "View"),
("tool.none", "No Tool", None, "View"),
("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"),
("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"),
("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Photo"),
("dialog.export", "Export…", Some("Cmd+Shift+E"), "File"),
("file.addPhotos", "Add Photos…", Some("Cmd+Shift+I"), "File"),
("app.about", "About LightCraft", None, "LightCraft"),
("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"),
("app.export", "Export Now", None, ""),
];
fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) {
if app.ui.right == p {
app.ui.right = RightPanel::None;
app.toast(ctx, format!("{name} Off"));
} else {
app.ui.right = p;
app.toast(ctx, format!("{name} On"));
if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) {
app.ui.view = ViewMode::Detail;
}
}
if p != RightPanel::Masking && app.ui.tool != "wbPicker" {
app.ui.tool.clear();
}
let _ = app.session.end_interaction();
}
/// Handle UI commands; `None` means "not a UI command — send it to the engine".
pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let ctx = egui::Context::default();
let r: Result<Value, String> = match id {
"view.photoGrid" => {
app.ui.view = ViewMode::PhotoGrid;
Ok(Value::Null)
}
"view.squareGrid" => {
app.ui.view = ViewMode::SquareGrid;
Ok(Value::Null)
}
"view.detail" => {
app.ui.view = ViewMode::Detail;
Ok(Value::Null)
}
"view.back" => {
if app.ui.dialog.is_some() {
app.ui.dialog = None;
} else if !app.ui.tool.is_empty() {
app.ui.tool.clear();
} else if app.ui.view == ViewMode::Detail {
app.ui.view = ViewMode::PhotoGrid;
}
Ok(Value::Null)
}
"view.filmstrip" => {
app.ui.filmstrip = !app.ui.filmstrip;
Ok(Value::Null)
}
"view.leftPanel" => {
app.ui.left_panel = !app.ui.left_panel;
Ok(Value::Null)
}
"view.beforeAfter" => {
app.ui.before_after = if app.ui.before_after == BeforeAfter::SideBySide { BeforeAfter::Off } else { BeforeAfter::SideBySide };
Ok(Value::Null)
}
"view.beforeAfterSplit" => {
app.ui.before_after = if app.ui.before_after == BeforeAfter::Split { BeforeAfter::Off } else { BeforeAfter::Split };
Ok(Value::Null)
}
"view.showOriginal" => {
app.ui.before_after = if app.ui.before_after == BeforeAfter::Original { BeforeAfter::Off } else { BeforeAfter::Original };
Ok(Value::Null)
}
"view.zoomFit" => {
app.ui.zoom = Zoom::Fit;
Ok(Value::Null)
}
"view.zoom100" => {
app.ui.zoom = Zoom::Percent(100);
Ok(Value::Null)
}
"view.zoomToggle" => {
app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(100) } else { Zoom::Fit };
Ok(Value::Null)
}
"view.zoomIn" | "view.zoomOut" => {
let steps = [25u32, 50, 100, 200, 400, 800];
let cur = match app.ui.zoom {
Zoom::Percent(p) => p,
_ => 25,
};
let next = if id == "view.zoomIn" {
steps.iter().find(|s| **s > cur).copied().unwrap_or(800)
} else {
steps.iter().rev().find(|s| **s < cur).copied().unwrap_or(0)
};
app.ui.zoom = if next == 0 { Zoom::Fit } else { Zoom::Percent(next) };
Ok(Value::Null)
}
"view.clipping" => {
app.ui.show_clipping = !app.ui.show_clipping;
Ok(Value::Null)
}
"view.histogram" => {
app.ui.histogram = !app.ui.histogram;
Ok(Value::Null)
}
"view.maskOverlay" => {
app.ui.mask_overlay = !app.ui.mask_overlay;
Ok(Value::Null)
}
"view.cropOverlay" => {
use crate::state::CropOverlay::*;
app.ui.crop_overlay = match app.ui.crop_overlay {
Thirds => Grid,
Grid => Golden,
Golden => Diagonal,
Diagonal => None,
None => Thirds,
};
Ok(Value::Null)
}
"view.filterBar" => {
app.ui.left_panel = true;
Ok(Value::Null)
}
"panel.edit" => {
panel(app, &ctx, RightPanel::Edit, "Edit");
Ok(Value::Null)
}
"panel.crop" => {
panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry");
Ok(Value::Null)
}
"panel.remove" => {
panel(app, &ctx, RightPanel::Remove, "Remove");
if app.ui.right == RightPanel::Remove && app.ui.tool.is_empty() {
app.ui.tool = "remove".into();
}
Ok(Value::Null)
}
"panel.masking" => {
panel(app, &ctx, RightPanel::Masking, "Masking");
Ok(Value::Null)
}
"panel.redeye" => {
panel(app, &ctx, RightPanel::RedEye, "Red Eye");
Ok(Value::Null)
}
"panel.info" => {
panel(app, &ctx, RightPanel::Info, "Info");
Ok(Value::Null)
}
"panel.keywords" => {
panel(app, &ctx, RightPanel::Keywords, "Keywords");
Ok(Value::Null)
}
"panel.versions" => {
panel(app, &ctx, RightPanel::Versions, "Versions");
Ok(Value::Null)
}
"panel.activity" => {
panel(app, &ctx, RightPanel::Activity, "History");
Ok(Value::Null)
}
"panel.presets" => {
app.ui.presets = !app.ui.presets;
if app.ui.presets && app.ui.view != ViewMode::Detail {
app.ui.view = ViewMode::Detail;
}
Ok(Value::Null)
}
"panel.close" => {
app.ui.right = RightPanel::None;
app.ui.presets = false;
Ok(Value::Null)
}
s if s.starts_with("section.") => {
let sec = &s["section.".len()..];
if app.ui.right != RightPanel::Edit {
app.ui.right = RightPanel::Edit;
}
app.ui.toggle_section(sec);
Ok(Value::Null)
}
s if s.starts_with("tool.") => {
let tool = &s["tool.".len()..];
match tool {
"none" => app.ui.tool.clear(),
"brush" => {
app.ui.right = RightPanel::Masking;
app.ui.view = ViewMode::Detail;
app.ui.tool = "brush".into();
}
"linear" | "radial" => {
app.ui.right = RightPanel::Masking;
app.ui.view = ViewMode::Detail;
app.ui.tool = tool.into();
return Some(app.session.execute("mask.add", &json!({"kind": tool})).map_err(|e| e.to_string()));
}
"wbPicker" => {
app.ui.right = RightPanel::Edit;
app.ui.view = ViewMode::Detail;
app.ui.tool = "wbPicker".into();
}
other => return Some(Err(format!("unknown tool `{other}`"))),
}
Ok(Value::Null)
}
"dialog.newAlbum" => {
app.ui.dialog = Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: false });
Ok(Value::Null)
}
"dialog.createPreset" => {
app.ui.dialog = Some(Dialog::CreatePreset { name: String::new(), group: "User Presets".into() });
Ok(Value::Null)
}
"dialog.copySettings" => {
let groups =
app.session.copy_groups.iter().filter_map(|g| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string))).collect();
app.ui.dialog = Some(Dialog::CopySettings { groups });
Ok(Value::Null)
}
"dialog.export" => {
app.ui.dialog = Some(Dialog::Export { format: "jpeg".into(), quality: 90, long_edge: 3000 });
Ok(Value::Null)
}
"app.about" => {
app.ui.dialog = Some(Dialog::About);
Ok(Value::Null)
}
"app.shortcuts" => {
app.ui.dialog = Some(Dialog::Shortcuts);
Ok(Value::Null)
}
"file.addPhotos" => {
let paths = match p.get("paths").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
None => app.services.pick_files.as_mut().map(|f| f()).unwrap_or_default(),
};
if paths.is_empty() {
return Some(Ok(Value::Null));
}
return Some(app.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string()));
}
"app.export" => crate::control::export_active(app, p),
_ => return None,
};
Some(r)
}
pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
match id {
s if s.starts_with("panel.") || s.starts_with("tool.") || s.starts_with("section.") => app.session.active().is_some() || s == "panel.close",
"app.export" | "dialog.export" | "dialog.createPreset" | "dialog.copySettings" => app.session.active().is_some(),
_ => true,
}
}
#[derive(Clone, Debug, Serialize)]
pub struct MenuEntry {
pub id: String,
pub label: String,
pub menu: Vec<String>,
pub shortcut: Option<String>,
pub enabled: bool,
}
/// The flattened menu model (UI commands + engine commands with menu paths).
pub fn menu_entries(app: &LightcraftApp) -> Vec<MenuEntry> {
let mut v: Vec<MenuEntry> = UI_COMMANDS
.iter()
.filter(|c| !c.3.is_empty())
.map(|(id, label, sc, m)| MenuEntry {
id: id.to_string(),
label: label.to_string(),
menu: vec![m.to_string()],
shortcut: sc.map(str::to_string),
enabled: ui_enabled(app, id),
})
.collect();
for c in app.session.commands() {
if !c.menu.is_empty() {
v.push(MenuEntry {
id: c.id.into(),
label: c.label.into(),
menu: c.menu.iter().map(|s| s.to_string()).collect(),
shortcut: c.shortcut.map(str::to_string),
enabled: c.enabled,
});
}
}
v
}

View File

@ -40,7 +40,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned());
let pill = Rect::from_center_size(pos2(full.center().x - 60.0, full.center().y), vec2(196.0, 30.0));
ui.painter().rect_filled(pill, 15.0, t.canvas);
let mut pill_ui = ui.new_child(egui::UiBuilder::new().max_rect(pill.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
let mut pill_ui =
ui.new_child(egui::UiBuilder::new().max_rect(pill.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
pill_ui.spacing_mut().item_spacing.x = 3.0;
let rating = active.as_ref().map(|p| p.rating).unwrap_or(0);
if let Some(r) = stars(&mut pill_ui, "bottom", rating, 19.0) {
@ -48,7 +49,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
pill_ui.add_space(6.0);
let flag = active.as_ref().map(|p| p.flag).unwrap_or_default();
for (id, icon, f) in [("unflag", Icon::Circle, Flag::None), ("pick", Icon::FlagPick, Flag::Pick), ("reject", Icon::FlagReject, Flag::Reject)] {
for (id, icon, f) in
[("unflag", Icon::Circle, Flag::None), ("pick", Icon::FlagPick, Flag::Pick), ("reject", Icon::FlagReject, Flag::Reject)]
{
let (r, resp) = pill_ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
register(pill_ui.ctx(), format!("flag:{id}"), r);
let on = flag == f && f != Flag::None;
@ -71,7 +74,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let cresp = ui.interact(copy_r, egui::Id::new("copy-settings"), Sense::click());
register(ui.ctx(), "button:copySettings", copy_r);
ui.painter().rect_filled(copy_r, 15.0, if cresp.hovered() { t.hover } else { t.canvas });
ui.painter().text(copy_r.center(), Align2::CENTER_CENTER, label, t.font(13.0), if active.is_some() { t.text_label } else { t.text_disabled });
ui.painter().text(
copy_r.center(),
Align2::CENTER_CENTER,
label,
t.font(13.0),
if active.is_some() { t.text_label } else { t.text_disabled },
);
if cresp.clicked() && active.is_some() {
let _ = if has_clip { app.run("develop.paste", json!({})) } else { app.run("develop.copy", json!({})) };
let msg = if has_clip { "Settings pasted" } else { "Edit settings copied" };
@ -87,10 +96,24 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
// right side
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(pos2(full.right() - 360.0, full.top()), full.right_bottom())).layout(egui::Layout::right_to_left(egui::Align::Center)));
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(Rect::from_min_max(pos2(full.right() - 360.0, full.top()), full.right_bottom()))
.layout(egui::Layout::right_to_left(egui::Align::Center)),
);
child.spacing_mut().item_spacing.x = 6.0;
if matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) {
if icon_button(&mut child, "beforeAfter", Icon::BeforeAfter, vec2(30.0, 30.0), app.ui.before_after != BeforeAfter::Off, true, "Before/After (Y)").clicked() {
if icon_button(
&mut child,
"beforeAfter",
Icon::BeforeAfter,
vec2(30.0, 30.0),
app.ui.before_after != BeforeAfter::Off,
true,
"Before/After (Y)",
)
.clicked()
{
let _ = app.run("view.beforeAfter", json!({}));
}
if icon_button(&mut child, "filmstrip", Icon::Filmstrip, vec2(30.0, 30.0), app.ui.filmstrip, true, "Filmstrip (/)").clicked() {
@ -102,10 +125,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
Zoom::Fill => "Fill".to_string(),
Zoom::Percent(p) => format!("{p}%"),
};
let zr = child.add(egui::Button::new(egui::RichText::new(format!("{zoom_label} ⌄")).font(t.font(13.0)).color(t.text_label)).frame(false));
register(child.ctx(), "button:zoom", zr.rect);
let zr = crate::widgets::dropdown(&mut child, "zoom", &zoom_label, t.font(13.0), t.text_label);
egui::Popup::menu(&zr).show(|ui| {
for (label, z) in [("Fit", Zoom::Fit), ("Fill", Zoom::Fill), ("50%", Zoom::Percent(50)), ("100%", Zoom::Percent(100)), ("200%", Zoom::Percent(200)), ("400%", Zoom::Percent(400))] {
for (label, z) in [
("Fit", Zoom::Fit),
("Fill", Zoom::Fill),
("50%", Zoom::Percent(50)),
("100%", Zoom::Percent(100)),
("200%", Zoom::Percent(200)),
("400%", Zoom::Percent(400)),
] {
if ui.selectable_label(app.ui.zoom == z, label).clicked() {
app.ui.zoom = z;
app.ui.pan = (0.5, 0.5);

View File

@ -106,7 +106,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let split = app.ui.before_after == BeforeAfter::SideBySide;
let areas: Vec<Rect> = if split {
let half = area.width() / 2.0 - 6.0;
vec![Rect::from_min_size(area.min, vec2(half, area.height())), Rect::from_min_size(pos2(area.center().x + 6.0, area.top()), vec2(half, area.height()))]
vec![
Rect::from_min_size(area.min, vec2(half, area.height())),
Rect::from_min_size(pos2(area.center().x + 6.0, area.top()), vec2(half, area.height())),
]
} else {
vec![area]
};
@ -122,10 +125,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
app.renderer.request(Slot::Main, job, 100);
}
let show_before = app.ui.before_after == BeforeAfter::Original || ui.input(|i| i.key_down(egui::Key::Backslash));
if split || show_before || app.ui.before_after == BeforeAfter::Split {
if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), true, !crop_tool) {
app.renderer.request(Slot::Before, job, 90);
}
if (split || show_before || app.ui.before_after == BeforeAfter::Split)
&& let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), true, !crop_tool)
{
app.renderer.request(Slot::Before, job, 90);
}
let p = ui.painter_at(canvas);
let draw = |slot: Slot, r: Rect| {
@ -187,7 +190,16 @@ fn clipping_overlay(app: &LightcraftApp, p: &egui::Painter, r: Rect) {
}
}
fn general_interaction(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, img: Rect, canvas: Rect, native: [usize; 2], aspect: f32) {
fn general_interaction(
app: &mut LightcraftApp,
ui: &mut egui::Ui,
resp: &egui::Response,
map: &CanvasMap,
img: Rect,
canvas: Rect,
native: [usize; 2],
aspect: f32,
) {
if app.ui.tool == "wbPicker" {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
if resp.clicked()
@ -260,9 +272,7 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
p.line_segment([lerp(pts[0], pts[3], f), lerp(pts[1], pts[2], f)], guide);
}
// handles: 0..3 corners, 4..7 edges (top, right, bottom, left)
let handles: Vec<Pos2> = (0..8)
.map(|i| if i < 4 { pts[i] } else { lerp(pts[i - 4], pts[(i - 3) % 4], 0.5) })
.collect();
let handles: Vec<Pos2> = (0..8).map(|i| if i < 4 { pts[i] } else { lerp(pts[i - 4], pts[(i - 3) % 4], 0.5) }).collect();
for (i, h) in handles.iter().enumerate() {
register(ui.ctx(), format!("cropHandle:{i}"), Rect::from_center_size(*h, vec2(14.0, 14.0)));
let s = if i < 4 { 12.0 } else { 9.0 };
@ -402,7 +412,10 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
if sel && app.ui.mask_overlay {
p.add(egui::Shape::convex_polygon(pts.clone(), red, Stroke::NONE));
}
p.add(egui::Shape::closed_line(pts, Stroke::new(if sel { 1.5 } else { 1.0 }, Color32::from_white_alpha(if sel { 230 } else { 120 }))));
p.add(egui::Shape::closed_line(
pts,
Stroke::new(if sel { 1.5 } else { 1.0 }, Color32::from_white_alpha(if sel { 230 } else { 120 })),
));
pin(p, c0, sel);
if sel {
register(ui.ctx(), format!("maskPin:{}", m.id), Rect::from_center_size(c0, vec2(14.0, 14.0)));
@ -626,7 +639,13 @@ fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
}
if resp.clicked() {
let m = ui.input(|i| i.modifiers);
let mode = if m.command { "toggle" } else if m.shift { "range" } else { "replace" };
let mode = if m.command {
"toggle"
} else if m.shift {
"range"
} else {
"replace"
};
let _ = app.run("library.select", json!({"ids": [id.0], "mode": mode}));
}
}

View File

@ -24,89 +24,92 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::About => "About LightCraft",
Dialog::Shortcuts => "Keyboard Shortcuts",
};
egui::Window::new(title).collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).default_width(380.0).show(ctx, |ui| {
ui.spacing_mut().item_spacing.y = 8.0;
match &mut dlg {
Dialog::NewAlbum { name, .. } | Dialog::RenameAlbum { name, .. } => {
let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY));
r.request_focus();
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
confirm = true;
egui::Window::new(title).collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).default_width(380.0).show(
ctx,
|ui| {
ui.spacing_mut().item_spacing.y = 8.0;
match &mut dlg {
Dialog::NewAlbum { name, .. } | Dialog::RenameAlbum { name, .. } => {
let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY));
r.request_focus();
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
confirm = true;
}
}
}
Dialog::CreatePreset { name, group } => {
ui.add(egui::TextEdit::singleline(name).hint_text("Preset name").desired_width(f32::INFINITY));
ui.add(egui::TextEdit::singleline(group).hint_text("Group").desired_width(f32::INFINITY));
ui.label(egui::RichText::new("Includes the current settings except crop, masks and remove.").color(t.text_dim));
}
Dialog::CopySettings { groups } => {
ui.columns(2, |cols| {
for (i, g) in SettingsGroup::ALL.iter().enumerate() {
let key = serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
let mut on = groups.contains(&key);
if cols[i % 2].checkbox(&mut on, g.label()).changed() {
if on {
groups.push(key);
} else {
groups.retain(|x| *x != key);
}
}
}
});
}
Dialog::Export { format, quality, long_edge } => {
ui.horizontal(|ui| {
ui.label("Format");
for f in ["jpeg", "png", "tiff"] {
ui.selectable_value(format, f.to_string(), f.to_uppercase());
}
});
ui.add(egui::Slider::new(quality, 10..=100).text("Quality"));
ui.add(egui::Slider::new(long_edge, 512..=8000).text("Long edge (px)"));
}
Dialog::About => {
ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text));
ui.label(format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
ui.label("MIT OR Apache-2.0. Fonts: Source Sans 3 (OFL). Icons: original.");
}
Dialog::Shortcuts => {
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
egui::Grid::new("shortcuts").striped(true).show(ui, |ui| {
for (id, label, sc, _) in crate::menus::UI_COMMANDS {
if let Some(sc) = sc {
ui.label(*label);
ui.label(*sc);
ui.label(egui::RichText::new(*id).color(t.text_dim));
ui.end_row();
}
}
for c in lightcraft_engine::command_specs() {
if let Some(sc) = c.shortcut {
ui.label(c.label);
ui.label(sc);
ui.label(egui::RichText::new(c.id).color(t.text_dim));
ui.end_row();
Dialog::CreatePreset { name, group } => {
ui.add(egui::TextEdit::singleline(name).hint_text("Preset name").desired_width(f32::INFINITY));
ui.add(egui::TextEdit::singleline(group).hint_text("Group").desired_width(f32::INFINITY));
ui.label(egui::RichText::new("Includes the current settings except crop, masks and remove.").color(t.text_dim));
}
Dialog::CopySettings { groups } => {
ui.columns(2, |cols| {
for (i, g) in SettingsGroup::ALL.iter().enumerate() {
let key = serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
let mut on = groups.contains(&key);
if cols[i % 2].checkbox(&mut on, g.label()).changed() {
if on {
groups.push(key);
} else {
groups.retain(|x| *x != key);
}
}
}
});
});
}
}
ui.add_space(4.0);
ui.horizontal(|ui| {
let informational = matches!(dlg, Dialog::About | Dialog::Shortcuts);
if !informational && ui.button("Cancel").clicked() {
close = true;
}
if ui.button(if informational { "Close" } else { "OK" }).clicked() {
if informational {
close = true;
} else {
confirm = true;
}
Dialog::Export { format, quality, long_edge } => {
ui.horizontal(|ui| {
ui.label("Format");
for f in ["jpeg", "png", "tiff"] {
ui.selectable_value(format, f.to_string(), f.to_uppercase());
}
});
ui.add(egui::Slider::new(quality, 10..=100).text("Quality"));
ui.add(egui::Slider::new(long_edge, 512..=8000).text("Long edge (px)"));
}
Dialog::About => {
ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text));
ui.label(format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
ui.label("MIT OR Apache-2.0. Fonts: Source Sans 3 (OFL). Icons: original.");
}
Dialog::Shortcuts => {
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
egui::Grid::new("shortcuts").striped(true).show(ui, |ui| {
for (id, label, sc, _) in crate::menus::UI_COMMANDS {
if let Some(sc) = sc {
ui.label(*label);
ui.label(*sc);
ui.label(egui::RichText::new(*id).color(t.text_dim));
ui.end_row();
}
}
for c in lightcraft_engine::command_specs() {
if let Some(sc) = c.shortcut {
ui.label(c.label);
ui.label(sc);
ui.label(egui::RichText::new(c.id).color(t.text_dim));
ui.end_row();
}
}
});
});
}
}
});
});
ui.add_space(4.0);
ui.horizontal(|ui| {
let informational = matches!(dlg, Dialog::About | Dialog::Shortcuts);
if !informational && ui.button("Cancel").clicked() {
close = true;
}
if ui.button(if informational { "Close" } else { "OK" }).clicked() {
if informational {
close = true;
} else {
confirm = true;
}
}
});
},
);
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
close = true;
}
@ -122,7 +125,9 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
match dlg {
Dialog::NewAlbum { name, folder } => app.run("album.create", json!({"name": name, "folder": folder, "addSelected": !folder})),
Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})),
Dialog::CreatePreset { name, group } => app.run("preset.create", json!({"name": if name.is_empty() { "My Preset" } else { name }, "group": group})),
Dialog::CreatePreset { name, group } => {
app.run("preset.create", json!({"name": if name.is_empty() { "My Preset" } else { name }, "group": group}))
}
Dialog::CopySettings { groups } => app.run("develop.copy", json!({"groups": groups})),
Dialog::Export { format, quality, long_edge } => app.run("app.export", json!({"format": format, "quality": quality, "longEdge": long_edge})),
Dialog::About | Dialog::Shortcuts => Ok(serde_json::Value::Null),

View File

@ -40,8 +40,28 @@ fn control(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id:
}
/// Relative temperature scale for rendered (non-raw) files: −100..100 ↔ Kelvin via mired shift.
const REL_TEMP: ControlSpec = ControlSpec { id: "wb.tempRel", label: "Temp", section: Section::Color, min: -100.0, max: 100.0, default: 0.0, step: 1.0, decimals: 0, track: Track::Temp };
const REL_TINT: ControlSpec = ControlSpec { id: "wb.tintRel", label: "Tint", section: Section::Color, min: -100.0, max: 100.0, default: 0.0, step: 1.0, decimals: 0, track: Track::Tint };
const REL_TEMP: ControlSpec = ControlSpec {
id: "wb.tempRel",
label: "Temp",
section: Section::Color,
min: -100.0,
max: 100.0,
default: 0.0,
step: 1.0,
decimals: 0,
track: Track::Temp,
};
const REL_TINT: ControlSpec = ControlSpec {
id: "wb.tintRel",
label: "Tint",
section: Section::Color,
min: -100.0,
max: 100.0,
default: 0.0,
step: 1.0,
decimals: 0,
track: Track::Tint,
};
fn k_to_rel(k: f64) -> f64 {
((1e6 / 6500.0 - 1e6 / k) / 0.8).clamp(-100.0, 100.0)
@ -85,8 +105,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Profile").font(t.font(13.0)).color(t.text_dim));
let name = lightcraft_engine::presets::PROFILES.iter().find(|p| p.id == d.profile.id).map(|p| p.name).unwrap_or("Color");
let r = ui.add(egui::Button::new(egui::RichText::new(format!("{name} ⌄")).font(t.font(15.0)).color(t.text_label)).frame(false));
register(ui.ctx(), "button:profile", r.rect);
let r = crate::widgets::dropdown(ui, "profile", name, t.font(15.0), t.text_label);
egui::Popup::menu(&r).show(|ui| {
for p in lightcraft_engine::presets::PROFILES {
if ui.selectable_label(p.id == d.profile.id, p.name).clicked() {
@ -117,8 +136,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 2 }).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("White Balance").font(t.font(13.0)).color(t.text_dim));
let r = ui.add(egui::Button::new(egui::RichText::new(format!("{} ⌄", d.wb.mode.label())).font(t.font(14.0)).color(t.text_label)).frame(false));
register(ui.ctx(), "button:wbMode", r.rect);
let r = crate::widgets::dropdown(ui, "wbMode", d.wb.mode.label(), t.font(14.0), t.text_label);
egui::Popup::menu(&r).show(|ui| {
for m in WbMode::ALL {
if m == WbMode::Custom {
@ -132,7 +150,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let active = app.ui.tool == "wbPicker";
if crate::widgets::icon_button(ui, "wbPicker", Icon::Picker, vec2(28.0, 28.0), active, true, "White Balance Selector (W)").clicked() {
if crate::widgets::icon_button(ui, "wbPicker", Icon::Picker, vec2(28.0, 28.0), active, true, "White Balance Selector (W)")
.clicked()
{
app.ui.tool = if active { String::new() } else { "wbPicker".into() };
}
});
@ -186,7 +206,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
control(app, ui, d, c, c == "detail.sharpenAmount" || d.detail.sharpen_amount > 0.0);
}
sub_title(ui, "Noise Reduction");
for c in ["detail.nrLuminance", "detail.nrDetail", "detail.nrContrast", "detail.nrColor", "detail.nrColorDetail", "detail.nrColorSmoothness"] {
for c in ["detail.nrLuminance", "detail.nrDetail", "detail.nrContrast", "detail.nrColor", "detail.nrColorDetail", "detail.nrColorSmoothness"]
{
control(app, ui, d, c, true);
}
ui.add_space(8.0);
@ -216,7 +237,14 @@ fn sub_title(ui: &mut egui::Ui, title: &str) {
ui.painter().text(pos2(r.left() + 24.0, r.center().y + 4.0), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label);
}
fn section(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: &str, title: &str, body: impl FnOnce(&mut LightcraftApp, &mut egui::Ui, &DevelopSettings)) {
fn section(
app: &mut LightcraftApp,
ui: &mut egui::Ui,
d: &DevelopSettings,
id: &str,
title: &str,
body: impl FnOnce(&mut LightcraftApp, &mut egui::Ui, &DevelopSettings),
) {
let open = app.ui.section_open(id);
let (resp, toggled) = section_header(ui, id, title, open, Some(d.section_enabled(id)));
if let Some(on) = toggled {
@ -308,7 +336,13 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev
// channel selector
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 4 }).show(ui, |ui| {
ui.horizontal(|ui| {
for (ch, c) in [("parametric", Color32::from_gray(200)), ("master", Color32::WHITE), ("red", hex("#dd3333")), ("green", hex("#33bb55")), ("blue", hex("#3377ee"))] {
for (ch, c) in [
("parametric", Color32::from_gray(200)),
("master", Color32::WHITE),
("red", hex("#dd3333")),
("green", hex("#33bb55")),
("blue", hex("#3377ee")),
] {
let (r, resp) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::click());
register(ui.ctx(), format!("curveChannel:{ch}"), r);
let sel = app.ui.curve_channel == ch;
@ -340,8 +374,14 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev
p.rect_filled(r, 2.0, t.canvas);
for i in 1..4 {
let f = i as f32 / 4.0;
p.line_segment([pos2(r.left() + r.width() * f, r.top()), pos2(r.left() + r.width() * f, r.bottom())], Stroke::new(1.0, Color32::from_gray(52)));
p.line_segment([pos2(r.left(), r.top() + r.height() * f), pos2(r.right(), r.top() + r.height() * f)], Stroke::new(1.0, Color32::from_gray(52)));
p.line_segment(
[pos2(r.left() + r.width() * f, r.top()), pos2(r.left() + r.width() * f, r.bottom())],
Stroke::new(1.0, Color32::from_gray(52)),
);
p.line_segment(
[pos2(r.left(), r.top() + r.height() * f), pos2(r.right(), r.top() + r.height() * f)],
Stroke::new(1.0, Color32::from_gray(52)),
);
}
p.line_segment([r.left_bottom(), r.right_top()], Stroke::new(1.0, Color32::from_gray(70)));
let to_screen = |x: f64, y: f64| pos2(r.left() + x as f32 * r.width(), r.bottom() - y as f32 * r.height());
@ -349,7 +389,12 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev
if ch == "parametric" {
// draw the effective parametric curve by sampling the pipeline's region curve approximation
let c = &d.curve;
let regions = [(0.0, c.split_shadows / 100.0, c.shadows), (c.split_shadows / 100.0, c.split_mid / 100.0, c.darks), (c.split_mid / 100.0, c.split_highlights / 100.0, c.lights), (c.split_highlights / 100.0, 1.0, c.highlights)];
let regions = [
(0.0, c.split_shadows / 100.0, c.shadows),
(c.split_shadows / 100.0, c.split_mid / 100.0, c.darks),
(c.split_mid / 100.0, c.split_highlights / 100.0, c.lights),
(c.split_highlights / 100.0, 1.0, c.highlights),
];
let pts: Vec<Pos2> = (0..=64)
.map(|i| {
let x = i as f64 / 64.0;

View File

@ -10,7 +10,16 @@ use crate::icons::{Icon, paint};
use crate::theme::Tokens;
use crate::widgets::{icon_button, register};
fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, icon: Icon, label: &str, count: Option<usize>, selected: bool, indent: f32) -> egui::Response {
fn row(
app: &mut LightcraftApp,
ui: &mut egui::Ui,
id: &str,
icon: Icon,
label: &str,
count: Option<usize>,
selected: bool,
indent: f32,
) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::click());
register(ui.ctx(), format!("source:{id}"), r);
@ -20,8 +29,19 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, icon: Icon, label:
} else if resp.hovered() {
ui.painter().rect_filled(inner, 4.0, t.hover.gamma_multiply(0.6));
}
paint(ui.painter(), Rect::from_min_size(pos2(r.left() + 18.0 + indent, r.center().y - 8.0), vec2(16.0, 16.0)), icon, if selected { t.text } else { t.icon });
ui.painter().text(pos2(r.left() + 42.0 + indent, r.center().y), Align2::LEFT_CENTER, label, t.font(13.5), if selected { t.text } else { t.text_label });
paint(
ui.painter(),
Rect::from_min_size(pos2(r.left() + 18.0 + indent, r.center().y - 8.0), vec2(16.0, 16.0)),
icon,
if selected { t.text } else { t.icon },
);
ui.painter().text(
pos2(r.left() + 42.0 + indent, r.center().y),
Align2::LEFT_CENTER,
label,
t.font(13.5),
if selected { t.text } else { t.text_label },
);
if let Some(n) = count {
ui.painter().text(pos2(r.right() - 18.0, r.center().y), Align2::RIGHT_CENTER, n.to_string(), t.font(12.5), t.text_dim);
}
@ -58,7 +78,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
// Albums header
let (ar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
ui.painter().text(pos2(ar.left() + 18.0, ar.center().y), Align2::LEFT_CENTER, "Albums", t.semibold(13.5), t.text_label);
let mut hdr = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(pos2(ar.right() - 50.0, ar.top()), ar.right_bottom())).layout(egui::Layout::right_to_left(egui::Align::Center)));
let mut hdr = ui.new_child(
egui::UiBuilder::new()
.max_rect(Rect::from_min_max(pos2(ar.right() - 50.0, ar.top()), ar.right_bottom()))
.layout(egui::Layout::right_to_left(egui::Align::Center)),
);
let plus = icon_button(&mut hdr, "albumNew", Icon::Plus, vec2(26.0, 26.0), false, true, "Create Album");
egui::Popup::menu(&plus).show(|ui| {
if ui.button("Create Album…").clicked() {
@ -82,7 +106,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
}
ui.add_space(10.0);
if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0).clicked() {
if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0)
.clicked()
{
let _ = app.run("library.source", json!({"kind": "recentlyDeleted"}));
}
});

View File

@ -118,10 +118,26 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let sel = app.session.active_mask == Some(m.id);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
register(ui.ctx(), format!("mask:{}", m.id), r);
ui.painter().rect_filled(r, 4.0, if sel { t.tool_active } else if resp.hovered() { t.hover.gamma_multiply(0.7) } else { t.chrome });
ui.painter().rect_filled(
r,
4.0,
if sel {
t.tool_active
} else if resp.hovered() {
t.hover.gamma_multiply(0.7)
} else {
t.chrome
},
);
let icon = m.components.first().map(|c| kind_label(&c.shape).1).unwrap_or(Icon::Mask);
paint(ui.painter(), Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), icon, t.text_label);
ui.painter().text(pos2(r.left() + 32.0, r.center().y), Align2::LEFT_CENTER, &m.name, t.font(13.0), if m.visible { t.text } else { t.text_disabled });
ui.painter().text(
pos2(r.left() + 32.0, r.center().y),
Align2::LEFT_CENTER,
&m.name,
t.font(13.0),
if m.visible { t.text } else { t.text_disabled },
);
if resp.clicked() {
let _ = app.run("mask.select", json!({"id": m.id}));
}
@ -169,7 +185,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let out = slider(ui, s, v, true, None);
apply_slider_out(app, s, out, |app, v| app.run("mask.adjust", json!({"values": {s.id: v}})));
}
let amt = ControlSpec { id: "amount", label: "Amount", section: Section::Light, min: 0.0, max: 200.0, default: 100.0, step: 1.0, decimals: 0, track: Track::Plain };
let amt = ControlSpec {
id: "amount",
label: "Amount",
section: Section::Light,
min: 0.0,
max: 200.0,
default: 100.0,
step: 1.0,
decimals: 0,
track: Track::Plain,
};
let out = slider(ui, &amt, m.adjust.amount, true, None);
apply_slider_out(app, &amt, out, |app, v| app.run("mask.adjust", json!({"values": {"amount": v}})));
ui.add_space(30.0);
@ -177,7 +203,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
fn component_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, op: &str) {
for (kind, label) in [("brush", "Brush"), ("linear", "Linear Gradient"), ("radial", "Radial Gradient"), ("sky", "Sky"), ("subject", "Subject"), ("luminanceRange", "Luminance Range")] {
for (kind, label) in [
("brush", "Brush"),
("linear", "Linear Gradient"),
("radial", "Radial Gradient"),
("sky", "Sky"),
("subject", "Subject"),
("luminanceRange", "Luminance Range"),
] {
if ui.button(label).clicked() {
if kind == "brush" {
app.ui.tool = "brush".into();

View File

@ -22,8 +22,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
ui.spacing_mut().item_spacing.y = 0.0;
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover());
ui.painter().text(pos2(hr.left() + 24.0, hr.center().y + 2.0), Align2::LEFT_CENTER, "Presets", t.semibold(15.0), t.text);
let mut hdr = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(pos2(hr.right() - 80.0, hr.top()), hr.right_bottom())).layout(egui::Layout::right_to_left(egui::Align::Center)));
if icon_button(&mut hdr, "presetCreate", Icon::Plus, vec2(28.0, 28.0), false, app.session.active().is_some(), "Create Preset…").clicked() {
let mut hdr = ui.new_child(
egui::UiBuilder::new()
.max_rect(Rect::from_min_max(pos2(hr.right() - 80.0, hr.top()), hr.right_bottom()))
.layout(egui::Layout::right_to_left(egui::Align::Center)),
);
if icon_button(&mut hdr, "presetCreate", Icon::Plus, vec2(28.0, 28.0), false, app.session.active().is_some(), "Create Preset…").clicked()
{
app.ui.dialog = Some(crate::state::Dialog::CreatePreset { name: String::new(), group: "User Presets".into() });
}
divider(ui);
@ -31,7 +36,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let amt_id = egui::Id::new("preset-amount");
let last: Option<(String, f64)> = ui.data(|d| d.get_temp(amt_id));
if let Some((pid, amount)) = last.clone() {
let spec = ControlSpec { id: "presetAmount", label: "Amount", section: Section::Profile, min: 0.0, max: 200.0, default: 100.0, step: 1.0, decimals: 0, track: Track::Plain };
let spec = ControlSpec {
id: "presetAmount",
label: "Amount",
section: Section::Profile,
min: 0.0,
max: 200.0,
default: 100.0,
step: 1.0,
decimals: 0,
track: Track::Plain,
};
let out = slider(ui, &spec, amount, true, None);
if let Some(v) = out.value {
// re-apply from the pre-preset state: undo last preset step then apply with the new amount
@ -51,7 +66,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
register(ui.ctx(), format!("presetGroup:{g}"), r);
paint(ui.painter(), Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(12.0, 12.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label);
paint(
ui.painter(),
Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(12.0, 12.0)),
if open { Icon::ChevronDown } else { Icon::ChevronRight },
t.text_label,
);
ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, &g, t.semibold(13.0), t.text_label);
if resp.clicked() {
ui.data_mut(|d| d.insert_temp(open_id, !open));
@ -70,7 +90,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
ui.painter().text(pos2(r.left() + 40.0, r.center().y), Align2::LEFT_CENTER, &name, t.font(13.0), t.text_label);
if fav {
paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 20.0, r.center().y), vec2(12.0, 12.0)), Icon::StarFilled, t.star);
paint(
ui.painter(),
Rect::from_center_size(pos2(r.right() - 20.0, r.center().y), vec2(12.0, 12.0)),
Icon::StarFilled,
t.star,
);
}
if resp.clicked() {
let _ = app.run("preset.apply", json!({"id": pid, "amount": 100}));

View File

@ -88,10 +88,10 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
});
ui.add_space(6.0);
ui.horizontal(|ui| {
if text_button(ui, "cropRotateLeft", "⟲ 90°", false).clicked() {
if text_button(ui, "cropRotateLeft", "Rotate Left", false).clicked() {
let _ = app.run("photo.rotateLeft", json!({}));
}
if text_button(ui, "cropRotateRight", "⟳ 90°", false).clicked() {
if text_button(ui, "cropRotateRight", "Rotate Right", false).clicked() {
let _ = app.run("photo.rotateRight", json!({}));
}
if text_button(ui, "cropFlipH", "Flip H", d.crop.flip_h).clicked() {
@ -119,7 +119,12 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
padded(ui, |ui| {
ui.horizontal(|ui| {
ui.label("Overlay");
for (label, o) in [("Thirds", crate::state::CropOverlay::Thirds), ("Grid", crate::state::CropOverlay::Grid), ("Golden", crate::state::CropOverlay::Golden), ("None", crate::state::CropOverlay::None)] {
for (label, o) in [
("Thirds", crate::state::CropOverlay::Thirds),
("Grid", crate::state::CropOverlay::Grid),
("Golden", crate::state::CropOverlay::Golden),
("None", crate::state::CropOverlay::None),
] {
if ui.selectable_label(app.ui.crop_overlay == o, label).clicked() {
app.ui.crop_overlay = o;
}
@ -244,7 +249,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.add_space(8.0);
ui.horizontal_wrapped(|ui| {
for k in &p.meta.keywords {
let r = ui.add(egui::Button::new(egui::RichText::new(format!("{k} ✕")).color(t.text_label)).corner_radius(10.0));
let r = ui.add(egui::Button::new(egui::RichText::new(format!("{k} ×")).color(t.text_label)).corner_radius(10.0));
if r.clicked() {
let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]}));
}
@ -255,7 +260,9 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let all = app.session.catalog.keywords();
ui.horizontal_wrapped(|ui| {
for (k, n) in all {
if !p.meta.keywords.contains(&k) && ui.add(egui::Button::new(egui::RichText::new(format!("{k} {n}")).color(t.text_dim)).frame(false)).clicked() {
if !p.meta.keywords.contains(&k)
&& ui.add(egui::Button::new(egui::RichText::new(format!("{k} {n}")).color(t.text_dim)).frame(false)).clicked()
{
let _ = app.run("photo.setMeta", json!({"addKeywords": [k]}));
}
}
@ -276,7 +283,7 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if ui.button(&v.name).on_hover_text(&v.created).clicked() {
let _ = app.run("version.restore", json!({"index": i}));
}
if ui.small_button("✕").clicked() {
if ui.small_button("×").clicked() {
let _ = app.run("version.delete", json!({"index": i}));
}
});

View File

@ -14,7 +14,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
egui::Panel::right("tool_strip")
.exact_size(t.strip_w)
.resizable(false)
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left: 0, right: 0, top: 8, bottom: 8 }).stroke(egui::Stroke::new(1.0, t.divider)))
.frame(
egui::Frame::NONE
.fill(t.chrome)
.inner_margin(egui::Margin { left: 0, right: 0, top: 8, bottom: 8 })
.stroke(egui::Stroke::new(1.0, t.divider)),
)
.show(ui, |ui| {
let has_photo = app.session.active().is_some();
ui.vertical_centered(|ui| {
@ -50,7 +55,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
});
// bottom icons
let bottom = ui.max_rect().bottom();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(egui::Rect::from_min_max(egui::pos2(ui.max_rect().left(), bottom - 96.0), ui.max_rect().right_bottom())));
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(egui::Rect::from_min_max(egui::pos2(ui.max_rect().left(), bottom - 96.0), ui.max_rect().right_bottom())),
);
child.vertical_centered(|ui| {
let sz = vec2(t.strip_w, 40.0);
if icon_button(ui, "keywords", Icon::Tag, sz, app.ui.right == RightPanel::Keywords, has_photo, "Keywords (K)").clicked() {

View File

@ -32,9 +32,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let sr = Rect::from_center_size(pos2(full.center().x, full.center().y), vec2(sw, 28.0));
let id = egui::Id::new("search-field");
let focused = ui.memory(|m| m.has_focus(id));
ui.painter().rect(sr, 4.0, if focused { t.canvas } else { t.field }, Stroke::new(1.0, if focused { t.accent } else { t.field_border }), StrokeKind::Inside);
ui.painter().rect(
sr,
4.0,
if focused { t.canvas } else { t.field },
Stroke::new(1.0, if focused { t.accent } else { t.field_border }),
StrokeKind::Inside,
);
register(ui.ctx(), "field:search", sr);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(sr.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
let mut child =
ui.new_child(egui::UiBuilder::new().max_rect(sr.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
let empty = app.ui.search.is_empty();
if empty && !focused {
let g = child.painter().layout_no_wrap("Search Photos".into(), t.font(13.5), t.text_dim);
@ -44,7 +51,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
child.painter().galley(pos2(x0 + 24.0, sr.center().y - g.size().y / 2.0), g, t.text_dim);
}
let resp = child.add(
egui::TextEdit::singleline(&mut app.ui.search).id(id).frame(egui::Frame::NONE).desired_width(sr.width() - 20.0).font(t.font(13.5)).text_color(t.text),
egui::TextEdit::singleline(&mut app.ui.search)
.id(id)
.frame(egui::Frame::NONE)
.desired_width(sr.width() - 20.0)
.font(t.font(13.5))
.text_color(t.text),
);
if resp.changed() {
let q = app.ui.search.clone();
@ -55,7 +67,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let fresp = ui.interact(fr, egui::Id::new("filter-btn"), Sense::click());
register(ui.ctx(), "icon:filter", fr);
let filtering = app.session.filter != Default::default();
paint(ui.painter(), fr.shrink(6.0), Icon::Filter, if filtering { t.accent } else if fresp.hovered() { t.text } else { t.icon });
paint(
ui.painter(),
fr.shrink(6.0),
Icon::Filter,
if filtering {
t.accent
} else if fresp.hovered() {
t.text
} else {
t.icon
},
);
if fresp.clicked() {
let _ = app.run("view.filterBar", json!({}));
}

View File

@ -170,7 +170,10 @@ impl Renderer {
}
None => {
let tex = ctx.load_texture(name, color, egui::TextureOptions::LINEAR);
self.textures.insert(slot, Tex { key: r.key, photo: r.photo, tex, size: [img.width, img.height], histogram: Some(rendered.histogram), ms });
self.textures.insert(
slot,
Tex { key: r.key, photo: r.photo, tex, size: [img.width, img.height], histogram: Some(rendered.histogram), ms },
);
}
}
if slot == Slot::Main {

View File

@ -0,0 +1,136 @@
//! Keyboard shortcuts: parse `Cmd+Shift+X` style strings and dispatch UI and engine commands.
use egui::{Key, Modifiers};
use serde_json::json;
use crate::LightcraftApp;
pub fn parse(s: &str) -> Option<(Modifiers, Key)> {
let mut m = Modifiers::NONE;
let mut key = None;
for part in s.split('+') {
match part {
"Cmd" => m.command = true,
"Shift" => m.shift = true,
"Alt" => m.alt = true,
"Ctrl" => m.ctrl = true,
k => {
key = Key::from_name(k).or(match k {
"Right" => Some(Key::ArrowRight),
"Left" => Some(Key::ArrowLeft),
"Up" => Some(Key::ArrowUp),
"Down" => Some(Key::ArrowDown),
"Delete" => Some(Key::Backspace),
"\\" => Some(Key::Backslash),
"/" => Some(Key::Slash),
"=" => Some(Key::Equals),
"-" => Some(Key::Minus),
"[" => Some(Key::OpenBracket),
"]" => Some(Key::CloseBracket),
_ => None,
})
}
}
}
key.map(|k| (m, k))
}
fn matches(i: &egui::InputState, m: Modifiers, k: Key) -> bool {
i.events.iter().any(|e| match e {
egui::Event::Key { key, pressed: true, modifiers, .. } => {
*key == k && modifiers.command == m.command && modifiers.shift == m.shift && modifiers.alt == m.alt
}
_ => false,
})
}
pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
// don't steal keys from text fields
if ctx.egui_wants_keyboard_input() {
return;
}
let mut fire: Vec<String> = Vec::new();
ctx.input(|i| {
for (id, _, sc, _) in crate::menus::UI_COMMANDS {
if let Some((m, k)) = sc.and_then(parse)
&& matches(i, m, k)
{
fire.push(id.to_string());
}
}
for c in lightcraft_engine::command_specs() {
if let Some((m, k)) = c.shortcut.and_then(parse)
&& matches(i, m, k)
{
fire.push(c.id.to_string());
}
}
// rating 0-5, colour labels 6-9 (with Shift: and advance)
for (n, key) in [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5].iter().enumerate() {
if matches(i, Modifiers::NONE, *key) {
fire.push(format!("rate:{n}:0"));
}
if matches(i, Modifiers::SHIFT, *key) {
fire.push(format!("rate:{n}:1"));
}
}
for (label, key) in [("red", Key::Num6), ("yellow", Key::Num7), ("green", Key::Num8), ("blue", Key::Num9)] {
if matches(i, Modifiers::NONE, key) {
fire.push(format!("label:{label}"));
}
}
});
for f in fire {
if let Some(rest) = f.strip_prefix("rate:") {
let (n, adv) = rest.split_once(':').unwrap_or(("0", "0"));
let _ = app.run("photo.rate", json!({"rating": n.parse::<u8>().unwrap_or(0), "advance": adv == "1"}));
let label = if n == "0" { "Rating cleared".to_string() } else { format!("Rated {}", "★".repeat(n.parse().unwrap_or(0))) };
app.toast(ctx, label);
} else if let Some(l) = f.strip_prefix("label:") {
let _ = app.run("photo.label", json!({"label": l}));
} else {
// X is both reject (library) and swap crop aspect (crop tool)
if f == "photo.reject" && app.ui.right == crate::state::RightPanel::Crop {
let _ = app.run("crop.rotateAspect", json!({}));
continue;
}
if f == "crop.rotateAspect" && app.ui.right != crate::state::RightPanel::Crop {
continue;
}
let _ = app.run(&f, json!({}));
match f.as_str() {
"photo.pick" => app.toast(ctx, "Flagged as Pick"),
"photo.reject" => app.toast(ctx, "Flagged as Reject"),
"photo.unflag" => app.toast(ctx, "Unflagged"),
"edit.undo" => app.toast(ctx, "Undo"),
"edit.redo" => app.toast(ctx, "Redo"),
_ => {}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_shortcuts() {
let (m, k) = parse("Cmd+Shift+Z").unwrap();
assert!(m.command && m.shift && !m.alt);
assert_eq!(k, Key::Z);
assert_eq!(parse("Right").unwrap().1, Key::ArrowRight);
assert_eq!(parse("\\").unwrap().1, Key::Backslash);
// every declared shortcut parses
for (id, _, sc, _) in crate::menus::UI_COMMANDS {
if let Some(sc) = sc {
assert!(parse(sc).is_some(), "{id}: {sc}");
}
}
for c in lightcraft_engine::command_specs() {
if let Some(sc) = c.shortcut {
assert!(parse(sc).is_some(), "{}: {sc}", c.id);
}
}
}
}

View File

@ -108,7 +108,9 @@ impl Tokens {
pub fn install_fonts(ctx: &egui::Context) {
let mut fonts = FontDefinitions::default();
fonts.font_data.insert("SourceSans3".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"))));
fonts.font_data.insert("SourceSans3-Semibold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf"))));
fonts
.font_data
.insert("SourceSans3-Semibold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf"))));
let fallback: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
let mut prop = vec!["SourceSans3".to_string()];
prop.extend(fallback.clone());
@ -136,7 +138,13 @@ pub fn apply(ctx: &egui::Context) {
v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(140) };
v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 30, spread: 0, color: Color32::from_black_alpha(160) };
let w = &mut v.widgets;
for (wv, fill) in [(&mut w.noninteractive, t.chrome), (&mut w.inactive, t.button), (&mut w.hovered, t.hover), (&mut w.active, t.pressed), (&mut w.open, t.hover)] {
for (wv, fill) in [
(&mut w.noninteractive, t.chrome),
(&mut w.inactive, t.button),
(&mut w.hovered, t.hover),
(&mut w.active, t.pressed),
(&mut w.open, t.hover),
] {
wv.bg_fill = fill;
wv.weak_bg_fill = fill;
wv.corner_radius = CornerRadius::same(4);

View File

@ -50,7 +50,13 @@ fn track_stops(track: &Track) -> Option<Vec<Color32>> {
let prev = hex(BAND_COLORS[(i + 7) % 8]);
let next = hex(BAND_COLORS[(i + 1) % 8]);
let me = hex(BAND_COLORS[i]);
vec![prev.gamma_multiply(0.7), lerp(prev, me, 0.5).gamma_multiply(0.7), me.gamma_multiply(0.7), lerp(me, next, 0.5).gamma_multiply(0.7), next.gamma_multiply(0.7)]
vec![
prev.gamma_multiply(0.7),
lerp(prev, me, 0.5).gamma_multiply(0.7),
me.gamma_multiply(0.7),
lerp(me, next, 0.5).gamma_multiply(0.7),
next.gamma_multiply(0.7),
]
}
Track::Sat { band } => vec![hex("#454343"), lerp(hex("#454343"), hex(BAND_COLORS[*band as usize]), 0.6), hex(BAND_COLORS[*band as usize])],
Track::Lum { band } => {
@ -231,7 +237,12 @@ pub fn flyout_row(ui: &mut Ui, id: &str, title: &str, icon: Icon, open: bool) ->
p.rect_filled(tile, 3.0, t.chrome.gamma_multiply(1.15));
paint(p, tile.shrink(3.0), icon, t.text_label);
p.text(pos2(tile.right() + 10.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label);
paint(p, Rect::from_center_size(pos2(r.right() - 14.0, r.center().y), vec2(12.0, 12.0)), if open { Icon::ChevronDown } else { Icon::TriangleLeft }, t.text_label);
paint(
p,
Rect::from_center_size(pos2(r.right() - 14.0, r.center().y), vec2(12.0, 12.0)),
if open { Icon::ChevronDown } else { Icon::TriangleLeft },
t.text_label,
);
resp
}
@ -306,3 +317,16 @@ pub fn stars(ui: &mut Ui, id: &str, rating: u8, size: f32) -> Option<u8> {
let _ = hover_n;
out
}
/// A borderless dropdown button: `text` followed by a painted chevron (no font glyph needed).
pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Color32) -> Response {
let t = Tokens::get(ui.ctx());
let galley = ui.painter().layout_no_wrap(text.to_string(), font, color);
let size = vec2(galley.size().x + 20.0, galley.size().y.max(20.0));
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
register(ui.ctx(), format!("dropdown:{id}"), r);
let c = if resp.hovered() { t.text } else { color };
ui.painter().galley(pos2(r.left(), r.center().y - galley.size().y / 2.0), galley, c);
paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 7.0, r.center().y + 1.0), vec2(12.0, 12.0)), Icon::ChevronDown, c);
resp
}