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:
parent
b0d435f223
commit
a7ea39573b
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,6 @@
|
|||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*", "xtask"]
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
|
||||
pub mod cmd;
|
||||
pub mod demo;
|
||||
pub mod files;
|
||||
pub mod media;
|
||||
pub mod presets;
|
||||
mod view;
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}),
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"}));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}));
|
||||
|
|
|
|||
|
|
@ -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}));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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!({}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue