continued work

This commit is contained in:
Brandon Thomas
2026-09-30 22:21:57 -04:00
parent 955ae1f976
commit b0d435f223
20 changed files with 4694 additions and 1 deletions
Generated
+426 -1
View File
@@ -2,12 +2,33 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "accesskit"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
dependencies = [
"uuid",
]
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aligned"
version = "0.4.3"
@@ -224,6 +245,15 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "color"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf"
dependencies = [
"bytemuck",
]
[[package]]
name = "color_quant"
version = "1.1.0"
@@ -270,12 +300,77 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "ecolor"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
dependencies = [
"emath",
]
[[package]]
name = "egui"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba"
dependencies = [
"accesskit",
"ahash",
"bitflags",
"emath",
"epaint",
"itertools 0.15.0",
"log",
"nohash-hasher",
"profiling",
"smallvec",
"unicode-segmentation",
"web-sys",
]
[[package]]
name = "either"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "emath"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
[[package]]
name = "epaint"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3"
dependencies = [
"ahash",
"ecolor",
"emath",
"epaint_default_fonts",
"font-types",
"harfrust",
"log",
"nohash-hasher",
"parking_lot",
"profiling",
"self_cell",
"skrifa",
"smallvec",
"unicode-general-category",
"unicode-segmentation",
"vello_cpu",
]
[[package]]
name = "epaint_default_fonts"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
[[package]]
name = "equator"
version = "0.4.2"
@@ -306,6 +401,15 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "euclid"
version = "0.22.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
dependencies = [
"num-traits",
]
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -327,6 +431,12 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fearless_simd"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
@@ -350,6 +460,45 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "font-types"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
dependencies = [
"bytemuck",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -383,6 +532,31 @@ dependencies = [
"weezl",
]
[[package]]
name = "glifo"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
dependencies = [
"bytemuck",
"foldhash",
"hashbrown",
"log",
"peniko",
"skrifa",
"smallvec",
"vello_common",
]
[[package]]
name = "guillotiere"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab"
dependencies = [
"euclid",
]
[[package]]
name = "half"
version = "2.7.1"
@@ -394,6 +568,27 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "harfrust"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
dependencies = [
"bitflags",
"bytemuck",
"read-fonts",
"smallvec",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "image"
version = "0.25.10"
@@ -449,6 +644,15 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -480,6 +684,17 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0370574b86f7eca156b9f298392b5e69a23f8c86f3f865add60bbc2e79467a6"
[[package]]
name = "js-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "jxl-bitstream"
version = "1.1.0"
@@ -656,6 +871,18 @@ dependencies = [
"tracing",
]
[[package]]
name = "kurbo"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
dependencies = [
"arrayvec",
"euclid",
"polycool",
"smallvec",
]
[[package]]
name = "libc"
version = "0.2.189"
@@ -827,12 +1054,44 @@ dependencies = [
"thiserror",
]
[[package]]
name = "lightcraft-ui-egui"
version = "0.1.0"
dependencies = [
"egui",
"lightcraft-catalog",
"lightcraft-color",
"lightcraft-develop",
"lightcraft-engine",
"lightcraft-geom",
"lightcraft-pipeline",
"lightcraft-raster",
"log",
"serde",
"serde_json",
]
[[package]]
name = "linebender_resource_handle"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.34"
@@ -918,6 +1177,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nom"
version = "8.0.0"
@@ -989,6 +1254,29 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "paste"
version = "1.0.15"
@@ -1001,6 +1289,19 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "peniko"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6"
dependencies = [
"bytemuck",
"color",
"kurbo",
"linebender_resource_handle",
"smallvec",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -1020,6 +1321,15 @@ dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "polycool"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
dependencies = [
"arrayvec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -1178,7 +1488,7 @@ dependencies = [
"built",
"cfg-if",
"interpolate_name",
"itertools",
"itertools 0.14.0",
"libc",
"libfuzzer-sys",
"log",
@@ -1231,6 +1541,26 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "read-fonts"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
dependencies = [
"bytemuck",
"font-types",
"once_cell",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
@@ -1274,6 +1604,18 @@ dependencies = [
"wait-timeout",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "self_cell"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813"
[[package]]
name = "serde"
version = "1.0.229"
@@ -1338,6 +1680,28 @@ dependencies = [
"quote",
]
[[package]]
name = "skrifa"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
dependencies = [
"bytemuck",
"read-fonts",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -1438,12 +1802,30 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicode-general-category"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "uuid"
version = "1.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
[[package]]
name = "v_frame"
version = "0.3.9"
@@ -1455,6 +1837,39 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vello_common"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
dependencies = [
"bytemuck",
"fearless_simd",
"guillotiere",
"log",
"peniko",
"smallvec",
"thiserror",
]
[[package]]
name = "vello_cpu"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
dependencies = [
"bytemuck",
"glifo",
"hashbrown",
"vello_common",
]
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wait-timeout"
version = "0.2.1"
@@ -1518,6 +1933,16 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "weezl"
version = "0.1.12"
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "lightcraft-ui-egui"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "LightCraft egui frontend (Lightroom-style UI); swappable — nothing below depends on it (L5)"
[lints]
workspace = true
[dependencies]
lightcraft-geom = { workspace = true }
lightcraft-color = { workspace = true }
lightcraft-raster = { workspace = true }
lightcraft-develop = { workspace = true }
lightcraft-pipeline = { workspace = true }
lightcraft-catalog = { workspace = true }
lightcraft-engine = { workspace = true }
egui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+399
View File
@@ -0,0 +1,399 @@
//! Our own vector icons, drawn in code on a 20×20 design grid (1.5 px strokes, rounded caps).
//! Original work (see assets/ATTRIBUTION.md) — not derived from any third-party icon set.
use egui::{Color32, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Icon {
Sidebar,
Back,
Forward,
Search,
Filter,
Bell,
Share,
Help,
Cloud,
Sliders,
Crop,
Eraser,
Mask,
Eye,
Presets,
Versions,
Activity,
More,
Tag,
Info,
GridPhoto,
GridSquare,
Single,
Compare,
Sort,
Star,
StarFilled,
Circle,
FlagPick,
FlagReject,
Gear,
Filmstrip,
BeforeAfter,
ChevronDown,
ChevronRight,
ChevronLeft,
TriangleLeft,
Plus,
Minus,
Album,
Folder,
Photos,
Clock,
Trash,
Curve,
ProfileGrid,
Close,
Check,
Brush,
Linear,
Radial,
Sky,
Subject,
Picker,
Rotate,
Flip,
Invert,
Dots,
Video,
Heart,
Edited,
}
struct Pen<'a> {
p: &'a Painter,
r: Rect,
s: Stroke,
k: f32,
}
impl Pen<'_> {
fn pt(&self, x: f32, y: f32) -> Pos2 {
pos2(self.r.min.x + x * self.k, self.r.min.y + y * self.k)
}
fn line(&self, pts: &[(f32, f32)]) {
let v: Vec<Pos2> = pts.iter().map(|(x, y)| self.pt(*x, *y)).collect();
self.p.add(Shape::line(v, self.s));
}
fn closed(&self, pts: &[(f32, f32)]) {
let v: Vec<Pos2> = pts.iter().map(|(x, y)| self.pt(*x, *y)).collect();
self.p.add(Shape::closed_line(v, self.s));
}
fn fill(&self, pts: &[(f32, f32)], c: Color32) {
let v: Vec<Pos2> = pts.iter().map(|(x, y)| self.pt(*x, *y)).collect();
self.p.add(Shape::convex_polygon(v, c, Stroke::NONE));
}
fn circle(&self, x: f32, y: f32, r: f32) {
self.p.circle_stroke(self.pt(x, y), r * self.k, self.s);
}
fn dot(&self, x: f32, y: f32, r: f32) {
self.p.circle_filled(self.pt(x, y), r * self.k, self.s.color);
}
fn rect(&self, x0: f32, y0: f32, x1: f32, y1: f32, round: f32) {
self.p.rect_stroke(Rect::from_min_max(self.pt(x0, y0), self.pt(x1, y1)), round * self.k, self.s, egui::StrokeKind::Middle);
}
fn rect_fill(&self, x0: f32, y0: f32, x1: f32, y1: f32, round: f32) {
self.p.rect_filled(Rect::from_min_max(self.pt(x0, y0), self.pt(x1, y1)), round * self.k, self.s.color);
}
fn arc(&self, cx: f32, cy: f32, r: f32, a0: f32, a1: f32) {
let n = 24;
let v: Vec<(f32, f32)> = (0..=n)
.map(|i| {
let a = (a0 + (a1 - a0) * i as f32 / n as f32).to_radians();
(cx + r * a.cos(), cy + r * a.sin())
})
.collect();
self.line(&v);
}
}
fn star_pts(cx: f32, cy: f32, r: f32) -> Vec<(f32, f32)> {
(0..10)
.map(|i| {
let a = (-90.0 + i as f32 * 36.0f32).to_radians();
let rr = if i % 2 == 0 { r } else { r * 0.45 };
(cx + rr * a.cos(), cy + rr * a.sin())
})
.collect()
}
/// Paint `icon` centred in `rect` (square area used) with `color`.
pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) {
let side = rect.width().min(rect.height());
let r = Rect::from_center_size(rect.center(), vec2(side, side));
let k = side / 20.0;
let pen = Pen { p, r, s: Stroke::new((1.5 * k).max(1.0), color), k };
use Icon::*;
match icon {
Sidebar => {
pen.rect(3.0, 4.0, 17.0, 16.0, 1.5);
pen.line(&[(8.0, 4.0), (8.0, 16.0)]);
}
Back => pen.line(&[(16.0, 10.0), (4.0, 10.0), (9.0, 5.0)]),
Forward => pen.line(&[(4.0, 10.0), (16.0, 10.0), (11.0, 5.0)]),
Search => {
pen.circle(8.5, 8.5, 5.0);
pen.line(&[(12.2, 12.2), (16.5, 16.5)]);
}
Filter => pen.closed(&[(3.0, 4.0), (17.0, 4.0), (11.5, 10.5), (11.5, 16.0), (8.5, 14.5), (8.5, 10.5)]),
Bell => {
pen.line(&[(5.0, 14.0), (5.0, 9.0)]);
pen.arc(10.0, 9.0, 5.0, 180.0, 360.0);
pen.line(&[(15.0, 9.0), (15.0, 14.0)]);
pen.line(&[(3.5, 14.0), (16.5, 14.0)]);
pen.arc(10.0, 15.5, 1.8, 0.0, 180.0);
}
Share => {
pen.line(&[(6.0, 8.0), (4.0, 8.0), (4.0, 17.0), (16.0, 17.0), (16.0, 8.0), (14.0, 8.0)]);
pen.line(&[(10.0, 12.0), (10.0, 2.5)]);
pen.line(&[(6.5, 6.0), (10.0, 2.5), (13.5, 6.0)]);
}
Help => {
pen.circle(10.0, 10.0, 7.5);
pen.arc(10.0, 8.0, 2.5, 180.0, 400.0);
pen.line(&[(10.0, 10.5), (10.0, 11.8)]);
pen.dot(10.0, 14.2, 0.9);
}
Cloud => {
pen.arc(7.0, 11.5, 3.5, 90.0, 270.0);
pen.arc(10.5, 8.5, 4.5, 190.0, 340.0);
pen.arc(14.0, 11.8, 3.2, 270.0, 450.0);
pen.line(&[(7.0, 15.0), (14.0, 15.0)]);
}
Sliders => {
for (y, x) in [(5.0, 12.0), (10.0, 7.0), (15.0, 13.0)] {
pen.line(&[(3.0, y), (x - 2.0, y)]);
pen.line(&[(x + 2.0, y), (17.0, y)]);
pen.circle(x, y, 2.0);
}
}
Crop => {
pen.line(&[(6.0, 2.5), (6.0, 14.0), (17.5, 14.0)]);
pen.line(&[(2.5, 6.0), (14.0, 6.0), (14.0, 17.5)]);
pen.line(&[(15.5, 2.0), (17.5, 4.0), (15.5, 6.0)]);
}
Eraser => {
pen.closed(&[(3.0, 12.0), (10.0, 5.0), (16.0, 11.0), (11.0, 16.0), (7.0, 16.0)]);
pen.line(&[(6.5, 8.5), (12.5, 14.5)]);
pen.line(&[(7.0, 17.5), (17.0, 17.5)]);
}
Mask => {
pen.circle(10.0, 10.0, 7.0);
for i in 0..5 {
for j in 0..5 {
let (x, y) = (5.0 + i as f32 * 2.5, 5.0 + j as f32 * 2.5);
if (x - 10.0).hypot(y - 10.0) < 5.8 {
pen.dot(x, y, 0.6);
}
}
}
}
Eye => {
pen.arc(10.0, 16.0, 9.0, 212.0, 328.0);
pen.arc(10.0, 4.0, 9.0, 32.0, 148.0);
pen.circle(10.0, 10.0, 2.5);
}
Presets => {
pen.circle(8.0, 8.0, 5.0);
pen.circle(12.0, 12.0, 5.0);
}
Versions => {
pen.rect(5.0, 3.0, 17.0, 13.0, 1.5);
pen.line(&[(3.0, 6.0), (3.0, 16.0), (14.0, 16.0)]);
}
Activity => {
pen.circle(10.0, 10.0, 7.0);
pen.line(&[(10.0, 6.0), (10.0, 10.0), (13.0, 12.0)]);
}
More | Dots => {
for x in [5.0, 10.0, 15.0] {
pen.dot(x, 10.0, 1.3);
}
}
Tag => {
pen.closed(&[(3.0, 3.5), (10.0, 3.5), (17.0, 10.5), (10.5, 17.0), (3.0, 10.0)]);
pen.dot(7.0, 7.5, 1.2);
}
Info => {
pen.circle(10.0, 10.0, 7.5);
pen.line(&[(10.0, 9.0), (10.0, 14.0)]);
pen.dot(10.0, 6.3, 0.9);
}
GridPhoto => {
pen.rect_fill(3.0, 4.0, 10.0, 9.5, 0.5);
pen.rect_fill(11.0, 4.0, 17.0, 9.5, 0.5);
pen.rect_fill(3.0, 10.5, 8.0, 16.0, 0.5);
pen.rect_fill(9.0, 10.5, 17.0, 16.0, 0.5);
}
GridSquare => {
pen.rect_fill(3.5, 4.0, 9.5, 9.5, 0.5);
pen.rect_fill(10.5, 4.0, 16.5, 9.5, 0.5);
pen.rect_fill(3.5, 10.5, 9.5, 16.0, 0.5);
pen.rect_fill(10.5, 10.5, 16.5, 16.0, 0.5);
}
Single => pen.rect_fill(3.0, 5.0, 17.0, 15.0, 0.5),
Compare => {
pen.rect_fill(3.0, 5.0, 9.5, 15.0, 0.5);
pen.rect_fill(10.5, 5.0, 17.0, 15.0, 0.5);
}
Sort => {
pen.line(&[(3.0, 5.0), (17.0, 5.0)]);
pen.line(&[(3.0, 10.0), (13.0, 10.0)]);
pen.line(&[(3.0, 15.0), (9.0, 15.0)]);
}
Star => pen.closed(&star_pts(10.0, 10.5, 7.5)),
StarFilled => {
let pts = star_pts(10.0, 10.5, 7.5);
// convex fan: fill triangles from the centre
for i in 0..10 {
let a = pts[i];
let b = pts[(i + 1) % 10];
pen.fill(&[(10.0, 10.5), a, b], color);
}
}
Circle => pen.circle(10.0, 10.0, 6.0),
FlagPick => {
pen.line(&[(5.0, 17.0), (5.0, 3.5)]);
pen.closed(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)]);
}
FlagReject => {
pen.line(&[(5.0, 17.0), (5.0, 3.5)]);
pen.closed(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)]);
pen.line(&[(8.0, 5.0), (12.0, 9.0)]);
pen.line(&[(12.0, 5.0), (8.0, 9.0)]);
}
Gear => {
pen.circle(10.0, 10.0, 2.5);
for i in 0..8 {
let a = (i as f32 * 45.0f32).to_radians();
pen.line(&[(10.0 + 5.2 * a.cos(), 10.0 + 5.2 * a.sin()), (10.0 + 7.5 * a.cos(), 10.0 + 7.5 * a.sin())]);
}
pen.circle(10.0, 10.0, 5.2);
}
Filmstrip => {
pen.rect(2.5, 5.0, 17.5, 15.0, 1.0);
pen.rect_fill(5.0, 11.0, 8.5, 13.5, 0.3);
pen.rect_fill(9.5, 11.0, 13.0, 13.5, 0.3);
pen.line(&[(2.5, 9.5), (17.5, 9.5)]);
}
BeforeAfter => {
pen.rect(3.0, 4.0, 17.0, 16.0, 1.0);
pen.line(&[(10.0, 2.5), (10.0, 17.5)]);
pen.rect_fill(10.0, 4.0, 17.0, 16.0, 0.5);
}
ChevronDown => pen.line(&[(5.0, 7.5), (10.0, 12.5), (15.0, 7.5)]),
ChevronRight => pen.line(&[(7.5, 5.0), (12.5, 10.0), (7.5, 15.0)]),
ChevronLeft => pen.line(&[(12.5, 5.0), (7.5, 10.0), (12.5, 15.0)]),
TriangleLeft => pen.fill(&[(13.0, 6.0), (13.0, 14.0), (7.0, 10.0)], color),
Plus => {
pen.line(&[(10.0, 4.0), (10.0, 16.0)]);
pen.line(&[(4.0, 10.0), (16.0, 10.0)]);
}
Minus => pen.line(&[(4.0, 10.0), (16.0, 10.0)]),
Album => {
pen.rect(3.0, 5.0, 15.0, 17.0, 1.0);
pen.line(&[(6.0, 2.5), (17.5, 2.5), (17.5, 14.0)]);
}
Folder => pen.closed(&[(2.5, 5.0), (8.0, 5.0), (9.5, 7.0), (17.5, 7.0), (17.5, 15.5), (2.5, 15.5)]),
Photos => {
pen.rect(2.5, 4.0, 17.5, 16.0, 1.5);
pen.line(&[(2.5, 13.5), (7.0, 9.5), (11.0, 13.0), (13.5, 11.0), (17.5, 14.5)]);
pen.dot(13.5, 7.5, 1.3);
}
Clock => {
pen.circle(10.0, 10.0, 7.0);
pen.line(&[(10.0, 5.5), (10.0, 10.0), (13.5, 11.5)]);
}
Trash => {
pen.line(&[(3.0, 5.5), (17.0, 5.5)]);
pen.line(&[(8.0, 5.5), (8.0, 3.0), (12.0, 3.0), (12.0, 5.5)]);
pen.closed(&[(5.0, 5.5), (15.0, 5.5), (14.0, 17.0), (6.0, 17.0)]);
}
Curve => {
pen.rect(3.0, 3.0, 17.0, 17.0, 1.5);
let pts: Vec<(f32, f32)> = (0..=12)
.map(|i| {
let t = i as f32 / 12.0;
let y = t * t * (3.0 - 2.0 * t);
(4.5 + 11.0 * t, 15.5 - 11.0 * y)
})
.collect();
pen.line(&pts);
}
ProfileGrid => {
pen.rect_fill(3.5, 3.5, 9.0, 9.0, 0.5);
pen.rect_fill(11.0, 3.5, 16.5, 9.0, 0.5);
pen.rect_fill(3.5, 11.0, 9.0, 16.5, 0.5);
pen.circle(14.0, 14.0, 2.5);
pen.line(&[(15.8, 15.8), (17.5, 17.5)]);
}
Close => {
pen.line(&[(5.0, 5.0), (15.0, 15.0)]);
pen.line(&[(15.0, 5.0), (5.0, 15.0)]);
}
Check => pen.line(&[(4.0, 10.5), (8.5, 15.0), (16.0, 5.5)]),
Brush => {
pen.line(&[(16.5, 3.5), (9.0, 11.0)]);
pen.closed(&[(9.0, 11.0), (6.0, 11.5), (4.0, 14.5), (3.5, 17.0), (6.0, 16.5), (8.5, 14.0)]);
}
Linear => {
for (i, x) in [4.0, 7.5, 11.0, 14.5].iter().enumerate() {
let a = 1.0 - i as f32 * 0.22;
pen.p.line_segment([pen.pt(*x, 3.0), pen.pt(*x, 17.0)], Stroke::new(pen.s.width, color.gamma_multiply(a)));
}
}
Radial => {
pen.circle(10.0, 10.0, 7.0);
pen.p.circle_stroke(pen.pt(10.0, 10.0), 3.5 * k, Stroke::new(pen.s.width, color.gamma_multiply(0.5)));
}
Sky => {
pen.arc(8.0, 11.0, 3.2, 150.0, 300.0);
pen.arc(12.0, 9.5, 3.8, 210.0, 360.0);
pen.line(&[(5.0, 13.5), (15.5, 13.5)]);
pen.line(&[(2.5, 17.0), (17.5, 17.0)]);
}
Subject => {
pen.circle(10.0, 7.0, 3.0);
pen.arc(10.0, 18.0, 6.5, 200.0, 340.0);
pen.rect(2.5, 2.5, 17.5, 17.5, 2.0);
}
Picker => {
pen.line(&[(4.0, 16.0), (11.5, 8.5)]);
pen.closed(&[(11.0, 5.0), (15.0, 9.0), (17.0, 7.0), (13.0, 3.0)]);
}
Rotate => {
pen.arc(10.0, 10.0, 6.5, 200.0, 470.0);
pen.line(&[(3.0, 5.5), (4.0, 9.0), (7.5, 8.0)]);
}
Flip => {
pen.line(&[(10.0, 2.5), (10.0, 17.5)]);
pen.closed(&[(8.0, 5.0), (8.0, 15.0), (3.0, 15.0)]);
pen.fill(&[(12.0, 5.0), (12.0, 15.0), (17.0, 15.0)], color);
}
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(),
color,
Stroke::NONE,
));
}
Video => {
pen.rect(2.5, 5.0, 13.0, 15.0, 1.5);
pen.closed(&[(13.0, 8.5), (17.5, 6.0), (17.5, 14.0), (13.0, 11.5)]);
}
Heart => pen.closed(&[(10.0, 16.5), (3.5, 9.5), (4.0, 5.5), (7.5, 4.0), (10.0, 6.5), (12.5, 4.0), (16.0, 5.5), (16.5, 9.5)]),
Edited => {
pen.circle(10.0, 10.0, 7.0);
pen.line(&[(7.0, 13.0), (13.0, 7.0)]);
}
}
}
+284
View File
@@ -0,0 +1,284 @@
//! LightCraft's egui frontend: a Lightroom-style UI over `lightcraft-engine`.
//!
//! The UI is thin: every action goes through [`LightcraftApp::run`], which handles UI commands
//! (views, panels, zoom — see [`menus::UI_COMMANDS`]) and forwards everything else to the engine.
//! The same entry point serves menus, shortcuts, buttons and the control channel ([`control`]).
#![forbid(unsafe_code)]
pub mod control;
pub mod icons;
pub mod menus;
pub mod panels;
pub mod render;
pub mod shortcuts;
pub mod state;
pub mod theme;
pub mod widgets;
use std::sync::mpsc::{Receiver, Sender};
use lightcraft_engine::Session;
use serde_json::Value;
pub use control::{ControlRequest, ControlResponse};
pub use state::UiState;
pub type PickFiles = Box<dyn FnMut() -> Vec<String>>;
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
pub type PngEncode = Box<dyn Fn(&lightcraft_raster::Rgba8) -> Vec<u8>>;
/// Platform services injected by the host app (desktop or web).
#[derive(Default)]
pub struct Services {
/// Show an open dialog for photos; returns paths.
pub pick_files: Option<PickFiles>,
pub write: Option<WriteFn>,
/// PNG encoder (the host links an image encoder; the UI crate stays codec-free).
pub png: Option<PngEncode>,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Perf {
pub frame_ms: f64,
pub fps: f64,
}
pub struct LightcraftApp {
pub session: Session,
pub ui: UiState,
pub services: Services,
pub renderer: render::Renderer,
pub perf: Perf,
/// macOS: the host draws the traffic lights over our top bar.
pub integrated_titlebar: bool,
/// The host installed a native menu bar.
pub native_menu: bool,
control_rx: Option<Receiver<ControlRequest>>,
pending_screenshots: Vec<(u64, Option<String>, Sender<ControlResponse>)>,
queued_screenshots: Vec<(u64, f64, u32)>,
screenshot_token: u64,
/// Synthetic input events (from the control channel) injected one step per frame.
pub synthetic: Vec<egui::Event>,
styled: bool,
fonts_ready: bool,
last_time: f64,
/// Rect of the photo canvas and the displayed image (screen points) from the last frame.
pub canvas_rect: Option<egui::Rect>,
pub image_rect: Option<egui::Rect>,
/// Widget registry from the last frame (automation ids → rects).
pub widgets: Vec<(String, egui::Rect)>,
/// In-progress on-canvas gesture (brush stroke points, gradient drag…).
pub gesture: Option<panels::detail::Gesture>,
}
impl LightcraftApp {
pub fn new(session: Session, services: Services) -> Self {
Self {
session,
ui: UiState::default(),
services,
renderer: render::Renderer::default(),
perf: Perf::default(),
integrated_titlebar: false,
native_menu: false,
control_rx: None,
pending_screenshots: vec![],
queued_screenshots: vec![],
screenshot_token: 0,
synthetic: vec![],
styled: false,
fonts_ready: false,
last_time: 0.0,
canvas_rect: None,
image_rect: None,
widgets: vec![],
gesture: None,
}
}
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
self.control_rx = Some(rx);
self
}
/// Run a UI or engine command by id. The single entry point for every frontend path.
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
if let Some(r) = menus::run_ui_command(self, id, &params) {
return r;
}
let r = self.session.execute(id, &params).map_err(|e| e.to_string());
if let Err(e) = &r {
self.ui.status = e.clone();
}
r
}
/// Show a transient toast at the bottom of the canvas (like the reference app's HUD).
pub fn toast(&mut self, ctx: &egui::Context, text: impl Into<String>) {
let t = ctx.input(|i| i.time);
self.ui.toast = Some((text.into(), t + 1.4));
}
fn drain_control(&mut self, ctx: &egui::Context) {
let Some(rx) = self.control_rx.take() else { return };
while let Ok(req) = rx.try_recv() {
let reply = req.reply.clone();
match control::handle(self, ctx, &req) {
control::Outcome::Done(v) => {
let _ = reply.send(v);
}
control::Outcome::Screenshot { path } => {
self.screenshot_token += 1;
let token = self.screenshot_token;
self.queued_screenshots.push((token, now_ms() + 120.0, 0));
self.pending_screenshots.push((token, path, reply));
}
}
}
self.control_rx = Some(rx);
}
fn issue_screenshots(&mut self, ctx: &egui::Context) {
let now = now_ms();
let busy = self.renderer.queued() > 0;
self.queued_screenshots.retain_mut(|(token, at, frames)| {
*frames += 1;
// wait for pending renders (up to ~3 s) so screenshots show finished pixels
if now >= *at && *frames >= 3 && (!busy || *frames > 180) {
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token)));
false
} else {
true
}
});
if !self.queued_screenshots.is_empty() || !self.pending_screenshots.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
fn collect_screenshots(&mut self, ctx: &egui::Context) {
if self.pending_screenshots.is_empty() {
return;
}
let events: Vec<_> = ctx.input(|i| {
i.raw
.events
.iter()
.filter_map(|e| match e {
egui::Event::Screenshot { user_data, image, .. } => {
let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::<u64>()).copied()?;
Some((token, image.clone()))
}
_ => None,
})
.collect()
});
for (token, image) in events {
if let Some(i) = self.pending_screenshots.iter().position(|(t, _, _)| *t == token) {
let (_, path, reply) = self.pending_screenshots.remove(i);
let _ = reply.send(control::save_screenshot(self, &image, path.as_deref()));
}
}
}
/// Per-frame logic before layout (control channel, renders, shortcuts, drops).
pub fn logic(&mut self, ctx: &egui::Context) {
if !self.styled {
theme::install_fonts(ctx);
theme::apply(ctx);
self.styled = true;
} else {
self.fonts_ready = true;
}
let now = ctx.input(|i| i.time);
let dt = now - self.last_time;
if dt > 0.0 {
self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1;
}
self.last_time = now;
self.drain_control(ctx);
if !self.synthetic.is_empty() {
ctx.request_repaint();
}
self.renderer.poll(ctx, &mut self.session);
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
if self.fonts_ready {
shortcuts::handle(self, ctx);
}
#[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());
if !dropped.is_empty() {
let _ = self.run("library.import", serde_json::json!({"paths": dropped}));
}
}
}
/// Inject synthetic events (pointer events one per frame; key sequences up to the release).
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
if self.synthetic.is_empty() {
return;
}
let n = match self.synthetic[0] {
egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. } => 1,
_ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1),
};
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));
}
/// Lay out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
if !self.fonts_ready {
ctx.request_repaint();
return;
}
let t0 = now_ms();
// Order matters: earlier panels take the full edge (top bar spans the window; the tool strip,
// right panels and left panel run to the bottom; the bottom bar sits between them).
panels::topbar::show(self, ui);
panels::strip::show(self, ui);
if self.ui.right != state::RightPanel::None {
panels::right::show(self, ui);
}
if self.ui.presets {
panels::presets::show(self, ui);
}
if self.ui.left_panel {
panels::left::show(self, ui);
}
panels::bottombar::show(self, ui);
let t = theme::Tokens::get(&ctx);
let bg = if matches!(self.ui.view, state::ViewMode::Detail | state::ViewMode::Compare) { t.canvas } else { t.grid_bg };
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| match self.ui.view {
state::ViewMode::PhotoGrid | state::ViewMode::SquareGrid => panels::grid::show(self, ui),
state::ViewMode::Detail | state::ViewMode::Compare => panels::detail::show(self, ui),
});
panels::dialogs::show(self, &ctx);
panels::toast(self, &ctx);
self.widgets = widgets::take_registry(&ctx);
self.perf.frame_ms = now_ms() - t0;
}
}
pub fn now_ms() -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
}
#[cfg(target_arch = "wasm32")]
{
0.0
}
}
+157
View File
@@ -0,0 +1,157 @@
//! The bottom bar: view modes, sort, rating/flags pill, copy/paste settings, zoom and view toggles.
use egui::{Align2, Rect, Sense, pos2, vec2};
use lightcraft_catalog::Flag;
use serde_json::json;
use crate::LightcraftApp;
use crate::icons::{Icon, paint};
use crate::state::{BeforeAfter, ViewMode, Zoom};
use crate::theme::Tokens;
use crate::widgets::{icon_button, register, stars};
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::bottom("bottom_bar")
.exact_size(t.bottom_bar_h)
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left: 12, right: 12, top: 0, bottom: 0 }))
.show(ui, |ui| {
let full = ui.max_rect();
ui.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for (id, icon, mode, tip) in [
("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"),
("squareGrid", Icon::GridSquare, ViewMode::SquareGrid, "Square Grid (Shift+G)"),
("detail", Icon::Single, ViewMode::Detail, "Detail (D)"),
] {
if icon_button(ui, id, icon, vec2(32.0, 32.0), app.ui.view == mode, true, tip).clicked() {
let _ = app.run(&format!("view.{id}"), json!({}));
}
}
ui.add_space(10.0);
let (sep, _) = ui.allocate_exact_size(vec2(1.0, 22.0), Sense::hover());
ui.painter().rect_filled(sep, 0.0, t.button_border);
ui.add_space(10.0);
let sort = icon_button(ui, "sort", Icon::Sort, vec2(30.0, 30.0), false, true, "Sort");
egui::Popup::menu(&sort).show(|ui| sort_menu(app, ui));
});
// centre: rating/flag pill + copy/paste settings
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)));
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) {
let _ = app.run("photo.rate", json!({"rating": r}));
}
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)] {
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;
let c = if on {
if f == Flag::Reject { t.reject } else { t.pick }
} else if resp.hovered() {
t.text
} else {
t.icon
};
paint(pill_ui.painter(), r.shrink(3.0), icon, c);
if resp.clicked() {
let _ = app.run("photo.flag", json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }}));
}
}
// copy / paste settings
let copy_r = Rect::from_min_size(pos2(pill.right() + 10.0, pill.top()), vec2(136.0, 30.0));
let has_clip = app.session.clipboard.is_some();
let label = if has_clip { "Paste Edit Settings" } else { "Copy Edit Settings" };
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 });
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" };
app.toast(ui.ctx(), msg);
}
let gear_r = Rect::from_min_size(pos2(copy_r.right() + 4.0, pill.top()), vec2(30.0, 30.0));
let gresp = ui.interact(gear_r, egui::Id::new("copy-gear"), Sense::click());
register(ui.ctx(), "icon:copyGear", gear_r);
ui.painter().rect_filled(gear_r, 15.0, if gresp.hovered() { t.hover } else { t.canvas });
paint(ui.painter(), gear_r.shrink(7.0), Icon::Gear, t.icon);
if gresp.clicked() {
let _ = app.run("dialog.copySettings", json!({}));
}
// 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)));
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() {
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() {
let _ = app.run("view.filmstrip", json!({}));
}
child.add_space(10.0);
let zoom_label = match app.ui.zoom {
Zoom::Fit => "Fit".to_string(),
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);
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))] {
if ui.selectable_label(app.ui.zoom == z, label).clicked() {
app.ui.zoom = z;
app.ui.pan = (0.5, 0.5);
}
}
});
} else {
// thumbnail size slider
let (r, resp) = child.allocate_exact_size(vec2(110.0, 20.0), Sense::click_and_drag());
register(child.ctx(), "slider:thumbSize", r);
let p = child.painter();
let y = r.center().y;
p.line_segment([pos2(r.left(), y), pos2(r.right(), y)], egui::Stroke::new(2.0, t.track));
let f = (app.ui.thumb_size - 90.0) / (480.0 - 90.0);
let x = r.left() + f * r.width();
p.rect_filled(Rect::from_center_size(pos2(x, y), vec2(3.0, 14.0)), 1.0, t.thumb_hover);
if let Some(pp) = resp.interact_pointer_pos()
&& (resp.dragged() || resp.clicked())
{
app.ui.thumb_size = 90.0 + ((pp.x - r.left()) / r.width()).clamp(0.0, 1.0) * (480.0 - 90.0);
}
}
});
}
fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
use lightcraft_catalog::SortKey::*;
let cur = app.session.sort;
ui.label(egui::RichText::new("Sort by").weak());
for (label, key, k) in [
("Capture Date", CaptureDate, "captureDate"),
("Import Date", ImportDate, "importDate"),
("Modified Date", EditDate, "editDate"),
("File Name", FileName, "fileName"),
("Rating", Rating, "rating"),
("File Size", FileSize, "fileSize"),
] {
if ui.selectable_label(cur.key == key, label).clicked() {
let _ = app.run("library.sort", json!({"key": k}));
}
}
ui.separator();
if ui.selectable_label(cur.ascending, "Ascending").clicked() {
let _ = app.run("library.sort", json!({"ascending": true}));
}
if ui.selectable_label(!cur.ascending, "Descending").clicked() {
let _ = app.run("library.sort", json!({"ascending": false}));
}
}
+634
View File
@@ -0,0 +1,634 @@
//! The Detail (loupe) view: the developed photo, before/after, zoom & pan, the filmstrip, and the
//! on-canvas tools (crop, brush/gradient masks, remove spots, white-balance picker).
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use lightcraft_catalog::PhotoId;
use lightcraft_develop::{DevelopSettings, MaskShape};
use lightcraft_geom::{Affine, Point};
use lightcraft_pipeline::geometry::Frame;
use serde_json::json;
use crate::LightcraftApp;
use crate::render::Slot;
use crate::state::{BeforeAfter, CropOverlay, RightPanel, Zoom};
use crate::theme::Tokens;
use crate::widgets::register;
/// An in-progress on-canvas gesture.
#[derive(Clone, Debug)]
pub enum Gesture {
Brush { points: Vec<Point> },
Spot { points: Vec<Point> },
CropHandle { handle: u8, start: lightcraft_geom::Rect, angle: f64 },
CropRotate { start_angle: f64, a0: f32 },
Pan { start: (f32, f32), at: Pos2 },
MaskHandle { mask: u32, handle: u8 },
}
/// Screen ↔ normalized-image mapping for the displayed frame.
#[derive(Clone, Copy)]
pub struct CanvasMap {
rect: Rect,
to_norm: Affine,
from_norm: Affine,
k: f32,
}
impl CanvasMap {
fn new(frame: &Frame, rect: Rect) -> CanvasMap {
let k = 8.0f32;
let (w, h) = ((rect.width() * k).round().max(1.0) as usize, (rect.height() * k).round().max(1.0) as usize);
let to_norm = frame.out_to_norm(w, h);
CanvasMap { rect, to_norm, from_norm: to_norm.inverse().unwrap_or(Affine::IDENTITY), k }
}
pub fn norm(&self, p: Pos2) -> Point {
self.to_norm.apply(Point::new(((p.x - self.rect.left()) * self.k) as f64, ((p.y - self.rect.top()) * self.k) as f64))
}
pub fn screen(&self, n: Point) -> Pos2 {
let q = self.from_norm.apply(n);
pos2(self.rect.left() + q.x as f32 / self.k, self.rect.top() + q.y as f32 / self.k)
}
}
fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], ppp: f32, pan: (f32, f32)) -> Rect {
let (aw, ah) = (area.width(), area.height());
let (w, h) = match zoom {
Zoom::Fit => {
if aw / ah > aspect {
(ah * aspect, ah)
} else {
(aw, aw / aspect)
}
}
Zoom::Fill => {
if aw / ah > aspect {
(aw, aw / aspect)
} else {
(ah * aspect, ah)
}
}
Zoom::Percent(p) => {
// full-resolution pixels at p% (the photo's native width)
let w = img_px[0] as f32 * p as f32 / 100.0 / ppp;
(w, w / aspect)
}
};
let c = if w <= aw && h <= ah {
area.center()
} else {
// pan: which normalized point of the image sits at the area centre
pos2(area.center().x - (pan.0 - 0.5) * w, area.center().y - (pan.1 - 0.5) * h)
};
Rect::from_center_size(c, vec2(w, h))
}
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let full = ui.max_rect();
let film_h = if app.ui.filmstrip { t.film_h } else { 0.0 };
let canvas = Rect::from_min_max(full.min, pos2(full.right(), full.bottom() - film_h));
app.canvas_rect = Some(canvas);
if app.ui.filmstrip {
filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max));
}
let Some(id) = app.session.active() else {
super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip");
return;
};
let Some(photo) = app.session.catalog.photo(id).cloned() else { return };
let d = (*photo.develop).clone();
let crop_tool = app.ui.right == RightPanel::Crop;
let frame = Frame::new(photo.width.max(1) as usize, photo.height.max(1) as usize, &d, !crop_tool);
let aspect = frame.aspect() as f32;
let ppp = ui.ctx().pixels_per_point();
let area = canvas.shrink(if crop_tool { 48.0 } else { 24.0 });
let native = [photo.width.max(1) as usize, photo.height.max(1) as usize];
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()))]
} else {
vec![area]
};
let main_area = *areas.last().unwrap_or(&area);
let img_rect = fit_rect(main_area, aspect, app.ui.zoom, native, ppp, app.ui.pan);
app.image_rect = Some(img_rect);
// request renders: the loupe at display resolution (drafts during drags)
let interacting = app.session.interaction.is_some();
let scale = if interacting { 0.6 } else { 1.0 };
let want = (img_rect.width().max(img_rect.height()) * ppp * scale).min(2560.0) as usize;
let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) };
if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, !crop_tool) {
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);
}
}
let p = ui.painter_at(canvas);
let draw = |slot: Slot, r: Rect| {
if let Some(tex) = app.renderer.textures.get(&slot).filter(|t| t.photo == id) {
p.image(tex.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
true
} else if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) {
p.image(tex.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
true
} else {
false
}
};
if split {
let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan);
draw(Slot::Before, br);
draw(Slot::Main, img_rect);
p.text(pos2(br.left(), br.bottom() + 14.0), Align2::LEFT_CENTER, "Before", t.font(12.0), t.text_dim);
p.text(pos2(img_rect.left(), img_rect.bottom() + 14.0), Align2::LEFT_CENTER, "After", t.font(12.0), t.text_dim);
} else if show_before {
draw(Slot::Before, img_rect);
p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, "Before", t.font(12.0), t.text);
} else if !draw(Slot::Main, img_rect) {
p.text(canvas.center(), Align2::CENTER_CENTER, "Rendering…", t.font(13.0), t.text_dim);
}
if app.ui.before_after == BeforeAfter::Split {
let mid = img_rect.center().x;
if let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) {
let left = Rect::from_min_max(img_rect.min, pos2(mid, img_rect.bottom()));
p.image(tex.tex.id(), left, Rect::from_min_max(pos2(0.0, 0.0), pos2(0.5, 1.0)), Color32::WHITE);
}
p.line_segment([pos2(mid, img_rect.top()), pos2(mid, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE));
}
if app.ui.show_clipping && !show_before {
clipping_overlay(app, &p, img_rect);
}
register(ui.ctx(), "canvas:image", img_rect);
let map = CanvasMap::new(&frame, img_rect);
let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag());
match app.ui.right {
RightPanel::Crop => crop_overlay(app, ui, &resp, &map, &frame, &d, id),
RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d),
RightPanel::Remove => remove_overlay(app, ui, &resp, &map, &d),
_ => general_interaction(app, ui, &resp, &map, img_rect, canvas, native, aspect),
}
resp.context_menu(|ui| super::grid::context_menu(app, ui, id));
}
fn clipping_overlay(app: &LightcraftApp, p: &egui::Painter, r: Rect) {
// Highlight clipped regions using the histogram's extremes isn't spatial; show a subtle frame hint.
if let Some(h) = app.renderer.textures.get(&Slot::Main).and_then(|t| t.histogram.as_ref()) {
let (lo, hi) = h.clipping();
if hi > 0.003 {
p.rect_stroke(r, 0.0, Stroke::new(2.0, Color32::from_rgb(255, 60, 60)), StrokeKind::Outside);
}
if lo > 0.003 {
p.rect_stroke(r.expand(3.0), 0.0, Stroke::new(2.0, Color32::from_rgb(60, 120, 255)), StrokeKind::Outside);
}
}
}
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()
&& let Some(q) = resp.interact_pointer_pos()
{
let n = map.norm(q);
let _ = app.run("develop.wbPick", json!({"x": n.x, "y": n.y}));
app.ui.tool.clear();
}
return;
}
// click toggles Fit ↔ 100 % at the clicked point; drag pans when zoomed
let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0;
if resp.double_clicked() || (resp.clicked() && !zoomed) {
if let Some(q) = resp.interact_pointer_pos() {
let u = ((q.x - img.left()) / img.width()).clamp(0.0, 1.0);
let v = ((q.y - img.top()) / img.height()).clamp(0.0, 1.0);
app.ui.pan = (u, v);
}
app.ui.zoom = if matches!(app.ui.zoom, Zoom::Fit) { Zoom::Percent(100) } else { Zoom::Fit };
} else if resp.clicked() && zoomed {
app.ui.zoom = Zoom::Fit;
}
if zoomed {
ui.ctx().set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
if resp.dragged() {
let dlt = resp.drag_delta();
app.ui.pan.0 = (app.ui.pan.0 - dlt.x / img.width()).clamp(0.0, 1.0);
app.ui.pan.1 = (app.ui.pan.1 - dlt.y / img.height()).clamp(0.0, 1.0);
}
}
let _ = (native, aspect);
}
// ------------------------------------------------------------------------ crop
fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, frame: &Frame, d: &DevelopSettings, id: PhotoId) {
let quad = frame_crop_quad(d, frame);
let pts: Vec<Pos2> = quad.iter().map(|q| map.screen(*q)).collect();
let p = ui.painter();
// darken outside the crop
let img = map.rect;
let shade = Color32::from_black_alpha(150);
let mut mesh = egui::epaint::Mesh::default();
let outer = [img.left_top(), img.right_top(), img.right_bottom(), img.left_bottom()];
for i in 0..4 {
let a = outer[i];
let b = outer[(i + 1) % 4];
let c = pts[(i + 1) % 4];
let dd = pts[i];
let base = mesh.vertices.len() as u32;
for v in [a, b, c, dd] {
mesh.vertices.push(egui::epaint::Vertex { pos: v, uv: Pos2::ZERO, color: shade });
}
mesh.indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]);
}
p.add(mesh);
p.add(egui::Shape::closed_line(pts.clone(), Stroke::new(1.0, Color32::WHITE)));
// overlay guides
let lerp = |a: Pos2, b: Pos2, t: f32| a + (b - a) * t;
let guide = Stroke::new(0.8, Color32::from_white_alpha(150));
let fracs: Vec<f32> = match app.ui.crop_overlay {
CropOverlay::Thirds => vec![1.0 / 3.0, 2.0 / 3.0],
CropOverlay::Grid => (1..8).map(|i| i as f32 / 8.0).collect(),
CropOverlay::Golden => vec![0.382, 0.618],
CropOverlay::Diagonal | CropOverlay::None => vec![],
};
for f in fracs {
p.line_segment([lerp(pts[0], pts[1], f), lerp(pts[3], pts[2], f)], guide);
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();
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 };
p.rect_filled(Rect::from_center_size(*h, vec2(s, 3.0)), 0.0, Color32::WHITE);
p.rect_filled(Rect::from_center_size(*h, vec2(3.0, s)), 0.0, Color32::WHITE);
}
let inside = |q: Pos2| {
let n = map.norm(q);
let s = to_straight(n, d.crop.geometry.angle, frame);
d.crop.geometry.rect.contains(Point::new(s.x.clamp(-1.0, 2.0), s.y))
};
if let Some(hq) = resp.hover_pos() {
let near = handles.iter().position(|h| h.distance(hq) < 12.0);
ui.ctx().set_cursor_icon(match near {
Some(0 | 2) => egui::CursorIcon::ResizeNwSe,
Some(1 | 3) => egui::CursorIcon::ResizeNeSw,
Some(4 | 6) => egui::CursorIcon::ResizeVertical,
Some(_) => egui::CursorIcon::ResizeHorizontal,
None if inside(hq) => egui::CursorIcon::Move,
None => egui::CursorIcon::Alias,
});
}
if resp.drag_started()
&& let Some(q) = resp.interact_pointer_pos()
{
let _ = app.run("develop.beginInteraction", json!({"label": "Crop"}));
app.gesture = Some(match handles.iter().position(|h| h.distance(q) < 12.0) {
Some(h) => Gesture::CropHandle { handle: h as u8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle },
None if inside(q) => Gesture::CropHandle { handle: 8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle },
None => {
let c = map.screen(Point::new(0.5, 0.5));
Gesture::CropRotate { start_angle: d.crop.geometry.angle, a0: (q - c).angle() }
}
});
}
if resp.dragged()
&& let Some(q) = resp.interact_pointer_pos()
{
match app.gesture.clone() {
Some(Gesture::CropHandle { handle, start, angle }) => {
let n = to_straight(map.norm(q), angle, frame);
let o = resp.interact_pointer_pos().map(|q0| q0 - resp.drag_delta()).unwrap_or(q);
let _ = o;
let mut r = start;
let orig = ui.input(|i| i.pointer.press_origin()).map(|q0| to_straight(map.norm(q0), angle, frame)).unwrap_or(n);
let (dx, dy) = (n.x - orig.x, n.y - orig.y);
match handle {
0 => (r.x0, r.y0) = (start.x0 + dx, start.y0 + dy),
1 => (r.x1, r.y0) = (start.x1 + dx, start.y0 + dy),
2 => (r.x1, r.y1) = (start.x1 + dx, start.y1 + dy),
3 => (r.x0, r.y1) = (start.x0 + dx, start.y1 + dy),
4 => r.y0 = start.y0 + dy,
5 => r.x1 = start.x1 + dx,
6 => r.y1 = start.y1 + dy,
7 => r.x0 = start.x0 + dx,
_ => r = start.translate(lightcraft_geom::Vec2::new(dx, dy)),
}
// aspect lock
if let Some((aw, ah)) = d.crop.aspect
&& handle < 4
{
let (iw, ih) = (frame.ow, frame.oh);
let a = if (iw >= ih) == (aw >= ah) { aw as f64 / ah as f64 } else { ah as f64 / aw as f64 };
let w_px = (r.x1 - r.x0).abs() * iw;
let h_px = w_px / a;
let hn = h_px / ih;
if handle == 0 || handle == 1 {
r.y0 = r.y1 - hn;
} else {
r.y1 = r.y0 + hn;
}
}
let rr = lightcraft_geom::Rect::new(r.x0.min(r.x1), r.y0.min(r.y1), r.x0.max(r.x1), r.y0.max(r.y1));
if rr.width() > 0.02 && rr.height() > 0.02 {
let _ = app.run("crop.set", json!({"rect": [rr.x0, rr.y0, rr.x1, rr.y1]}));
}
}
Some(Gesture::CropRotate { start_angle, a0 }) => {
let c = map.screen(Point::new(0.5, 0.5));
let a = (q - c).angle();
let ang = (start_angle + (a - a0).to_degrees() as f64).clamp(-45.0, 45.0);
let _ = app.run("crop.straighten", json!({"angle": (ang * 100.0).round() / 100.0}));
}
_ => {}
}
}
if resp.drag_stopped() {
app.gesture = None;
let _ = app.run("develop.endInteraction", json!({}));
}
let _ = id;
}
/// Normalized oriented coords → the straightened (rotated) frame the crop rect lives in.
fn to_straight(n: Point, angle: f64, frame: &Frame) -> Point {
let (w, h) = (frame.ow, frame.oh);
let px = Point::new(n.x * w, n.y * h);
let r = Affine::rotate_about(angle.to_radians(), Point::new(w / 2.0, h / 2.0)).apply(px);
Point::new(r.x / w, r.y / h)
}
fn frame_crop_quad(d: &DevelopSettings, frame: &Frame) -> [Point; 4] {
let f = Frame { crop: d.crop.geometry, ..frame.clone() };
f.crop_quad_norm()
}
// ------------------------------------------------------------------------ masks
fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) {
let red = Color32::from_rgba_unmultiplied(230, 30, 40, 90);
let long = (map.rect.width().max(map.rect.height())) as f64;
let active = app.session.active_mask;
let p = ui.painter();
for m in &d.masks {
let sel = Some(m.id) == active;
for c in &m.components {
match &c.shape {
MaskShape::Brush { strokes } if sel && app.ui.mask_overlay => {
for s in strokes {
let r = (s.size * long) as f32;
for q in &s.points {
p.circle_filled(map.screen(*q), r, if s.erase { Color32::from_black_alpha(60) } else { red });
}
}
}
MaskShape::Radial { center, rx, ry, angle, .. } => {
let c0 = map.screen(*center);
let pts: Vec<Pos2> = (0..64)
.map(|i| {
let a = i as f64 / 64.0 * std::f64::consts::TAU;
let (s, co) = angle.to_radians().sin_cos();
let (x, y) = (rx * a.cos(), ry * a.sin());
let l = frame_long_norm(map);
map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1))
})
.collect();
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 }))));
pin(p, c0, sel);
if sel {
register(ui.ctx(), format!("maskPin:{}", m.id), Rect::from_center_size(c0, vec2(14.0, 14.0)));
}
}
MaskShape::Linear { start, end } => {
let (a, b) = (map.screen(*start), map.screen(*end));
let dir = (b - a).normalized();
let perp = vec2(-dir.y, dir.x) * 3000.0;
for (q, alpha) in [(a, 230), (b, 110)] {
p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(if sel { alpha } else { 80 })));
}
pin(p, a + (b - a) * 0.5, sel);
if sel {
for (i, q) in [a, b].iter().enumerate() {
p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE));
register(ui.ctx(), format!("maskHandle:{}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0)));
}
}
}
_ => {}
}
}
}
// brush tool
if app.ui.tool == "brush" {
let r = (app.ui.brush_size as f64 * long) as f32;
if let Some(h) = resp.hover_pos() {
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE));
p.circle_stroke(h, r * (1.0 - app.ui.brush_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(120)));
}
if let Some(q) = resp.interact_pointer_pos()
&& (resp.drag_started() || resp.dragged() || resp.clicked())
{
let n = map.norm(q);
match &mut app.gesture {
Some(Gesture::Brush { points }) => points.push(n),
_ => app.gesture = Some(Gesture::Brush { points: vec![n] }),
}
}
if let Some(Gesture::Brush { points }) = &app.gesture {
for q in points {
p.circle_filled(map.screen(*q), r, red);
}
}
if (resp.drag_stopped() || resp.clicked())
&& let Some(Gesture::Brush { points }) = app.gesture.take()
{
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
let _ = app.run(
"mask.brushStroke",
json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, "erase": app.ui.brush_erase}),
);
}
return;
}
// drag pins/handles of the active mask
let Some(mid) = active else { return };
let Some(m) = d.masks.iter().find(|m| m.id == mid) else { return };
let Some(comp) = m.components.first() else { return };
if resp.drag_started()
&& let Some(q) = resp.interact_pointer_pos()
{
let handle = match &comp.shape {
MaskShape::Radial { center, .. } => (map.screen(*center).distance(q) < 14.0).then_some(0),
MaskShape::Linear { start, end } => {
if map.screen(*start).distance(q) < 14.0 {
Some(1)
} else if map.screen(*end).distance(q) < 14.0 {
Some(2)
} else {
Some(0)
}
}
_ => None,
};
if let Some(h) = handle {
let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"}));
app.gesture = Some(Gesture::MaskHandle { mask: mid, handle: h });
}
}
if resp.dragged()
&& let (Some(Gesture::MaskHandle { mask, handle }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos())
{
let n = map.norm(q);
let dn = {
let q0 = q - resp.drag_delta();
let n0 = map.norm(q0);
Point::new(n.x - n0.x, n.y - n0.y)
};
let new_shape = match comp.shape.clone() {
MaskShape::Radial { center, rx, ry, angle, feather, invert } => {
MaskShape::Radial { center: Point::new(center.x + dn.x, center.y + dn.y), rx, ry, angle, feather, invert }
}
MaskShape::Linear { start, end } => match handle {
1 => MaskShape::Linear { start: n, end },
2 => MaskShape::Linear { start, end: n },
_ => MaskShape::Linear { start: Point::new(start.x + dn.x, start.y + dn.y), end: Point::new(end.x + dn.x, end.y + dn.y) },
},
s => s,
};
let _ = app.run("mask.update", json!({"id": mask, "shape": new_shape}));
}
if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::MaskHandle { .. })) {
app.gesture = None;
let _ = app.run("develop.endInteraction", json!({}));
}
}
/// Long-edge units → normalized units for x and y.
fn frame_long_norm(map: &CanvasMap) -> (f64, f64) {
// derive from the mapping: 1 normalized unit in x and y in screen points
let o = map.screen(Point::new(0.0, 0.0));
let x = map.screen(Point::new(1.0, 0.0)).distance(o) as f64;
let y = map.screen(Point::new(0.0, 1.0)).distance(o) as f64;
let l = x.max(y);
(l / x.max(1e-9), l / y.max(1e-9))
}
fn pin(p: &egui::Painter, c: Pos2, sel: bool) {
p.circle_filled(c, 6.0, if sel { Color32::from_rgb(1, 101, 221) } else { Color32::from_gray(200) });
p.circle_stroke(c, 6.0, Stroke::new(1.5, Color32::WHITE));
}
// ------------------------------------------------------------------------ remove
fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) {
let long = (map.rect.width().max(map.rect.height())) as f64;
let p = ui.painter();
for s in &d.spots {
let r = (s.size * long) as f32;
for q in &s.points {
p.circle_stroke(map.screen(*q), r, Stroke::new(1.0, Color32::from_white_alpha(200)));
}
}
let r = (app.ui.remove_size as f64 * long) as f32;
if let Some(h) = resp.hover_pos() {
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE));
}
if let Some(q) = resp.interact_pointer_pos()
&& (resp.drag_started() || resp.dragged() || resp.clicked())
{
let n = map.norm(q);
match &mut app.gesture {
Some(Gesture::Spot { points }) => points.push(n),
_ => app.gesture = Some(Gesture::Spot { points: vec![n] }),
}
}
if let Some(Gesture::Spot { points }) = &app.gesture {
for q in points {
p.circle_filled(map.screen(*q), r, Color32::from_white_alpha(60));
}
}
if (resp.drag_stopped() || resp.clicked())
&& let Some(Gesture::Spot { points }) = app.gesture.take()
{
let mode = match app.ui.tool.as_str() {
"heal" => "heal",
"clone" => "clone",
_ => "remove",
};
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
let _ = app.run("spot.add", json!({"mode": mode, "points": pts, "size": app.ui.remove_size}));
}
}
// ------------------------------------------------------------------------ filmstrip
fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
let t = Tokens::get(ui.ctx());
ui.painter().rect_filled(r, 0.0, t.canvas);
ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 4.0)), 0.0, Color32::from_gray(0x20));
let ids = app.session.visible_cloned();
let active = app.session.selection.active;
let cell_w = 120.0;
let ppp = ui.ctx().pixels_per_point();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(0.0, 4.0))));
let scroll_key = egui::Id::new("film-last-active");
let last: Option<PhotoId> = child.data(|d| d.get_temp(scroll_key));
child.data_mut(|d| d.insert_temp(scroll_key, active));
egui::ScrollArea::horizontal().id_salt("filmstrip").auto_shrink([false, false]).show_viewport(&mut child, |ui, vp| {
let (area, _) = ui.allocate_exact_size(vec2(ids.len() as f32 * cell_w, r.height() - 16.0), Sense::hover());
for (i, id) in ids.iter().enumerate() {
let cr = Rect::from_min_size(pos2(area.left() + i as f32 * cell_w, area.top()), vec2(cell_w, area.height()));
if Some(*id) == active && last != active {
ui.scroll_to_rect(cr, Some(egui::Align::Center));
}
let local = Rect::from_min_size(pos2(i as f32 * cell_w, 0.0), cr.size());
if !local.intersects(vp.expand2(vec2(cell_w * 4.0, 0.0))) {
continue;
}
let resp = ui.interact(cr, egui::Id::new(("film", id.0)), Sense::click());
register(ui.ctx(), format!("film:{}", id.0), cr);
let sel = Some(*id) == active;
let p = ui.painter();
if sel {
p.rect_filled(cr, 0.0, t.cell_selected);
} else if resp.hovered() {
p.rect_filled(cr, 0.0, t.cell_selected.gamma_multiply(0.6));
}
if let Some(ph) = app.session.catalog.photo(*id) {
let name = ph.file_name.rsplit_once('.').map(|(n, _)| n).unwrap_or(&ph.file_name);
let short: String = if name.len() > 14 { format!("{}…", &name[..13]) } else { name.to_string() };
p.text(pos2(cr.left() + 8.0, cr.top() + 10.0), Align2::LEFT_CENTER, short, t.font(10.0), t.text_dim);
p.text(pos2(cr.right() - 8.0, cr.top() + 10.0), Align2::RIGHT_CENTER, &ph.format, t.semibold(8.5), t.text_dim);
}
let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0));
if let Some(job) = app.session.render_job(*id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2, 256, false, true) {
app.renderer.request(Slot::Thumb(*id), job, 8);
}
if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(*id)) {
let [tw, th] = tex.size;
let s = (img_area.width() / tw as f32).min(img_area.height() / th as f32);
let fr = Rect::from_center_size(img_area.center(), vec2(tw as f32 * s, th as f32 * s));
p.image(tex.tex.id(), fr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
if sel {
p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside);
}
}
if resp.clicked() {
let m = ui.input(|i| i.modifiers);
let mode = if m.command { "toggle" } else if m.shift { "range" } else { "replace" };
let _ = app.run("library.select", json!({"ids": [id.0], "mode": mode}));
}
}
});
}
+130
View File
@@ -0,0 +1,130 @@
//! Modal dialogs (new album, rename, create preset, choose settings to copy, export, about, shortcuts).
use lightcraft_develop::SettingsGroup;
use serde_json::json;
use crate::LightcraftApp;
use crate::state::Dialog;
use crate::theme::Tokens;
pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(mut dlg) = app.ui.dialog.clone() else { return };
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
ctx.layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("dim"))).rect_filled(screen, 0.0, egui::Color32::from_black_alpha(140));
let mut close = false;
let mut confirm = false;
let title = match &dlg {
Dialog::NewAlbum { folder: true, .. } => "Create Folder",
Dialog::NewAlbum { .. } => "Create Album",
Dialog::RenameAlbum { .. } => "Rename Album",
Dialog::CreatePreset { .. } => "Create Preset",
Dialog::CopySettings { .. } => "Choose Edit Settings to Copy",
Dialog::Export { .. } => "Export",
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;
}
}
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();
}
}
});
});
}
}
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;
}
if confirm {
let _ = confirm_dialog(app, &dlg);
close = true;
}
app.ui.dialog = if close { None } else { Some(dlg) };
}
/// Apply a dialog's action (also used by `ui.dialog.confirm`).
pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_json::Value, String> {
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::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),
}
}
+562
View File
@@ -0,0 +1,562 @@
//! The Edit panel: histogram, Auto/B&W, profile, and the Light / Color / Effects / Detail / Optics
//! sections, built from the engine's control specs.
use egui::epaint::{Mesh, Vertex};
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2};
use lightcraft_catalog::PhotoId;
use lightcraft_develop::{ControlSpec, DevelopSettings, Section, Track, WbMode, controls};
use lightcraft_geom::Point;
use serde_json::{Value, json};
use crate::LightcraftApp;
use crate::icons::{Icon, paint};
use crate::render::Slot;
use crate::theme::Tokens;
use crate::widgets::{BAND_COLORS, SliderOut, divider, flyout_row, hex, register, section_header, slider, text_button};
/// Commit a slider interaction: begin → live updates → end, so a drag is one undo step.
pub fn apply_slider_out(
app: &mut LightcraftApp,
spec: &ControlSpec,
out: SliderOut,
mut set: impl FnMut(&mut LightcraftApp, f64) -> Result<Value, String>,
) {
if out.drag_started && !out.reset {
let _ = app.run("develop.beginInteraction", json!({"label": spec.label}));
}
if let Some(v) = out.value {
let _ = set(app, v);
}
if out.drag_stopped || out.reset {
let _ = app.run("develop.endInteraction", json!({}));
}
}
fn control(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: &str, enabled: bool) {
let Some(spec) = controls::find(id) else { return };
let v = controls::get(d, id).unwrap_or(spec.default);
let out = slider(ui, spec, v, enabled, None);
apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": spec.id, "value": v})));
}
/// 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 };
fn k_to_rel(k: f64) -> f64 {
((1e6 / 6500.0 - 1e6 / k) / 0.8).clamp(-100.0, 100.0)
}
fn rel_to_k(r: f64) -> f64 {
1e6 / (1e6 / 6500.0 - r * 0.8)
}
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let t = Tokens::get(ui.ctx());
let d = app.session.develop_of(id).unwrap_or_default();
let raw = app.session.catalog.photo(id).is_some_and(|p| p.kind == lightcraft_catalog::MediaKind::Raw);
if app.ui.histogram {
histogram(app, ui, id);
}
// header: Edit + Auto / B&W / HDR
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::hover());
ui.painter().text(pos2(hr.left() + 24.0, hr.bottom() - 10.0), Align2::LEFT_CENTER, "Edit", t.semibold(15.0), t.text);
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 14 }).show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
if text_button(ui, "auto", "Auto", false).clicked() {
let _ = app.run("develop.auto", json!({}));
app.toast(ui.ctx(), "Auto settings applied");
}
let bw = crate::is_bw(&d);
if text_button(ui, "bw", "B&W", bw).clicked() {
let _ = app.run("develop.treatment", json!({}));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if text_button(ui, "reset", "Reset", false).on_hover_text("Reset all edits (Cmd+Shift+R)").clicked() {
let _ = app.run("develop.reset", json!({}));
}
});
});
});
divider(ui);
// profile row
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 14, bottom: 14 }).show(ui, |ui| {
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);
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() {
let _ = app.run("develop.profile", json!({"id": p.id}));
}
}
});
});
});
divider(ui);
section(app, ui, &d, "light", "Light", |app, ui, d| {
for c in ["light.exposure", "light.contrast", "light.highlights", "light.shadows", "light.whites", "light.blacks"] {
control(app, ui, d, c, true);
}
ui.add_space(6.0);
let open = app.ui.flyout_open("curve");
if flyout_row(ui, "curve", "Curve", Icon::Curve, open).clicked() {
app.ui.toggle_flyout("curve");
}
if open {
curve_editor(app, ui, id, d);
}
ui.add_space(8.0);
});
section(app, ui, &d, "color", "Color", |app, ui, d| {
// White balance row
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);
egui::Popup::menu(&r).show(|ui| {
for m in WbMode::ALL {
if m == WbMode::Custom {
continue;
}
if ui.selectable_label(d.wb.mode == m, m.label()).clicked() {
let mode = serde_json::to_value(m).unwrap_or_default();
let _ = app.run("develop.wb", json!({"mode": mode}));
}
}
});
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() {
app.ui.tool = if active { String::new() } else { "wbPicker".into() };
}
});
});
});
if raw {
control(app, ui, d, "wb.temp", true);
control(app, ui, d, "wb.tint", true);
} else {
let out = slider(ui, &REL_TEMP, k_to_rel(d.wb.temp), true, None);
apply_slider_out(app, &REL_TEMP, out, |app, v| app.run("develop.set", json!({"control": "wb.temp", "value": rel_to_k(v)})));
let out = slider(ui, &REL_TINT, d.wb.tint.clamp(-100.0, 100.0), true, None);
apply_slider_out(app, &REL_TINT, out, |app, v| app.run("develop.set", json!({"control": "wb.tint", "value": v})));
}
control(app, ui, d, "color.vibrance", true);
control(app, ui, d, "color.saturation", true);
ui.add_space(6.0);
let open = app.ui.flyout_open("mixer");
if flyout_row(ui, "mixer", if crate::is_bw(d) { "B&W Mixer" } else { "Color Mixer" }, Icon::Radial, open).clicked() {
app.ui.toggle_flyout("mixer");
}
if open {
mixer(app, ui, d);
}
let open = app.ui.flyout_open("grading");
if flyout_row(ui, "grading", "Color Grading", Icon::Presets, open).clicked() {
app.ui.toggle_flyout("grading");
}
if open {
grading(app, ui, d);
}
ui.add_space(8.0);
});
section(app, ui, &d, "effects", "Effects", |app, ui, d| {
for c in ["effects.texture", "effects.clarity", "effects.dehaze"] {
control(app, ui, d, c, true);
}
sub_title(ui, "Vignette");
for c in ["vignette.amount", "vignette.midpoint", "vignette.feather", "vignette.roundness", "vignette.highlights"] {
let enabled = c == "vignette.amount" || d.vignette.amount != 0.0;
control(app, ui, d, c, enabled);
}
sub_title(ui, "Grain");
for c in ["grain.amount", "grain.size", "grain.roughness"] {
control(app, ui, d, c, c == "grain.amount" || d.grain.amount != 0.0);
}
ui.add_space(8.0);
});
section(app, ui, &d, "detail", "Detail", |app, ui, d| {
for c in ["detail.sharpenAmount", "detail.sharpenRadius", "detail.sharpenDetail", "detail.sharpenMasking"] {
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"] {
control(app, ui, d, c, true);
}
ui.add_space(8.0);
});
section(app, ui, &d, "optics", "Optics", |app, ui, d| {
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 4 }).show(ui, |ui| {
let mut ca = d.optics.remove_ca;
if ui.checkbox(&mut ca, "Remove Chromatic Aberration").changed() {
let _ = app.run("develop.merge", json!({"settings": {"optics": {"remove_ca": ca}}, "label": "Remove CA"}));
}
let mut lp = d.optics.lens_profile;
if ui.checkbox(&mut lp, "Use Lens Corrections").changed() {
let _ = app.run("develop.merge", json!({"settings": {"optics": {"lens_profile": lp}}, "label": "Lens Corrections"}));
}
});
for c in ["optics.distortion", "optics.vignetting", "optics.vignettingMidpoint", "optics.defringePurple", "optics.defringeGreen"] {
control(app, ui, d, c, true);
}
ui.add_space(8.0);
});
ui.add_space(40.0);
}
fn sub_title(ui: &mut egui::Ui, title: &str) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover());
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)) {
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 {
let _ = app.run("develop.sectionEnabled", json!({"section": id, "enabled": on}));
} else if resp.clicked() {
app.ui.toggle_section(id);
}
if open {
body(app, ui, d);
}
divider(ui);
}
// ------------------------------------------------------------------------------ histogram
fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let (r, resp) = ui.allocate_exact_size(vec2(w, 118.0), Sense::click());
register(ui.ctx(), "histogram", r);
let p = ui.painter();
p.rect_filled(r, 0.0, t.button);
let plot = Rect::from_min_max(r.min + vec2(0.0, 6.0), pos2(r.right(), r.bottom() - 26.0));
if let Some(h) = app.renderer.textures.get(&Slot::Main).and_then(|t| t.histogram.clone()) {
let smooth = |v: &[u32]| -> Vec<f32> {
(0..256)
.map(|i| {
let (a, b) = (i.max(2) - 2, (i + 3).min(256));
v[a..b].iter().map(|x| *x as f32).sum::<f32>() / (b - a) as f32
})
.collect()
};
let chans = [(smooth(&h.r), hex("#df3939")), (smooth(&h.g), hex("#44b072")), (smooth(&h.b), hex("#3b6fe0"))];
let peak = chans.iter().flat_map(|(v, _)| v.iter().skip(2).take(252)).fold(1.0f32, |a, b| a.max(*b));
let to = |i: usize, v: f32| pos2(plot.left() + plot.width() * i as f32 / 255.0, plot.bottom() - plot.height() * (v / peak).sqrt().min(1.0));
// luminance fill
let lum = smooth(&h.luma);
let mut mesh = Mesh::default();
for i in 0..256 {
let top = to(i, lum[i]);
mesh.vertices.push(Vertex { pos: top, uv: Pos2::ZERO, color: Color32::from_gray(120).gamma_multiply(0.35) });
mesh.vertices.push(Vertex { pos: pos2(top.x, plot.bottom()), uv: Pos2::ZERO, color: Color32::from_gray(120).gamma_multiply(0.35) });
if i > 0 {
let k = (i as u32) * 2;
mesh.indices.extend([k - 2, k - 1, k, k - 1, k + 1, k]);
}
}
p.add(mesh);
for (v, c) in &chans {
let pts: Vec<Pos2> = (0..256).map(|i| to(i, v[i])).collect();
p.add(egui::Shape::line(pts, Stroke::new(1.5, *c)));
}
let (lo, hi) = h.clipping();
for (on, x, active) in [(lo > 0.003, plot.left() + 12.0, app.ui.show_clipping), (hi > 0.003, plot.right() - 12.0, app.ui.show_clipping)] {
let tri = Rect::from_center_size(pos2(x, plot.top() + 10.0), vec2(14.0, 12.0));
p.rect_filled(tri, 2.0, if active { t.hover } else { Color32::from_gray(60) });
paint(p, tri.shrink(2.0), Icon::ChevronDown, if on { Color32::WHITE } else { Color32::from_gray(130) });
}
}
if resp.clicked() {
app.ui.show_clipping = !app.ui.show_clipping;
}
// EXIF strip
if let Some(ph) = app.session.catalog.photo(id) {
let m = &ph.meta;
let items = [
m.iso.map(|i| format!("ISO {i}")).unwrap_or_default(),
m.focal_mm.map(|f| format!("{f:.0}mm")).unwrap_or_default(),
m.aperture.map(|a| format!("f/{a:.1}")).unwrap_or_default(),
m.shutter.clone(),
];
let y = r.bottom() - 13.0;
for (i, s) in items.iter().enumerate() {
let x = r.left() + 18.0 + (r.width() - 36.0) * i as f32 / 3.0;
let align = match i {
0 => Align2::LEFT_CENTER,
3 => Align2::RIGHT_CENTER,
_ => Align2::CENTER_CENTER,
};
p.text(pos2(x, y), align, s, t.font(12.5), t.text_label);
}
}
}
// ------------------------------------------------------------------------------ tone curve
fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &DevelopSettings) {
let t = Tokens::get(ui.ctx());
// 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"))] {
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;
if ch == "parametric" {
paint(ui.painter(), r.shrink(4.0), Icon::Curve, if sel { t.text } else { t.icon });
} else {
ui.painter().circle_stroke(r.center(), 7.0, Stroke::new(2.0, c));
if sel {
ui.painter().circle_filled(r.center(), 3.5, c);
}
}
if sel {
ui.painter().line_segment([r.left_bottom() + vec2(4.0, 1.0), r.right_bottom() + vec2(-4.0, 1.0)], Stroke::new(2.0, t.text));
}
if resp.clicked() {
app.ui.curve_channel = ch.into();
}
}
});
});
let ch = app.ui.curve_channel.clone();
let w = ui.available_width();
let side = (w - 48.0).max(80.0);
let (outer, _) = ui.allocate_exact_size(vec2(w, side + 12.0), Sense::hover());
let r = Rect::from_min_size(pos2(outer.left() + 24.0, outer.top() + 6.0), vec2(side, side));
let resp = ui.interact(r, egui::Id::new(("curve", &ch)), Sense::click_and_drag());
register(ui.ctx(), "curve", r);
let p = ui.painter();
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([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());
let from_screen = |q: Pos2| (((q.x - r.left()) / r.width()).clamp(0.0, 1.0) as f64, ((r.bottom() - q.y) / r.height()).clamp(0.0, 1.0) as f64);
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 pts: Vec<Pos2> = (0..=64)
.map(|i| {
let x = i as f64 / 64.0;
let mut dd = 0.0;
for (a, b, amt) in regions {
let (ctr, half) = ((a + b) / 2.0, (b - a) * 0.75 + 0.05);
let tt = ((x - ctr) / half).clamp(-1.0, 1.0);
dd += amt / 100.0 * 0.22 * (0.5 + 0.5 * (tt * std::f64::consts::PI).cos());
}
to_screen(x, (x + dd * 4.0 * x * (1.0 - x)).clamp(0.0, 1.0))
})
.collect();
p.add(egui::Shape::line(pts, Stroke::new(2.0, Color32::from_gray(220))));
for c in ["curve.highlights", "curve.lights", "curve.darks", "curve.shadows"] {
control(app, ui, d, c, true);
}
return;
}
let pts_of = |d: &DevelopSettings| -> Vec<Point> {
let v = match ch.as_str() {
"red" => &d.curve.red,
"green" => &d.curve.green,
"blue" => &d.curve.blue,
_ => &d.curve.master,
};
if v.is_empty() { vec![Point::new(0.0, 0.0), Point::new(1.0, 1.0)] } else { v.clone() }
};
let pts = pts_of(d);
let curve = lightcraft_color::spline::MonotoneCurve::new(&pts.iter().map(|q| (q.x, q.y)).collect::<Vec<_>>());
let color = match ch.as_str() {
"red" => hex("#dd3333"),
"green" => hex("#33bb55"),
"blue" => hex("#3377ee"),
_ => Color32::from_gray(225),
};
let line: Vec<Pos2> = (0..=96).map(|i| to_screen(i as f64 / 96.0, curve.eval(i as f64 / 96.0).clamp(0.0, 1.0))).collect();
p.add(egui::Shape::line(line, Stroke::new(2.0, color)));
for q in &pts {
p.circle_filled(to_screen(q.x, q.y), 4.0, color);
}
// interaction: drag the nearest point, click empty space to add, double-click to delete
let drag_id = egui::Id::new(("curve-drag", &ch));
let mut dragging: Option<usize> = ui.data(|dd| dd.get_temp(drag_id));
let nearest = |q: Pos2| pts.iter().enumerate().map(|(i, p)| (i, to_screen(p.x, p.y).distance(q))).min_by(|a, b| a.1.total_cmp(&b.1));
let mut new_pts = None;
if resp.double_clicked()
&& let Some(q) = resp.interact_pointer_pos()
&& let Some((i, dist)) = nearest(q)
&& dist < 10.0
&& i != 0
&& i != pts.len() - 1
{
let mut v = pts.clone();
v.remove(i);
new_pts = Some(v);
} else if resp.drag_started()
&& let Some(q) = resp.interact_pointer_pos()
{
let _ = app.run("develop.beginInteraction", json!({"label": "Tone Curve"}));
match nearest(q) {
Some((i, dist)) if dist < 10.0 => dragging = Some(i),
_ => {
let (x, y) = from_screen(q);
let mut v = pts.clone();
let i = v.iter().position(|p| p.x > x).unwrap_or(v.len());
v.insert(i, Point::new(x, y));
dragging = Some(i);
new_pts = Some(v);
}
}
} else if resp.dragged()
&& let (Some(i), Some(q)) = (dragging, resp.interact_pointer_pos())
{
let (mut x, y) = from_screen(q);
let mut v = pts.clone();
if i < v.len() {
let lo = if i == 0 { 0.0 } else { v[i - 1].x + 0.01 };
let hi = if i + 1 >= v.len() { 1.0 } else { v[i + 1].x - 0.01 };
if i == 0 {
x = x.min(hi);
} else if i + 1 == v.len() {
x = x.max(lo);
} else {
x = x.clamp(lo, hi);
}
v[i] = Point::new(x, y);
new_pts = Some(v);
}
} else if resp.clicked()
&& let Some(q) = resp.interact_pointer_pos()
&& nearest(q).is_none_or(|(_, dist)| dist >= 10.0)
{
let (x, y) = from_screen(q);
let mut v = pts.clone();
let i = v.iter().position(|p| p.x > x).unwrap_or(v.len());
v.insert(i, Point::new(x, y));
new_pts = Some(v);
}
if let Some(v) = new_pts {
let arr: Vec<[f64; 2]> = v.iter().map(|p| [p.x, p.y]).collect();
let _ = app.run("develop.curve", json!({"channel": ch, "points": arr}));
}
if resp.drag_stopped() {
dragging = None;
let _ = app.run("develop.endInteraction", json!({}));
}
ui.data_mut(|dd| dd.insert_temp(drag_id, dragging));
let _ = id;
}
// ------------------------------------------------------------------------------ colour mixer
fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) {
let t = Tokens::get(ui.ctx());
let bands = lightcraft_develop::MIXER_BANDS;
let sel = bands.iter().position(|b| *b == app.ui.mixer_mode).unwrap_or(0);
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 4 }).show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 5.0;
for (i, b) in bands.iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
register(ui.ctx(), format!("mixerBand:{b}"), r);
ui.painter().circle_filled(r.center(), 9.0, hex(BAND_COLORS[i]));
if i == sel {
ui.painter().circle_stroke(r.center(), 10.5, Stroke::new(1.5, t.text));
ui.painter().circle_filled(r.center(), 2.0, Color32::WHITE);
}
if resp.clicked() {
app.ui.mixer_mode = b.to_string();
}
}
});
});
let b = bands[sel];
if crate::is_bw(d) {
control(app, ui, d, &format!("bw.{b}"), true);
} else {
for k in ["hue", "sat", "lum"] {
control(app, ui, d, &format!("mixer.{b}.{k}"), true);
}
}
}
// ------------------------------------------------------------------------------ colour grading
fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let (area, _) = ui.allocate_exact_size(vec2(w, 250.0), Sense::hover());
let big = 44.0;
let small = 38.0;
let wheels = [
("midtones", "Midtones", pos2(area.center().x, area.top() + 60.0), big),
("shadows", "Shadows", pos2(area.left() + w * 0.28, area.top() + 170.0), small),
("highlights", "Highlights", pos2(area.left() + w * 0.72, area.top() + 170.0), small),
];
for (key, label, c, rad) in wheels {
let wh = match key {
"shadows" => d.grading.shadows,
"midtones" => d.grading.midtones,
_ => d.grading.highlights,
};
let r = Rect::from_center_size(c, vec2(rad * 2.0, rad * 2.0));
let resp = ui.interact(r, egui::Id::new(("wheel", key)), Sense::click_and_drag());
register(ui.ctx(), format!("wheel:{key}"), r);
paint_wheel(ui.painter(), c, rad);
let a = (wh.hue as f32).to_radians();
let dot = c + vec2(a.cos(), -a.sin()) * rad * (wh.sat as f32 / 100.0);
ui.painter().circle_filled(dot, 5.0, Color32::WHITE);
ui.painter().circle_stroke(dot, 5.0, Stroke::new(1.0, Color32::BLACK));
ui.painter().text(pos2(c.x, c.y + rad + 12.0), Align2::CENTER_CENTER, label, t.font(12.0), t.text_label);
if resp.drag_started() {
let _ = app.run("develop.beginInteraction", json!({"label": format!("{label} Grading")}));
}
if (resp.dragged() || resp.clicked())
&& let Some(q) = resp.interact_pointer_pos()
{
let v = q - c;
let hue = ((-v.y).atan2(v.x).to_degrees() + 360.0) % 360.0;
let sat = (v.length() / rad * 100.0).min(100.0);
let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): hue.round(), format!("grading.{key}.sat"): sat.round()}}));
}
if resp.drag_stopped() || resp.clicked() {
let _ = app.run("develop.endInteraction", json!({}));
}
if resp.double_clicked() {
let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): 0, format!("grading.{key}.sat"): 0}}));
}
}
for c in ["grading.shadows.lum", "grading.midtones.lum", "grading.highlights.lum", "grading.blending", "grading.balance"] {
control(app, ui, d, c, true);
}
}
fn paint_wheel(p: &egui::Painter, c: Pos2, rad: f32) {
let mut mesh = Mesh::default();
let n = 48;
mesh.vertices.push(Vertex { pos: c, uv: Pos2::ZERO, color: Color32::from_gray(128) });
for i in 0..=n {
let a = i as f32 / n as f32 * std::f32::consts::TAU;
let rgb = lightcraft_color::perceptual::hsv_to_rgb(a.to_degrees(), 0.75, 0.8);
let col = Color32::from_rgb((rgb[0] * 255.0) as u8, (rgb[1] * 255.0) as u8, (rgb[2] * 255.0) as u8);
mesh.vertices.push(Vertex { pos: c + vec2(a.cos(), -a.sin()) * rad, uv: Pos2::ZERO, color: col });
if i > 0 {
mesh.indices.extend([0, i as u32, i as u32 + 1]);
}
}
p.add(mesh);
p.circle_stroke(c, rad, Stroke::new(1.0, Color32::from_gray(40)));
}
+270
View File
@@ -0,0 +1,270 @@
//! Photo Grid (justified rows) and Square Grid. Virtualized: only visible cells request thumbnails.
use std::collections::HashSet;
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use lightcraft_catalog::{Flag, PhotoId};
use serde_json::json;
use crate::LightcraftApp;
use crate::icons::{Icon, paint};
use crate::render::Slot;
use crate::state::ViewMode;
use crate::theme::Tokens;
use crate::widgets::register;
/// Thumbnail render size (pixels, long edge) for a cell of `pts` points.
fn thumb_px(pts: f32, ppp: f32) -> usize {
let px = (pts * ppp).ceil() as usize;
if px <= 256 {
256
} else if px <= 384 {
384
} else {
512
}
}
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let ids = app.session.visible_cloned();
// header: source title + count
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
let title = app.session.source.label(&app.session.catalog);
ui.painter().rect_filled(hr, 0.0, t.canvas);
ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, t.semibold(17.0), t.text);
let sel_n = app.session.selection.ids.len();
let cnt = if sel_n > 1 { format!("{sel_n} of {} photos", ids.len()) } else { format!("{} photos", ids.len()) };
ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim);
app.canvas_rect = Some(ui.max_rect());
if ids.is_empty() {
super::empty_message(ui, ui.max_rect(), "No photos", "Add photos with File → Add Photos (Cmd+Shift+I), or drop them here");
return;
}
let ppp = ui.ctx().pixels_per_point();
let square = app.ui.view == ViewMode::SquareGrid;
let target = app.ui.thumb_size;
let avail_w = ui.available_width() - 8.0;
// layout rows
struct Cell {
id: PhotoId,
rect: Rect,
}
let mut cells: Vec<Cell> = Vec::with_capacity(ids.len());
let gap = if square { 1.0 } else { 6.0 };
let mut y = 4.0f32;
if square {
let cols = ((avail_w + gap) / (target + gap)).floor().max(1.0);
let cw = (avail_w - gap * (cols - 1.0)) / cols;
for (i, id) in ids.iter().enumerate() {
let (c, r) = ((i as f32) % cols, (i as f32 / cols).floor());
cells.push(Cell { id: *id, rect: Rect::from_min_size(pos2(4.0 + c * (cw + gap), y + r * (cw + gap)), vec2(cw, cw)) });
}
} else {
// justified rows: accumulate aspect ratios until the row is full
let aspects: Vec<f32> = ids
.iter()
.map(|id| {
let p = app.session.catalog.photo(*id);
let (w, h) = p.map(|p| (p.width.max(1) as f32, p.height.max(1) as f32)).unwrap_or((3.0, 2.0));
let swap = p.is_some_and(|p| p.develop.orientation.swaps_axes());
let crop = p.map(|p| p.develop.crop.geometry.rect).unwrap_or(lightcraft_geom::Rect::UNIT);
let (w, h) = if swap { (h, w) } else { (w, h) };
(w * crop.width() as f32) / (h * crop.height() as f32).max(1e-3)
})
.collect();
let mut i = 0;
while i < ids.len() {
let mut sum = 0.0;
let mut j = i;
while j < ids.len() {
sum += aspects[j];
let h = (avail_w - gap * (j - i) as f32) / sum;
j += 1;
if h <= target {
break;
}
}
let full = j < ids.len() || (avail_w - gap * (j - i - 1) as f32) / sum <= target;
let h = if full { (avail_w - gap * (j - i - 1) as f32) / sum } else { target };
let mut x = 4.0;
for k in i..j {
let w = aspects[k] * h;
cells.push(Cell { id: ids[k], rect: Rect::from_min_size(pos2(x, y), vec2(w, h)) });
x += w + gap;
}
y += h + gap;
i = j;
}
}
let total_h = cells.last().map(|c| c.rect.bottom()).unwrap_or(0.0) + 12.0;
let active = app.session.selection.active;
// keep the active photo in view when it changes by keyboard
let scroll_to: Option<Rect> = {
let key = egui::Id::new("grid-last-active");
let last: Option<PhotoId> = ui.data(|d| d.get_temp(key));
ui.data_mut(|d| d.insert_temp(key, active));
if last != active { active.and_then(|a| cells.iter().find(|c| c.id == a).map(|c| c.rect)) } else { None }
};
let mut visible_ids = HashSet::new();
egui::ScrollArea::vertical().id_salt("grid-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), total_h), Sense::hover());
let origin = area.min;
if let Some(r) = scroll_to {
ui.scroll_to_rect(r.translate(origin.to_vec2()), None);
}
let prefetch = viewport.expand2(vec2(0.0, viewport.height()));
for c in &cells {
if !c.rect.intersects(prefetch) {
continue;
}
visible_ids.insert(c.id);
let r = c.rect.translate(origin.to_vec2());
let onscreen = c.rect.intersects(viewport);
cell(app, ui, c.id, r, square, onscreen, ppp);
}
});
app.renderer.evict_thumbs(&visible_ids, 600);
let _ = (Color32::BLACK, StrokeKind::Inside, Stroke::NONE);
}
fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square: bool, onscreen: bool, ppp: f32) {
let t = Tokens::get(ui.ctx());
let Some(photo) = app.session.catalog.photo(id).cloned() else { return };
let resp = ui.interact(r, egui::Id::new(("cell", id.0)), Sense::click_and_drag());
register(ui.ctx(), format!("thumb:{}", id.0), r);
let selected = app.session.selection.contains(id);
let active = app.session.selection.active == Some(id);
let p = ui.painter();
let img_rect = if square {
p.rect_filled(r, 0.0, if selected { t.cell_selected } else { t.cell });
Rect::from_min_max(r.min + vec2(10.0, 24.0), r.max - vec2(10.0, 10.0))
} else {
r
};
// thumbnail
let size = thumb_px(img_rect.width().max(img_rect.height()), ppp);
if let Some(job) = app.session.render_job(id, size, size, false, true) {
app.renderer.request(Slot::Thumb(id), job, if onscreen { 10 } else { 5 });
}
if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) {
let [tw, th] = tex.size;
let fit = if square {
let s = (img_rect.width() / tw as f32).min(img_rect.height() / th as f32);
Rect::from_center_size(img_rect.center(), vec2(tw as f32 * s, th as f32 * s))
} else {
img_rect
};
p.image(tex.tex.id(), fit, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
if active {
p.rect_stroke(fit.expand(if square { 2.0 } else { 0.0 }), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
} else if selected {
p.rect_stroke(fit, 0.0, Stroke::new(2.0, Color32::from_gray(170)), StrokeKind::Outside);
}
} else {
p.rect_filled(img_rect.shrink(if square { 20.0 } else { 0.0 }), 0.0, Color32::from_gray(38));
}
// labels and badges
if square && app.ui.show_filenames {
let name = photo.file_name.rsplit_once('.').map(|(n, _)| n.to_string()).unwrap_or(photo.file_name.clone());
p.text(pos2(r.left() + 8.0, r.top() + 12.0), Align2::LEFT_CENTER, name, t.font(10.5), t.text_dim);
let fmt = photo.format.clone();
let g = p.layout_no_wrap(fmt, t.semibold(9.0), t.text_label);
let br = Rect::from_min_size(pos2(r.right() - g.size().x - 14.0, r.top() + 5.0), g.size() + vec2(8.0, 3.0));
p.rect_filled(br, 2.0, Color32::from_gray(26));
p.galley(br.min + vec2(4.0, 1.5), g, t.text_label);
}
let show_badges = resp.hovered() || selected || photo.rating > 0 || photo.flag != Flag::None;
if show_badges {
let bar = Rect::from_min_max(pos2(img_rect.left(), img_rect.bottom() - 24.0), img_rect.right_bottom());
if resp.hovered() || selected {
p.rect_filled(bar, 0.0, Color32::from_black_alpha(120));
}
let mut x = bar.left() + 6.0;
for i in 0..photo.rating {
paint(p, Rect::from_min_size(pos2(x + i as f32 * 13.0, bar.center().y - 6.0), vec2(12.0, 12.0)), Icon::StarFilled, t.star);
}
x += photo.rating as f32 * 13.0 + 4.0;
match photo.flag {
Flag::Pick => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagPick, t.pick),
Flag::Reject => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagReject, t.reject),
Flag::None => {}
}
if photo.is_edited() {
paint(p, Rect::from_min_size(pos2(bar.right() - 20.0, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::Sliders, t.text_label);
}
}
if photo.flag == Flag::Reject {
p.rect_filled(img_rect, 0.0, Color32::from_black_alpha(110));
}
// interaction
if resp.clicked() {
let m = ui.input(|i| i.modifiers);
let mode = if m.shift {
"range"
} else if m.command {
"toggle"
} else {
"replace"
};
let _ = app.run("library.select", json!({"ids": [id.0], "mode": mode}));
}
if resp.double_clicked() {
let _ = app.run("library.select", json!({"ids": [id.0]}));
let _ = app.run("view.detail", json!({}));
}
resp.context_menu(|ui| context_menu(app, ui, id));
}
pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if !app.session.selection.contains(id) {
let _ = app.run("library.select", json!({"ids": [id.0]}));
}
if ui.button("Open in Detail").clicked() {
let _ = app.run("view.detail", json!({}));
}
ui.separator();
ui.menu_button("Set Rating", |ui| {
for r in 0..=5 {
if ui.button(if r == 0 { "No Stars".to_string() } else { "★".repeat(r) }).clicked() {
let _ = app.run("photo.rate", json!({"rating": r}));
}
}
});
ui.menu_button("Set Flag", |ui| {
for (l, f) in [("Pick", "pick"), ("Reject", "reject"), ("Unflagged", "none")] {
if ui.button(l).clicked() {
let _ = app.run("photo.flag", json!({"flag": f}));
}
}
});
ui.menu_button("Add to Album", |ui| {
let albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder).map(|a| (a.id.0, a.name.clone())).collect();
for (aid, name) in albums {
if ui.button(name).clicked() {
let _ = app.run("album.addPhotos", json!({"id": aid}));
}
}
});
ui.separator();
if ui.button("Copy Edit Settings").clicked() {
let _ = app.run("develop.copy", json!({}));
}
if ui.add_enabled(app.session.clipboard.is_some(), egui::Button::new("Paste Edit Settings")).clicked() {
let _ = app.run("develop.paste", json!({}));
}
if ui.button("Reset Edits").clicked() {
let _ = app.run("develop.reset", json!({}));
}
ui.separator();
if ui.button("Rotate Left").clicked() {
let _ = app.run("photo.rotateLeft", json!({}));
}
if ui.button("Rotate Right").clicked() {
let _ = app.run("photo.rotateRight", json!({}));
}
ui.separator();
if ui.button("Delete Photo").clicked() {
let _ = app.run("photo.delete", json!({}));
}
}
+130
View File
@@ -0,0 +1,130 @@
//! The left "My Photos" panel: library sources, albums tree, and date groups.
use egui::{Align2, Rect, Sense, pos2, vec2};
use lightcraft_catalog::{Album, AlbumId};
use lightcraft_engine::LibrarySource;
use serde_json::json;
use crate::LightcraftApp;
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 {
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);
let inner = r.shrink2(vec2(8.0, 0.0));
if selected {
ui.painter().rect_filled(inner, 4.0, t.canvas);
} 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 });
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);
}
let _ = app;
resp
}
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::left("left_panel")
.exact_size(t.left_w)
.resizable(false)
.frame(egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)))
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, "My Photos", t.semibold(15.0), t.text);
let stats: Vec<_> = app.session.catalog.photos().filter(|p| !p.deleted).map(|p| p.flag).collect();
let total = stats.len();
let picks = stats.iter().filter(|f| **f == lightcraft_catalog::Flag::Pick).count();
let deleted = app.session.catalog.photos().filter(|p| p.deleted).count();
egui::ScrollArea::vertical().id_salt("left-scroll").auto_shrink([false, false]).show(ui, |ui| {
let src = app.session.source;
for (id, icon, label, count, s) in [
("all", Icon::Photos, "All Photos", Some(total), LibrarySource::All),
("recentlyAdded", Icon::Clock, "Recently Added", None, LibrarySource::RecentlyAdded),
("picks", Icon::FlagPick, "Picks", Some(picks), LibrarySource::Picks),
] {
if row(app, ui, id, icon, label, count, src == s, 0.0).clicked() {
let _ = app.run("library.source", json!({"kind": id}));
}
}
ui.add_space(10.0);
// 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 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() {
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false });
}
if ui.button("Create Folder…").clicked() {
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true });
}
});
let albums: Vec<Album> = app.session.catalog.albums().cloned().collect();
albums_tree(app, ui, &albums, None, 0.0);
ui.add_space(10.0);
// By date
let (dr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, "By Date", t.semibold(13.5), t.text_label);
for g in app.session.catalog.date_groups() {
let sel = app.session.filter.date.as_deref() == Some(g.year.as_str());
if row(app, ui, &format!("year:{}", g.year), Icon::Clock, &g.year, Some(g.count), sel, 0.0).clicked() {
let v = if sel { serde_json::Value::Null } else { json!(g.year) };
let _ = app.run("library.filter", json!({"date": v}));
}
}
ui.add_space(10.0);
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"}));
}
});
});
}
fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent: Option<AlbumId>, indent: f32) {
let mut kids: Vec<&Album> = all.iter().filter(|a| a.parent == parent).collect();
kids.sort_by_key(|a| (!a.folder, a.name.to_lowercase()));
for a in kids {
if a.folder {
let open_id = egui::Id::new(("folder-open", a.id.0));
let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true);
let resp = row(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, false, indent);
if resp.clicked() {
ui.data_mut(|d| d.insert_temp(open_id, !open));
}
folder_menu(app, &resp, a);
if open {
albums_tree(app, ui, all, Some(a.id), indent + 16.0);
}
} else {
let sel = app.session.source == LibrarySource::Album(a.id);
let resp = row(app, ui, &format!("album:{}", a.id.0), Icon::Album, &a.name, Some(a.photos.len()), sel, indent);
if resp.clicked() {
let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0}));
}
folder_menu(app, &resp, a);
}
}
}
fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) {
resp.context_menu(|ui| {
if !a.folder && ui.button("Add Selected Photos").clicked() {
let _ = app.run("album.addPhotos", json!({"id": a.id.0}));
}
if ui.button("Rename…").clicked() {
app.ui.dialog = Some(crate::state::Dialog::RenameAlbum { id: a.id.0, name: a.name.clone() });
}
if ui.button("Delete").clicked() {
let _ = app.run("album.delete", json!({"id": a.id.0}));
}
});
}
+220
View File
@@ -0,0 +1,220 @@
//! The Masking panel: create masks, list them, edit components and local adjustments.
use egui::{Align2, Rect, Sense, Stroke, pos2, vec2};
use lightcraft_catalog::PhotoId;
use lightcraft_develop::{ControlSpec, LocalAdjustments, MaskShape, Section, Track};
use serde_json::json;
use super::edit::apply_slider_out;
use super::right::header;
use crate::LightcraftApp;
use crate::icons::{Icon, paint};
use crate::theme::Tokens;
use crate::widgets::{divider, icon_button, register, slider, text_button};
const fn spec(id: &'static str, label: &'static str, min: f64, max: f64, step: f64, decimals: u8, track: Track) -> ControlSpec {
ControlSpec { id, label, section: Section::Light, min, max, default: 0.0, step, decimals, track }
}
/// Local adjustment sliders (key = `LocalAdjustments` field name).
pub const LOCAL: &[ControlSpec] = &[
spec("temp", "Temp", -100.0, 100.0, 1.0, 0, Track::Temp),
spec("tint", "Tint", -100.0, 100.0, 1.0, 0, Track::Tint),
spec("exposure", "Exposure", -4.0, 4.0, 0.01, 2, Track::Centered),
spec("contrast", "Contrast", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("highlights", "Highlights", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("shadows", "Shadows", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("whites", "Whites", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("blacks", "Blacks", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("texture", "Texture", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("clarity", "Clarity", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("dehaze", "Dehaze", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("hue", "Hue", -100.0, 100.0, 1.0, 0, Track::Rainbow),
spec("saturation", "Saturation", -100.0, 100.0, 1.0, 0, Track::Gradient { from: "#7a7a7a", to: "#e04a3a" }),
spec("sharpness", "Sharpness", -100.0, 100.0, 1.0, 0, Track::Centered),
spec("noise", "Noise", -100.0, 100.0, 1.0, 0, Track::Centered),
];
pub fn local_get(a: &LocalAdjustments, key: &str) -> f64 {
serde_json::to_value(a).ok().and_then(|v| v.get(key).and_then(|x| x.as_f64())).unwrap_or(0.0)
}
fn kind_label(s: &MaskShape) -> (&'static str, Icon) {
match s {
MaskShape::Brush { .. } => ("Brush", Icon::Brush),
MaskShape::Linear { .. } => ("Linear Gradient", Icon::Linear),
MaskShape::Radial { .. } => ("Radial Gradient", Icon::Radial),
MaskShape::ColorRange { .. } => ("Color Range", Icon::Picker),
MaskShape::LuminanceRange { .. } => ("Luminance Range", Icon::Sliders),
MaskShape::DepthRange { .. } => ("Depth Range", Icon::Sliders),
MaskShape::Subject => ("Subject", Icon::Subject),
MaskShape::Sky => ("Sky", Icon::Sky),
MaskShape::Background => ("Background", Icon::Subject),
MaskShape::Object { .. } => ("Object", Icon::Subject),
MaskShape::People { .. } => ("People", Icon::Subject),
MaskShape::Landscape { .. } => ("Landscape", Icon::Sky),
}
}
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let t = Tokens::get(ui.ctx());
let d = app.session.develop_of(id).unwrap_or_default();
header(ui, "Masking");
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| {
ui.label(egui::RichText::new("Create New Mask").color(t.text_dim));
ui.add_space(6.0);
let tiles: [(&str, &str, Icon); 8] = [
("subject", "Subject", Icon::Subject),
("sky", "Sky", Icon::Sky),
("background", "Background", Icon::Subject),
("brush", "Brush", Icon::Brush),
("linear", "Linear", Icon::Linear),
("radial", "Radial", Icon::Radial),
("luminanceRange", "Luminance", Icon::Sliders),
("colorRange", "Color", Icon::Picker),
];
egui::Grid::new("mask-tiles").spacing(vec2(6.0, 6.0)).show(ui, |ui| {
for (i, (kind, label, icon)) in tiles.iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(52.0, 52.0), Sense::click());
register(ui.ctx(), format!("maskNew:{kind}"), r);
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.inset });
paint(ui.painter(), Rect::from_center_size(r.center() - vec2(0.0, 7.0), vec2(20.0, 20.0)), *icon, t.text_label);
ui.painter().text(pos2(r.center().x, r.bottom() - 9.0), Align2::CENTER_CENTER, *label, t.font(10.5), t.text_dim);
if resp.clicked() {
match *kind {
"brush" | "linear" | "radial" => {
app.ui.tool = kind.to_string();
if *kind != "brush" {
let _ = app.run("mask.add", json!({"kind": kind}));
}
}
k => {
let _ = app.run("mask.add", json!({"kind": k}));
}
}
}
if i % 4 == 3 {
ui.end_row();
}
}
});
});
divider(ui);
// mask list
egui::Frame::NONE.inner_margin(egui::Margin { left: 16, right: 16, top: 8, bottom: 8 }).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Masks").font(t.semibold(13.0)).color(t.text_label));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let on = app.ui.mask_overlay;
if icon_button(ui, "maskOverlay", Icon::Eye, vec2(24.0, 24.0), on, true, "Show overlay (O)").clicked() {
app.ui.mask_overlay = !on;
}
});
});
if d.masks.is_empty() {
ui.label(egui::RichText::new("No masks yet. Choose a mask type above.").color(t.text_dim));
}
for m in &d.masks {
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 });
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 });
if resp.clicked() {
let _ = app.run("mask.select", json!({"id": m.id}));
}
}
});
let Some(mid) = app.session.active_mask else { return };
let Some(m) = d.masks.iter().find(|m| m.id == mid).cloned() else { return };
divider(ui);
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 8 }).show(ui, |ui| {
for (i, c) in m.components.iter().enumerate() {
let (label, icon) = kind_label(&c.shape);
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
paint(ui.painter(), r, icon, t.text_label);
let op = match c.op {
lightcraft_develop::MaskOp::Add => "",
lightcraft_develop::MaskOp::Subtract => "− ",
lightcraft_develop::MaskOp::Intersect => "∩ ",
};
ui.label(format!("{op}{label}{}", if c.invert { " (inverted)" } else { "" }));
let _ = i;
});
}
ui.add_space(6.0);
ui.horizontal(|ui| {
let add = text_button(ui, "maskAddComp", "Add", false);
egui::Popup::menu(&add).show(|ui| component_menu(app, ui, "add"));
let sub = text_button(ui, "maskSubComp", "Subtract", false);
egui::Popup::menu(&sub).show(|ui| component_menu(app, ui, "subtract"));
if text_button(ui, "maskInvert", "Invert", m.invert).clicked() {
let _ = app.run("mask.invert", json!({}));
}
if icon_button(ui, "maskDelete", Icon::Trash, vec2(26.0, 24.0), false, true, "Delete mask").clicked() {
let _ = app.run("mask.delete", json!({}));
}
});
});
if app.ui.tool == "brush" {
divider(ui);
brush_settings(app, ui);
}
divider(ui);
for s in LOCAL {
let v = local_get(&m.adjust, s.id);
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 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);
let _ = Stroke::NONE;
}
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")] {
if ui.button(label).clicked() {
if kind == "brush" {
app.ui.tool = "brush".into();
app.ui.brush_erase = op == "subtract";
} else {
let _ = app.run("mask.addComponent", json!({"op": op, "kind": kind}));
}
}
}
}
fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 0 }).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Brush").font(t.semibold(13.0)));
if text_button(ui, "brushAdd", "Add", !app.ui.brush_erase).clicked() {
app.ui.brush_erase = false;
}
if text_button(ui, "brushErase", "Erase", app.ui.brush_erase).clicked() {
app.ui.brush_erase = true;
}
});
});
for (id, label, min, max, get) in [
("ui.brushSize", "Size", 1.0, 100.0, (app.ui.brush_size * 400.0) as f64),
("ui.brushFeather", "Feather", 0.0, 100.0, app.ui.brush_feather as f64),
("ui.brushFlow", "Flow", 1.0, 100.0, app.ui.brush_flow as f64),
] {
let s = ControlSpec { id, label, section: Section::Light, min, max, default: min, step: 1.0, decimals: 0, track: Track::Plain };
let out = slider(ui, &s, get, true, None);
if let Some(v) = out.value {
match id {
"ui.brushSize" => app.ui.brush_size = (v / 400.0) as f32,
"ui.brushFeather" => app.ui.brush_feather = v as f32,
_ => app.ui.brush_flow = v as f32,
}
}
}
}
+46
View File
@@ -0,0 +1,46 @@
//! Window regions and panels.
pub mod bottombar;
pub mod detail;
pub mod dialogs;
pub mod edit;
pub mod grid;
pub mod left;
pub mod masking;
pub mod presets;
pub mod right;
pub mod strip;
pub mod topbar;
use egui::{Align2, Rect, pos2, vec2};
use crate::LightcraftApp;
use crate::theme::Tokens;
/// The HUD toast at the bottom centre of the canvas.
pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
let Some((text, until)) = app.ui.toast.clone() else { return };
if now > until {
app.ui.toast = None;
return;
}
let Some(canvas) = app.canvas_rect else { return };
let t = Tokens::get(ctx);
let fade = ((until - now) / 0.25).clamp(0.0, 1.0) as f32;
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("toast")));
let galley = painter.layout_no_wrap(text, t.font(14.0), t.text.gamma_multiply(fade));
let size = vec2(galley.size().x + 40.0, 40.0);
let r = Rect::from_center_size(pos2(canvas.center().x, canvas.bottom() - 60.0), size);
painter.rect_filled(r, 6.0, egui::Color32::from_black_alpha((200.0 * fade) as u8));
painter.galley(r.center() - galley.size() / 2.0, galley, t.text);
ctx.request_repaint_after(std::time::Duration::from_millis(30));
}
/// Paint a centred, dimmed message (empty states).
pub fn empty_message(ui: &egui::Ui, rect: Rect, title: &str, body: &str) {
let t = Tokens::get(ui.ctx());
let p = ui.painter();
p.text(rect.center() - vec2(0.0, 12.0), Align2::CENTER_CENTER, title, t.semibold(18.0), t.text_label);
p.text(rect.center() + vec2(0.0, 14.0), Align2::CENTER_CENTER, body, t.font(13.0), t.text_dim);
}
+93
View File
@@ -0,0 +1,93 @@
//! The Presets column (opens to the left of the Edit panel): grouped presets with hover preview,
//! amount slider, create/favourite.
use std::collections::BTreeMap;
use egui::{Align2, Rect, Sense, pos2, vec2};
use lightcraft_develop::{ControlSpec, Section, Track};
use serde_json::json;
use crate::LightcraftApp;
use crate::icons::{Icon, paint};
use crate::theme::Tokens;
use crate::widgets::{divider, icon_button, register, slider};
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::right("presets_panel")
.exact_size(t.panel_w)
.resizable(false)
.frame(egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)))
.show(ui, |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() {
app.ui.dialog = Some(crate::state::Dialog::CreatePreset { name: String::new(), group: "User Presets".into() });
}
divider(ui);
// amount slider (applies to the last applied preset)
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 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
let _ = app.run("edit.undo", json!({}));
let _ = app.run("preset.apply", json!({"id": pid, "amount": v}));
ui.data_mut(|d| d.insert_temp(amt_id, (pid, v)));
}
divider(ui);
}
egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| {
let mut groups: BTreeMap<String, Vec<(String, String, bool)>> = BTreeMap::new();
for p in &app.session.presets {
groups.entry(p.group.clone()).or_default().push((p.id.clone(), p.name.clone(), p.favorite));
}
for (g, items) in groups {
let open_id = egui::Id::new(("preset-group", &g));
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);
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));
}
if !open {
continue;
}
for (pid, name, fav) in items {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click());
register(ui.ctx(), format!("preset:{pid}"), r);
let active = last.as_ref().is_some_and(|(l, _)| *l == pid);
if active {
ui.painter().rect_filled(r, 0.0, t.tool_active);
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.7));
}
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);
}
if resp.clicked() {
let _ = app.run("preset.apply", json!({"id": pid, "amount": 100}));
ui.data_mut(|d| d.insert_temp(amt_id, (pid.clone(), 100.0)));
app.toast(ui.ctx(), format!("Preset: {name}"));
}
resp.context_menu(|ui| {
if ui.button(if fav { "Remove from Favorites" } else { "Add to Favorites" }).clicked() {
let _ = app.run("preset.favorite", json!({"id": pid}));
}
if ui.button("Delete Preset").clicked() {
let _ = app.run("preset.delete", json!({"id": pid}));
}
});
}
}
ui.add_space(30.0);
});
});
}
+311
View File
@@ -0,0 +1,311 @@
//! The right-hand panel next to the tool strip: Edit, Crop, Remove, Masking, Red Eye, Info,
//! Keywords, Versions, Activity.
use egui::{Align2, Rect, Sense, pos2, vec2};
use lightcraft_catalog::PhotoId;
use serde_json::json;
use crate::LightcraftApp;
use crate::icons::{Icon, paint};
use crate::state::RightPanel;
use crate::theme::Tokens;
use crate::widgets::{divider, register, slider, text_button};
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::right("right_panel")
.exact_size(t.panel_w)
.resizable(false)
.frame(egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)))
.show(ui, |ui| {
let Some(id) = app.session.active() else {
let r = ui.max_rect();
super::empty_message(ui, r, "No photo selected", "Select a photo to edit");
return;
};
egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
match app.ui.right {
RightPanel::Edit => super::edit::show(app, ui, id),
RightPanel::Crop => crop(app, ui, id),
RightPanel::Remove => remove(app, ui, id),
RightPanel::Masking => super::masking::show(app, ui, id),
RightPanel::RedEye => red_eye(app, ui),
RightPanel::Info => info(app, ui, id),
RightPanel::Keywords => keywords(app, ui, id),
RightPanel::Versions => versions(app, ui, id),
RightPanel::Activity => activity(app, ui, id),
RightPanel::None => {}
}
});
});
}
pub fn header(ui: &mut egui::Ui, title: &str) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover());
ui.painter().text(pos2(r.left() + 24.0, r.center().y + 2.0), Align2::LEFT_CENTER, title, t.semibold(15.0), t.text);
}
pub fn label_row(ui: &mut egui::Ui, label: &str, value: &str) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover());
ui.painter().text(pos2(r.left() + 24.0, r.center().y), Align2::LEFT_CENTER, label, t.font(12.5), t.text_dim);
ui.painter().text(pos2(r.left() + 110.0, r.center().y), Align2::LEFT_CENTER, value, t.font(12.5), t.text_label);
}
fn padded(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 6 }).show(ui, add);
}
fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let d = app.session.develop_of(id).unwrap_or_default();
header(ui, "Crop");
padded(ui, |ui| {
ui.horizontal(|ui| {
ui.label("Aspect Ratio");
let cur = d.crop.aspect.map(|(w, h)| format!("{} × {}", w as f64 / 100.0, h as f64 / 100.0)).unwrap_or_else(|| "Free".into());
let r = ui.add(egui::Button::new(cur).frame(false));
register(ui.ctx(), "button:cropAspect", r.rect);
egui::Popup::menu(&r).show(|ui| {
for (label, a) in [
("Free", "free"),
("Original", "original"),
("1 × 1", "1x1"),
("4 × 5 / 8 × 10", "4x5"),
("8.5 × 11", "8.5x11"),
("5 × 7", "5x7"),
("2 × 3 / 4 × 6", "2x3"),
("4 × 3", "4x3"),
("16 × 9", "16x9"),
("16 × 10", "16x10"),
] {
if ui.button(label).clicked() {
let _ = app.run("crop.aspect", json!({"aspect": a}));
}
}
});
});
ui.add_space(6.0);
ui.horizontal(|ui| {
if text_button(ui, "cropRotateLeft", "⟲ 90°", false).clicked() {
let _ = app.run("photo.rotateLeft", json!({}));
}
if text_button(ui, "cropRotateRight", "⟳ 90°", false).clicked() {
let _ = app.run("photo.rotateRight", json!({}));
}
if text_button(ui, "cropFlipH", "Flip H", d.crop.flip_h).clicked() {
let _ = app.run("photo.flipHorizontal", json!({}));
}
if text_button(ui, "cropFlipV", "Flip V", d.crop.flip_v).clicked() {
let _ = app.run("photo.flipVertical", json!({}));
}
});
ui.add_space(6.0);
ui.horizontal(|ui| {
if text_button(ui, "cropSwapAspect", "Swap Aspect (X)", false).clicked() {
let _ = app.run("crop.rotateAspect", json!({}));
}
if text_button(ui, "cropReset", "Reset", false).clicked() {
let _ = app.run("crop.reset", json!({}));
}
});
});
let ang = lightcraft_develop::controls::find("crop.angle").copied();
if let Some(spec) = ang {
let out = slider(ui, &spec, d.crop.geometry.angle, true, Some("Straighten"));
super::edit::apply_slider_out(app, &spec, out, |app, v| app.run("crop.straighten", json!({"angle": v})));
}
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)] {
if ui.selectable_label(app.ui.crop_overlay == o, label).clicked() {
app.ui.crop_overlay = o;
}
}
});
});
divider(ui);
header(ui, "Geometry");
for spec in lightcraft_develop::controls::in_section(lightcraft_develop::Section::Geometry).filter(|c| c.id != "crop.angle") {
let v = lightcraft_develop::controls::get(&d, spec.id).unwrap_or(spec.default);
let out = slider(ui, spec, v, true, None);
super::edit::apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": spec.id, "value": v})));
}
}
fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let d = app.session.develop_of(id).unwrap_or_default();
header(ui, "Remove");
padded(ui, |ui| {
ui.horizontal(|ui| {
for (label, tool) in [("Content-Aware Remove", "remove"), ("Heal", "heal"), ("Clone", "clone")] {
if text_button(ui, &format!("removeMode-{tool}"), label, app.ui.tool == tool).clicked() {
app.ui.tool = tool.into();
}
}
});
ui.add_space(8.0);
ui.label(format!("{} spot(s) on this photo", d.spots.len()));
ui.add_space(4.0);
ui.label(egui::RichText::new("Paint over a distraction on the photo to remove it.").color(Tokens::get(ui.ctx()).text_dim));
});
let spec = lightcraft_develop::ControlSpec {
id: "ui.removeSize",
label: "Size",
section: lightcraft_develop::Section::Detail,
min: 1.0,
max: 100.0,
default: 20.0,
step: 1.0,
decimals: 0,
track: lightcraft_develop::Track::Plain,
};
let out = slider(ui, &spec, (app.ui.remove_size * 1000.0) as f64, true, None);
if let Some(v) = out.value {
app.ui.remove_size = (v / 1000.0) as f32;
}
padded(ui, |ui| {
if !d.spots.is_empty() && text_button(ui, "removeClear", "Delete all spots", false).clicked() {
for i in (0..d.spots.len()).rev() {
let _ = app.run("spot.delete", json!({"index": i}));
}
}
});
}
fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui) {
header(ui, "Red Eye");
padded(ui, |ui| {
ui.label("Click and drag over an eye to correct it.");
if text_button(ui, "redeyeAuto", "Auto Correct", false).clicked() {
app.ui.status = "Red eye correction arrives in M8".into();
}
});
}
fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let Some(p) = app.session.catalog.photo(id).cloned() else { return };
header(ui, "Info");
let t = Tokens::get(ui.ctx());
padded(ui, |ui| {
ui.label(egui::RichText::new(&p.file_name).font(t.semibold(14.0)).color(t.text));
ui.label(egui::RichText::new(format!("{} × {} · {}", p.width, p.height, p.format)).color(t.text_dim));
ui.add_space(8.0);
if let Some(r) = crate::widgets::stars(ui, "info", p.rating, 20.0) {
let _ = app.run("photo.rate", json!({"rating": r}));
}
});
divider(ui);
let mut title = p.meta.title.clone();
let mut caption = p.meta.caption.clone();
let mut copyright = p.meta.copyright.clone();
padded(ui, |ui| {
for (label, val, key) in [("Title", &mut title, "title"), ("Caption", &mut caption, "caption"), ("Copyright", &mut copyright, "copyright")] {
ui.label(egui::RichText::new(label).color(t.text_dim));
let r = ui.add(egui::TextEdit::singleline(val).desired_width(f32::INFINITY));
register(ui.ctx(), format!("field:{key}"), r.rect);
if r.lost_focus() {
let _ = app.run("photo.setMeta", json!({key: val.clone()}));
}
ui.add_space(6.0);
}
});
divider(ui);
header(ui, "Camera");
let m = &p.meta;
label_row(ui, "Captured", p.captured.as_deref().unwrap_or("—"));
label_row(ui, "Camera", &m.camera);
label_row(ui, "Lens", &m.lens);
label_row(ui, "Focal", &m.focal_mm.map(|f| format!("{f:.0} mm")).unwrap_or_default());
label_row(ui, "Aperture", &m.aperture.map(|f| format!("f/{f:.1}")).unwrap_or_default());
label_row(ui, "Shutter", &m.shutter);
label_row(ui, "ISO", &m.iso.map(|f| f.to_string()).unwrap_or_default());
label_row(ui, "Location", &m.location);
label_row(ui, "File size", &format!("{:.1} MB", p.file_size as f64 / 1e6));
}
fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let Some(p) = app.session.catalog.photo(id).cloned() else { return };
header(ui, "Keywords");
let t = Tokens::get(ui.ctx());
padded(ui, |ui| {
let kid = egui::Id::new("kw-input");
let mut text = ui.data_mut(|d| d.get_temp::<String>(kid).unwrap_or_default());
let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text("Add keyword").desired_width(f32::INFINITY));
register(ui.ctx(), "field:keyword", r.rect);
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !text.trim().is_empty() {
let kws: Vec<String> = text.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
let _ = app.run("photo.setMeta", json!({"addKeywords": kws}));
text.clear();
}
ui.data_mut(|d| d.insert_temp(kid, text));
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));
if r.clicked() {
let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]}));
}
}
});
ui.add_space(10.0);
ui.label(egui::RichText::new("Keywords in library").color(t.text_dim));
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() {
let _ = app.run("photo.setMeta", json!({"addKeywords": [k]}));
}
}
});
});
}
fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let Some(p) = app.session.catalog.photo(id).cloned() else { return };
header(ui, "Versions");
padded(ui, |ui| {
if text_button(ui, "versionCreate", "Create Version", false).clicked() {
let _ = app.run("version.create", json!({}));
}
ui.add_space(8.0);
for (i, v) in p.versions.iter().enumerate() {
ui.horizontal(|ui| {
if ui.button(&v.name).on_hover_text(&v.created).clicked() {
let _ = app.run("version.restore", json!({"index": i}));
}
if ui.small_button("✕").clicked() {
let _ = app.run("version.delete", json!({"index": i}));
}
});
}
if p.versions.is_empty() {
ui.label("No versions yet.");
}
});
}
fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let Some(p) = app.session.catalog.photo(id).cloned() else { return };
header(ui, "History");
let t = Tokens::get(ui.ctx());
padded(ui, |ui| {
if p.history.is_empty() {
ui.label(egui::RichText::new("No edits yet.").color(t.text_dim));
}
for (i, h) in p.history.iter().enumerate().rev() {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
register(ui.ctx(), format!("history:{i}"), r);
if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
paint(ui.painter(), Rect::from_min_size(r.min + vec2(0.0, 4.0), vec2(16.0, 16.0)), Icon::Clock, t.icon);
ui.painter().text(pos2(r.left() + 24.0, r.center().y), Align2::LEFT_CENTER, &h.label, t.font(12.5), t.text_label);
if resp.clicked() {
let _ = app.run("history.restore", json!({"index": i}));
}
}
});
}
+69
View File
@@ -0,0 +1,69 @@
//! The vertical tool strip at the far right (Presets, Edit, Crop, Remove, Masking, Red Eye …).
use egui::vec2;
use serde_json::json;
use crate::LightcraftApp;
use crate::icons::Icon;
use crate::state::RightPanel;
use crate::theme::Tokens;
use crate::widgets::icon_button;
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
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)))
.show(ui, |ui| {
let has_photo = app.session.active().is_some();
ui.vertical_centered(|ui| {
ui.spacing_mut().item_spacing.y = 6.0;
let sz = vec2(t.strip_w, 40.0);
if icon_button(ui, "presets", Icon::Presets, sz, app.ui.presets, has_photo, "Presets (Shift+P)").clicked() {
let _ = app.run("panel.presets", json!({}));
}
for (id, icon, panel, tip) in [
("edit", Icon::Sliders, RightPanel::Edit, "Edit (E)"),
("crop", Icon::Crop, RightPanel::Crop, "Crop & Rotate (C)"),
("remove", Icon::Eraser, RightPanel::Remove, "Remove (H)"),
("masking", Icon::Mask, RightPanel::Masking, "Masking (M)"),
("redeye", Icon::Eye, RightPanel::RedEye, "Red Eye"),
] {
if icon_button(ui, id, icon, sz, app.ui.right == panel, has_photo, tip).clicked() {
let _ = app.run(&format!("panel.{id}"), json!({}));
}
if id == "edit" {
separator(ui, &t);
}
}
separator(ui, &t);
if icon_button(ui, "versions", Icon::Versions, sz, app.ui.right == RightPanel::Versions, has_photo, "Versions (Shift+V)").clicked() {
let _ = app.run("panel.versions", json!({}));
}
if icon_button(ui, "activity", Icon::Activity, sz, app.ui.right == RightPanel::Activity, true, "History & Activity (Y)").clicked() {
let _ = app.run("panel.activity", json!({}));
}
if icon_button(ui, "more", Icon::More, sz, false, true, "More").clicked() {
app.ui.dialog = Some(crate::state::Dialog::About);
}
});
// 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())));
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() {
let _ = app.run("panel.keywords", json!({}));
}
if icon_button(ui, "info", Icon::Info, sz, app.ui.right == RightPanel::Info, has_photo, "Info (I)").clicked() {
let _ = app.run("panel.info", json!({}));
}
});
});
}
fn separator(ui: &mut egui::Ui, t: &Tokens) {
let (r, _) = ui.allocate_exact_size(vec2(t.strip_w - 16.0, 1.0), egui::Sense::hover());
ui.painter().rect_filled(r, 0.0, t.button_border.gamma_multiply(0.7));
}
+81
View File
@@ -0,0 +1,81 @@
//! The top bar: sidebar toggle, back/forward, search, filter, and the right-hand icons.
use egui::{Align2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::json;
use crate::LightcraftApp;
use crate::icons::{Icon, paint};
use crate::theme::Tokens;
use crate::widgets::{icon_button, register};
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let left = if app.integrated_titlebar { 78 } else { 10 };
egui::Panel::top("top_bar")
.exact_size(t.top_bar_h)
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 12, top: 0, bottom: 0 }))
.show(ui, |ui| {
let full = ui.max_rect();
ui.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, "Show/hide My Photos panel").clicked() {
let _ = app.run("view.leftPanel", json!({}));
}
ui.add_space(8.0);
if icon_button(ui, "back", Icon::Back, vec2(30.0, 30.0), false, true, "Back").clicked() {
let _ = app.run("view.back", json!({}));
}
icon_button(ui, "forward", Icon::Forward, vec2(30.0, 30.0), false, false, "Forward");
});
// search field (centred on the window)
let sw = 640.0f32.min(full.width() - 460.0).max(200.0);
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);
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 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);
let w = g.size().x + 24.0;
let x0 = sr.center().x - w / 2.0;
paint(child.painter(), Rect::from_min_size(pos2(x0, sr.center().y - 8.0), vec2(16.0, 16.0)), Icon::Search, t.text_dim);
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),
);
if resp.changed() {
let q = app.ui.search.clone();
let _ = app.run("library.filter", json!({"text": q}));
}
// filter icon right of the search field
let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0));
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 });
if fresp.clicked() {
let _ = app.run("view.filterBar", json!({}));
}
// right icons
let mut x = full.right() - 18.0;
for (id, icon, tip, cmd) in [
("cloud", Icon::Cloud, "Local library — no cloud account needed", ""),
("help", Icon::Help, "Keyboard shortcuts", "app.shortcuts"),
("share", Icon::Share, "Export", "dialog.export"),
("bell", Icon::Bell, "Activity", "panel.activity"),
] {
let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0));
let resp = ui.interact(r, egui::Id::new(("top", id)), Sense::click()).on_hover_text(tip);
register(ui.ctx(), format!("icon:{id}"), r);
paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon });
if resp.clicked() && !cmd.is_empty() {
let _ = app.run(cmd, json!({}));
}
x -= 40.0;
}
let _ = Align2::CENTER_CENTER;
});
}
+195
View File
@@ -0,0 +1,195 @@
//! Background rendering: a small worker pool runs engine [`RenderJob`]s; results become textures.
//!
//! Each visible thing that needs pixels is a *slot* (a grid thumbnail, the loupe, the "before"
//! image…). Requests are deduplicated per slot (a newer request replaces a queued older one) and
//! prioritised (loupe first, then visible thumbnails). On wasm the jobs run inline, one per frame.
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Arc, Condvar, Mutex};
use lightcraft_catalog::PhotoId;
use lightcraft_engine::Session;
use lightcraft_engine::media::{RenderJob, RenderResult};
use lightcraft_raster::Histogram;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Slot {
Thumb(PhotoId),
Main,
Before,
Compare(u8),
}
pub struct Tex {
pub key: u64,
pub photo: PhotoId,
pub tex: egui::TextureHandle,
pub size: [usize; 2],
pub histogram: Option<Histogram>,
pub ms: f64,
}
type Queue = Arc<(Mutex<Vec<(Slot, u32, RenderJob)>>, Condvar)>;
pub struct Renderer {
queue: Queue,
results_tx: Sender<(Slot, RenderResult, f64)>,
results: Receiver<(Slot, RenderResult, f64)>,
pending: HashMap<Slot, u64>,
pub textures: HashMap<Slot, Tex>,
started: bool,
pub last_main_ms: f64,
/// Jobs finished since start (for inspect/perf).
pub completed: u64,
}
impl Default for Renderer {
fn default() -> Self {
let (tx, rx) = channel();
Renderer {
queue: Arc::new((Mutex::new(Vec::new()), Condvar::new())),
results_tx: tx,
results: rx,
pending: HashMap::new(),
textures: HashMap::new(),
started: false,
last_main_ms: 0.0,
completed: 0,
}
}
}
fn now() -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
}
#[cfg(target_arch = "wasm32")]
{
0.0
}
}
impl Renderer {
fn start(&mut self) {
if self.started {
return;
}
self.started = true;
#[cfg(not(target_arch = "wasm32"))]
{
let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 6);
for i in 0..n {
let q = self.queue.clone();
let tx = self.results_tx.clone();
let _ = std::thread::Builder::new().name(format!("lc-render-{i}")).spawn(move || {
loop {
let job = {
let (lock, cv) = &*q;
let mut g = lock.lock().unwrap_or_else(|e| e.into_inner());
while g.is_empty() {
g = cv.wait(g).unwrap_or_else(|e| e.into_inner());
}
// highest priority, newest first
let best = g.iter().enumerate().max_by_key(|(i, (_, p, _))| (*p, *i)).map(|(i, _)| i).unwrap_or(0);
g.remove(best)
};
let t0 = now();
let (slot, _, job) = job;
let r = job.run();
if tx.send((slot, r, now() - t0)).is_err() {
break;
}
}
});
}
}
}
/// Is the slot's current texture (or pending request) already for `key`?
pub fn is_current(&self, slot: Slot, key: u64) -> bool {
self.textures.get(&slot).is_some_and(|t| t.key == key) || self.pending.get(&slot) == Some(&key)
}
/// Request a render for `slot` (no-op if already current or pending).
pub fn request(&mut self, slot: Slot, job: RenderJob, priority: u32) {
if self.is_current(slot, job.key) {
return;
}
self.start();
self.pending.insert(slot, job.key);
let (lock, cv) = &*self.queue;
let mut g = lock.lock().unwrap_or_else(|e| e.into_inner());
g.retain(|(s, _, _)| *s != slot);
g.push((slot, priority, job));
cv.notify_one();
}
pub fn queued(&self) -> usize {
self.queue.0.lock().map(|g| g.len()).unwrap_or(0)
}
/// Collect finished jobs into textures. Returns true if anything changed.
pub fn poll(&mut self, ctx: &egui::Context, session: &mut Session) -> bool {
#[cfg(target_arch = "wasm32")]
{
// inline: run one job per frame
let job = {
let mut g = self.queue.0.lock().unwrap_or_else(|e| e.into_inner());
let best = g.iter().enumerate().max_by_key(|(i, (_, p, _))| (*p, *i)).map(|(i, _)| i);
best.map(|b| g.remove(b))
};
if let Some((slot, _, job)) = job {
let r = job.run();
let _ = self.results_tx.send((slot, r, 0.0));
}
}
let mut changed = false;
while let Ok((slot, r, ms)) = self.results.try_recv() {
session.accept(&r);
self.completed += 1;
if self.pending.get(&slot) == Some(&r.key) {
self.pending.remove(&slot);
}
let Ok(rendered) = r.rendered else {
continue;
};
let img = &rendered.image;
let color = egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes());
let name = format!("{slot:?}");
match self.textures.get_mut(&slot) {
Some(t) => {
t.tex.set(color, egui::TextureOptions::LINEAR);
t.key = r.key;
t.photo = r.photo;
t.size = [img.width, img.height];
t.histogram = Some(rendered.histogram);
t.ms = ms;
}
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 });
}
}
if slot == Slot::Main {
self.last_main_ms = ms;
}
changed = true;
}
if !self.pending.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
changed
}
/// Drop thumbnails that aren't in `keep` (bounded memory for huge libraries).
pub fn evict_thumbs(&mut self, keep: &std::collections::HashSet<PhotoId>, max: usize) {
let thumbs = self.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count();
if thumbs <= max {
return;
}
self.textures.retain(|s, _| !matches!(s, Slot::Thumb(id) if !keep.contains(id)));
}
}
+199
View File
@@ -0,0 +1,199 @@
//! UI state (serde: saved as preferences, readable/settable through the control channel).
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ViewMode {
/// Justified rows ("Photo Grid").
#[default]
PhotoGrid,
SquareGrid,
Detail,
Compare,
}
/// The right-hand tool/panel shown next to the tool strip.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RightPanel {
#[default]
None,
Edit,
Crop,
Remove,
Masking,
RedEye,
Versions,
Activity,
Keywords,
Info,
}
impl RightPanel {
pub fn is_edit_tool(self) -> bool {
matches!(self, RightPanel::Edit | RightPanel::Crop | RightPanel::Remove | RightPanel::Masking | RightPanel::RedEye)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Zoom {
#[default]
Fit,
Fill,
/// 100 % = one image pixel per physical screen pixel.
Percent(u32),
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BeforeAfter {
#[default]
Off,
/// Hold `\`: show the original.
Original,
SideBySide,
Split,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CropOverlay {
#[default]
Thirds,
Grid,
Golden,
Diagonal,
None,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct UiState {
pub view: ViewMode,
pub left_panel: bool,
pub right: RightPanel,
/// Presets column open (opens to the left of the Edit panel).
pub presets: bool,
pub filmstrip: bool,
pub zoom: Zoom,
/// Pan offset of the loupe when zoomed (image-normalized centre).
pub pan: (f32, f32),
pub before_after: BeforeAfter,
pub thumb_size: f32,
/// Open Edit sections by id.
pub open_sections: Vec<String>,
/// Open flyouts (curve, mixer, grading…).
pub open_flyouts: Vec<String>,
/// Single-panel mode: opening one section closes the others.
pub single_panel: bool,
pub show_clipping: bool,
pub histogram: bool,
pub mask_overlay: bool,
pub crop_overlay: CropOverlay,
pub show_filenames: bool,
pub search: String,
/// Selected curve channel in the Curve flyout.
pub curve_channel: String,
/// Selected mixer mode: "hue" | "saturation" | "luminance" | "all".
pub mixer_mode: String,
/// Selected colour grading wheel: "3way" | "shadows" | "midtones" | "highlights" | "global".
pub grading_mode: String,
/// Active on-canvas tool: "", "brush", "linear", "radial", "wbPicker", "straighten", "remove".
pub tool: String,
pub brush_size: f32,
pub brush_feather: f32,
pub brush_flow: f32,
pub brush_erase: bool,
pub remove_size: f32,
/// Transient toast text and its expiry (seconds of app time).
#[serde(skip)]
pub toast: Option<(String, f64)>,
#[serde(skip)]
pub status: String,
#[serde(skip)]
pub dialog: Option<Dialog>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Dialog {
NewAlbum { name: String, folder: bool },
RenameAlbum { id: u64, name: String },
CreatePreset { name: String, group: String },
CopySettings { groups: Vec<String> },
Export { format: String, quality: u32, long_edge: u32 },
About,
Shortcuts,
}
impl Default for UiState {
fn default() -> Self {
UiState {
view: ViewMode::Detail,
left_panel: false,
right: RightPanel::Edit,
presets: false,
filmstrip: true,
zoom: Zoom::Fit,
pan: (0.5, 0.5),
before_after: BeforeAfter::Off,
thumb_size: 220.0,
open_sections: vec!["light".into()],
open_flyouts: vec![],
single_panel: false,
show_clipping: false,
histogram: true,
mask_overlay: true,
crop_overlay: CropOverlay::Thirds,
show_filenames: true,
search: String::new(),
curve_channel: "parametric".into(),
mixer_mode: "hue".into(),
grading_mode: "3way".into(),
tool: String::new(),
brush_size: 0.04,
brush_feather: 50.0,
brush_flow: 60.0,
brush_erase: false,
remove_size: 0.02,
toast: None,
status: String::new(),
dialog: None,
}
}
}
impl UiState {
pub fn section_open(&self, id: &str) -> bool {
self.open_sections.iter().any(|s| s == id)
}
pub fn toggle_section(&mut self, id: &str) {
if self.section_open(id) {
self.open_sections.retain(|s| s != id);
} else {
if self.single_panel {
self.open_sections.clear();
}
self.open_sections.push(id.to_string());
}
}
pub fn flyout_open(&self, id: &str) -> bool {
self.open_flyouts.iter().any(|s| s == id)
}
pub fn toggle_flyout(&mut self, id: &str) {
if self.flyout_open(id) {
self.open_flyouts.retain(|s| s != id);
} else {
self.open_flyouts.push(id.to_string());
}
}
/// Clamp values restored from disk.
pub fn sanitized(mut self) -> Self {
self.thumb_size = self.thumb_size.clamp(90.0, 480.0);
self.brush_size = self.brush_size.clamp(0.002, 0.5);
self.dialog = None;
self
}
}
+158
View File
@@ -0,0 +1,158 @@
//! Design tokens (colours, sizes, fonts). Values measured from black-box observation of the
//! reference app's dark theme (see plan/lightroom/10-observed-ui.md); everything is our own code.
use std::sync::Arc;
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals};
pub const FONT_SEMIBOLD: &str = "semibold";
#[derive(Clone, Copy, Debug)]
pub struct Tokens {
/// Top bar, side panels, bottom bar, tool strip.
pub chrome: Color32,
/// Photo canvas (detail view) and filmstrip.
pub canvas: Color32,
/// Grid background behind the cells.
pub grid_bg: Color32,
pub cell: Color32,
pub cell_selected: Color32,
pub divider: Color32,
pub inset: Color32,
pub field: Color32,
pub field_border: Color32,
pub button: Color32,
pub button_border: Color32,
pub hover: Color32,
pub pressed: Color32,
pub tool_active: Color32,
pub text: Color32,
pub text_label: Color32,
pub text_dim: Color32,
pub text_disabled: Color32,
/// Icon glyphs in the chrome.
pub icon: Color32,
pub track: Color32,
pub thumb: Color32,
pub thumb_hover: Color32,
pub accent: Color32,
pub star: Color32,
pub pick: Color32,
pub reject: Color32,
pub mask_overlay: Color32,
// metrics (points)
pub top_bar_h: f32,
pub bottom_bar_h: f32,
pub panel_w: f32,
pub strip_w: f32,
pub left_w: f32,
pub slider_row_h: f32,
pub section_h: f32,
pub film_h: f32,
}
impl Default for Tokens {
fn default() -> Self {
Tokens {
chrome: Color32::from_rgb(0x2d, 0x2d, 0x2d),
canvas: Color32::from_rgb(0x1c, 0x1c, 0x1c),
grid_bg: Color32::from_rgb(0x0f, 0x0f, 0x0f),
cell: Color32::from_rgb(0x1c, 0x1c, 0x1c),
cell_selected: Color32::from_rgb(0x2d, 0x2d, 0x2d),
divider: Color32::from_rgb(0x1c, 0x1c, 0x1c),
inset: Color32::from_rgb(0x23, 0x23, 0x23),
field: Color32::from_rgb(0x22, 0x22, 0x22),
field_border: Color32::from_rgb(0x3c, 0x3c, 0x3c),
button: Color32::from_rgb(0x24, 0x24, 0x24),
button_border: Color32::from_rgb(0x4c, 0x4c, 0x4c),
hover: Color32::from_rgb(0x3a, 0x3a, 0x3a),
pressed: Color32::from_rgb(0x3f, 0x3f, 0x3f),
tool_active: Color32::from_rgb(0x3f, 0x3f, 0x3f),
text: Color32::from_rgb(0xe2, 0xe2, 0xe2),
text_label: Color32::from_rgb(0xbc, 0xbc, 0xbc),
text_dim: Color32::from_rgb(0x8e, 0x8e, 0x8e),
text_disabled: Color32::from_rgb(0x5c, 0x5c, 0x5c),
icon: Color32::from_rgb(0x9a, 0x9a, 0x9a),
track: Color32::from_rgb(0x5a, 0x5a, 0x5a),
thumb: Color32::from_rgb(0xa0, 0xa0, 0xa0),
thumb_hover: Color32::from_rgb(0xe0, 0xe0, 0xe0),
accent: Color32::from_rgb(0x01, 0x65, 0xdd),
star: Color32::from_rgb(0xd8, 0xd8, 0xd8),
pick: Color32::from_rgb(0xf0, 0xf0, 0xf0),
reject: Color32::from_rgb(0xe0, 0x4a, 0x4a),
mask_overlay: Color32::from_rgba_unmultiplied(0xe0, 0x20, 0x30, 110),
top_bar_h: 42.0,
bottom_bar_h: 48.0,
panel_w: 270.0,
strip_w: 48.0,
left_w: 268.0,
slider_row_h: 45.0,
section_h: 57.0,
film_h: 142.0,
}
}
}
impl Tokens {
pub fn get(ctx: &egui::Context) -> Tokens {
ctx.data(|d| d.get_temp::<Tokens>(egui::Id::NULL)).unwrap_or_default()
}
pub fn font(&self, size: f32) -> FontId {
FontId::proportional(size)
}
pub fn semibold(&self, size: f32) -> FontId {
FontId::new(size, FontFamily::Name(FONT_SEMIBOLD.into()))
}
}
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"))));
let fallback: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
let mut prop = vec!["SourceSans3".to_string()];
prop.extend(fallback.clone());
fonts.families.insert(FontFamily::Proportional, prop);
let mut semi = vec!["SourceSans3-Semibold".to_string()];
semi.extend(fallback);
fonts.families.insert(FontFamily::Name(FONT_SEMIBOLD.into()), semi);
ctx.set_fonts(fonts);
}
pub fn apply(ctx: &egui::Context) {
let t = Tokens::default();
ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t));
let mut v = Visuals::dark();
v.panel_fill = t.chrome;
v.window_fill = t.chrome;
v.extreme_bg_color = t.field;
v.faint_bg_color = t.inset;
v.window_stroke = Stroke::new(1.0, t.button_border);
v.window_corner_radius = CornerRadius::same(6);
v.menu_corner_radius = CornerRadius::same(6);
v.selection.bg_fill = t.accent.gamma_multiply(0.6);
v.selection.stroke = Stroke::new(1.0, t.accent);
v.override_text_color = Some(t.text_label);
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)] {
wv.bg_fill = fill;
wv.weak_bg_fill = fill;
wv.corner_radius = CornerRadius::same(4);
wv.fg_stroke = Stroke::new(1.0, t.text_label);
}
w.noninteractive.bg_stroke = Stroke::new(1.0, t.divider);
w.inactive.bg_stroke = Stroke::new(1.0, t.button_border);
ctx.set_visuals(v);
ctx.global_style_mut(|s| {
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
s.spacing.button_padding = egui::vec2(8.0, 3.0);
s.spacing.interact_size.y = 22.0;
s.text_styles.insert(egui::TextStyle::Body, FontId::proportional(13.0));
s.text_styles.insert(egui::TextStyle::Button, FontId::proportional(13.0));
s.text_styles.insert(egui::TextStyle::Small, FontId::proportional(11.0));
s.text_styles.insert(egui::TextStyle::Heading, FontId::new(16.0, FontFamily::Name(FONT_SEMIBOLD.into())));
s.animation_time = 0.08;
});
}
+308
View File
@@ -0,0 +1,308 @@
//! Lightroom-style widgets: sliders with gradient tracks and hollow thumbs, section headers,
//! flyout rows, bordered buttons. Every interactive widget registers an automation id + rect
//! ([`register`]) so the control channel can find and click it by name.
use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use lightcraft_develop::{ControlSpec, Track};
use crate::icons::{Icon, paint};
use crate::theme::Tokens;
// ------------------------------------------------------------------ automation registry
#[derive(Clone, Default)]
pub struct Registry(pub Vec<(String, Rect)>);
/// Record a widget's automation id and screen rect for this frame.
pub fn register(ctx: &egui::Context, id: impl Into<String>, rect: Rect) {
let id = id.into();
ctx.data_mut(|d| d.get_temp_mut_or_default::<Registry>(egui::Id::new("lc-registry")).0.push((id, rect)));
}
pub fn take_registry(ctx: &egui::Context) -> Vec<(String, Rect)> {
ctx.data_mut(|d| std::mem::take(&mut d.get_temp_mut_or_default::<Registry>(egui::Id::new("lc-registry")).0))
}
// ------------------------------------------------------------------ colour helpers
pub fn hex(s: &str) -> Color32 {
let v = u32::from_str_radix(s.trim_start_matches('#'), 16).unwrap_or(0x808080);
Color32::from_rgb((v >> 16) as u8, (v >> 8) as u8, v as u8)
}
pub fn lerp(a: Color32, b: Color32, t: f32) -> Color32 {
let t = t.clamp(0.0, 1.0);
let f = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8;
Color32::from_rgb(f(a.r(), b.r()), f(a.g(), b.g()), f(a.b(), b.b()))
}
/// Band colours for the 8 colour-mixer bands (swatches and tracks).
pub const BAND_COLORS: [&str; 8] = ["#aa0000", "#bb6600", "#aaaa00", "#009900", "#00aaaa", "#0044cc", "#7722aa", "#aa0077"];
fn track_stops(track: &Track) -> Option<Vec<Color32>> {
Some(match track {
Track::Plain | Track::Centered => return None,
Track::Temp => ["#193961", "#47566a", "#60666d", "#70705b", "#737339", "#75742a"].iter().map(|h| hex(h)).collect(),
Track::Tint => ["#246123", "#2c552c", "#394739", "#4a3d4c", "#6b336b", "#872c87"].iter().map(|h| hex(h)).collect(),
Track::Gradient { from, to } => vec![hex(from), hex(to)],
Track::Hue { band } => {
let i = *band as usize;
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)]
}
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 } => {
let c = hex(BAND_COLORS[*band as usize]);
vec![c.gamma_multiply(0.2), c.gamma_multiply(0.55), lerp(c, Color32::WHITE, 0.35), lerp(c, Color32::WHITE, 0.6)]
}
Track::Rainbow => ["#aa0000", "#aaaa00", "#009900", "#00aaaa", "#0044cc", "#aa0077", "#aa0000"].iter().map(|h| hex(h)).collect(),
})
}
fn paint_track(ui: &Ui, rect: Rect, track: &Track, t: &Tokens, thumb_x: f32, ring: f32) {
let p = ui.painter();
let y = rect.center().y;
let h = 1.0;
if let Some(stops) = track_stops(track) {
// gradient: segments between stops
let n = stops.len().max(2) - 1;
let steps = 48;
for i in 0..steps {
let (a, b) = (i as f32 / steps as f32, (i + 1) as f32 / steps as f32);
let seg = (a * n as f32).floor() as usize;
let local = a * n as f32 - seg as f32;
let c = lerp(stops[seg.min(n)], stops[(seg + 1).min(n)], local);
let x0 = rect.left() + rect.width() * a;
let x1 = rect.left() + rect.width() * b;
p.rect_filled(Rect::from_min_max(pos2(x0, y - h - 0.5), pos2(x1 + 0.5, y + h + 0.5)), 0.0, c);
}
} else {
p.rect_filled(Rect::from_min_max(pos2(rect.left(), y - h), pos2(rect.right(), y + h)), 1.0, t.track);
}
// gap around the hollow thumb
p.rect_filled(Rect::from_center_size(pos2(thumb_x, y), vec2(ring * 2.0 + 4.0, 6.0)), 0.0, t.chrome);
}
/// Result of a slider interaction this frame.
#[derive(Default, Debug, Clone, Copy)]
pub struct SliderOut {
pub value: Option<f64>,
pub drag_started: bool,
pub drag_stopped: bool,
pub reset: bool,
}
/// A Lightroom slider row (label + value above a track with a hollow ring thumb).
/// Double-click the label or thumb to reset. Shift-drag = fine adjustment.
pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let (row, _) = ui.allocate_exact_size(vec2(w, t.slider_row_h), Sense::hover());
let pad_l = 24.0;
let pad_r = 22.0;
let label_rect = Rect::from_min_size(pos2(row.left() + pad_l, row.top() + 4.0), vec2(w - pad_l - pad_r, 18.0));
let track_rect = Rect::from_min_max(pos2(row.left() + pad_l, row.top() + 22.0), pos2(row.right() - pad_r, row.top() + 40.0));
let id = ui.id().with(spec.id);
let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() });
let label_resp = ui.interact(label_rect, id.with("label"), Sense::click());
register(ui.ctx(), format!("slider:{}", spec.id), track_rect);
let mut out = SliderOut::default();
let span = (spec.max - spec.min).max(1e-9);
let to_x = |v: f64| track_rect.left() + ((v - spec.min) / span).clamp(0.0, 1.0) as f32 * track_rect.width();
let from_x = |x: f32| spec.min + ((x - track_rect.left()) / track_rect.width()).clamp(0.0, 1.0) as f64 * span;
let mut v = value;
if resp.double_clicked() || label_resp.double_clicked() {
out.reset = true;
out.value = Some(spec.default);
v = spec.default;
} else {
if resp.drag_started() {
out.drag_started = true;
}
if (resp.dragged() || resp.drag_started())
&& let Some(p) = resp.interact_pointer_pos()
{
let fine = ui.input(|i| i.modifiers.shift);
let nv = if fine {
let dx = ui.input(|i| i.pointer.delta().x);
value + dx as f64 * span / track_rect.width() as f64 * 0.1
} else {
from_x(p.x)
};
let step = spec.step.max(1e-9);
let nv = ((nv / step).round() * step).clamp(spec.min, spec.max);
if (nv - value).abs() > 1e-12 {
out.value = Some(nv);
v = nv;
}
} else if resp.clicked()
&& let Some(p) = resp.interact_pointer_pos()
{
let step = spec.step.max(1e-9);
let nv = ((from_x(p.x) / step).round() * step).clamp(spec.min, spec.max);
out.value = Some(nv);
v = nv;
out.drag_started = true;
out.drag_stopped = true;
}
if resp.drag_stopped() {
out.drag_stopped = true;
}
}
// paint
let hovered = resp.hovered() || resp.dragged();
let text_c = if enabled { t.text_label } else { t.text_disabled };
let p = ui.painter();
p.text(label_rect.left_center(), Align2::LEFT_CENTER, label_override.unwrap_or(spec.label), t.font(12.5), text_c);
let shown = if spec.id == "wb.temp" { format!("{v:.0}") } else { spec.format(v).replace("+0.00", "0").replace("-0.00", "0") };
let shown = if shown == "+0" || shown == "-0" { "0".to_string() } else { shown };
p.text(label_rect.right_center(), Align2::RIGHT_CENTER, shown, t.font(12.5), text_c);
let ring = 7.0;
let tx = to_x(v);
paint_track(ui, track_rect, &spec.track, &t, tx, ring);
let p = ui.painter();
let ring_c = if !enabled {
t.text_disabled
} else if hovered {
t.thumb_hover
} else {
t.thumb
};
p.circle_filled(pos2(tx, track_rect.center().y), ring, t.chrome);
p.circle_stroke(pos2(tx, track_rect.center().y), ring, Stroke::new(2.0, ring_c));
if hovered {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
out
}
/// Collapsible section header ("› Light"). Returns the response (click toggles).
pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: Option<bool>) -> (Response, Option<bool>) {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let (r, resp) = ui.allocate_exact_size(vec2(w, 52.0), Sense::click());
register(ui.ctx(), format!("section:{id}"), r);
let p = ui.painter();
if resp.hovered() {
p.rect_filled(r, 0.0, t.chrome.gamma_multiply(1.06));
}
let chev = Rect::from_center_size(pos2(r.left() + 30.0, r.center().y), vec2(14.0, 14.0));
paint(p, chev, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label);
p.text(pos2(r.left() + 44.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(14.0), t.text);
let mut toggled = None;
if let Some(on) = enabled
&& (resp.hovered() || !on)
{
let eye = Rect::from_center_size(pos2(r.right() - 34.0, r.center().y), vec2(18.0, 18.0));
let er = ui.interact(eye, ui.id().with(("eye", id)), Sense::click());
register(ui.ctx(), format!("sectionEye:{id}"), eye);
paint(ui.painter(), eye, Icon::Eye, if on { t.icon } else { t.text_disabled });
if !on {
ui.painter().line_segment([eye.left_bottom(), eye.right_top()], Stroke::new(1.5, t.text_disabled));
}
if er.clicked() {
toggled = Some(!on);
}
}
(resp, toggled)
}
/// Thin divider between groups.
pub fn divider(ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let (r, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover());
ui.painter().rect_filled(r, 0.0, t.divider);
}
/// A flyout row inside a section (Curve, Color Mixer, Color Grading…): inset dark box with icon tile.
pub fn flyout_row(ui: &mut Ui, id: &str, title: &str, icon: Icon, open: bool) -> Response {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let (outer, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover());
let r = Rect::from_min_max(pos2(outer.left() + 16.0, outer.top() + 4.0), pos2(outer.right() - 16.0, outer.bottom() - 4.0));
let resp = ui.interact(r, ui.id().with(("flyout", id)), Sense::click());
register(ui.ctx(), format!("flyout:{id}"), r);
let p = ui.painter();
p.rect_filled(r, 4.0, if resp.hovered() { t.hover.gamma_multiply(0.8) } else { t.inset });
let tile = Rect::from_min_size(pos2(r.left() + 4.0, r.top() + 4.0), vec2(r.height() - 8.0, r.height() - 8.0));
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);
resp
}
/// A small bordered text button (Auto, B&W, HDR…).
pub fn text_button(ui: &mut Ui, id: &str, label: &str, active: bool) -> Response {
let t = Tokens::get(ui.ctx());
let font = t.semibold(11.5);
let galley = ui.painter().layout_no_wrap(label.to_string(), font, t.text);
let size = vec2((galley.size().x + 18.0).max(37.0), 24.0);
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
register(ui.ctx(), format!("button:{id}"), r);
let fill = if active {
t.pressed
} else if resp.hovered() {
t.hover
} else {
t.button
};
let p = ui.painter();
p.rect(r, CornerRadius::same(4), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside);
p.galley(r.center() - galley.size() / 2.0, galley, t.text);
resp
}
/// An icon-only button. `active` draws the selected background (tool strip).
pub fn icon_button(ui: &mut Ui, id: &str, icon: Icon, size: egui::Vec2, active: bool, enabled: bool, tooltip: &str) -> Response {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(size, if enabled { Sense::click() } else { Sense::hover() });
register(ui.ctx(), format!("icon:{id}"), r);
let p = ui.painter();
let sq = Rect::from_center_size(r.center(), vec2(32.0f32.min(size.x), 32.0f32.min(size.y)));
if active {
p.rect_filled(sq, 4.0, t.tool_active);
} else if resp.hovered() && enabled {
p.rect_filled(sq, 4.0, t.hover.gamma_multiply(0.7));
}
let c = if !enabled {
t.text_disabled
} else if active {
t.pick
} else if resp.hovered() {
t.text
} else {
t.icon
};
let glyph = (size.x.min(size.y) * 0.62).min(22.0);
paint(p, Rect::from_center_size(r.center(), vec2(glyph, glyph)), icon, c);
if !tooltip.is_empty() { resp.on_hover_text(tooltip) } else { resp }
}
/// Star rating row (5 stars); returns a new rating when clicked (clicking the current rating clears).
pub fn stars(ui: &mut Ui, id: &str, rating: u8, size: f32) -> Option<u8> {
let t = Tokens::get(ui.ctx());
let mut out = None;
let hover_n = {
let mut h = None;
for i in 0..5u8 {
let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click());
register(ui.ctx(), format!("star:{id}:{}", i + 1), r);
if resp.hovered() {
h = Some(i + 1);
}
if resp.clicked() {
out = Some(if rating == i + 1 { 0 } else { i + 1 });
}
ui.painter().text(r.center(), Align2::CENTER_CENTER, "", t.font(1.0), t.star);
let filled = i < rating;
paint(ui.painter(), r.shrink(size * 0.12), if filled { Icon::StarFilled } else { Icon::Star }, if filled { t.star } else { t.icon });
}
h
};
let _ = hover_n;
out
}