diff --git a/Cargo.lock b/Cargo.lock index e50487fbd..902d36636 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml new file mode 100644 index 000000000..f574fb28e --- /dev/null +++ b/crates/ui-egui/Cargo.toml @@ -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 } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs new file mode 100644 index 000000000..df98e1376 --- /dev/null +++ b/crates/ui-egui/src/icons.rs @@ -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 = 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 = 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 = 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)]); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs new file mode 100644 index 000000000..9be3efe45 --- /dev/null +++ b/crates/ui-egui/src/lib.rs @@ -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 Vec>; +pub type WriteFn = Box Result<(), String>>; +pub type PngEncode = Box Vec>; + +/// 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, + pub write: Option, + /// PNG encoder (the host links an image encoder; the UI crate stays codec-free). + pub png: Option, +} + +#[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>, + pending_screenshots: Vec<(u64, Option, Sender)>, + queued_screenshots: Vec<(u64, f64, u32)>, + screenshot_token: u64, + /// Synthetic input events (from the control channel) injected one step per frame. + pub synthetic: Vec, + 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, + pub image_rect: Option, + /// 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, +} + +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) -> 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 { + if let Some(r) = menus::run_ui_command(self, id, ¶ms) { + return r; + } + let r = self.session.execute(id, ¶ms).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) { + 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::()).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 = 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 + } +} diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs new file mode 100644 index 000000000..60bf389a4 --- /dev/null +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -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})); + } +} diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs new file mode 100644 index 000000000..a5a459bc6 --- /dev/null +++ b/crates/ui-egui/src/panels/detail.rs @@ -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 }, + Spot { points: Vec }, + 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 = 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 = 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 = 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 = (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 = (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 = 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})); + } + } + }); +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs new file mode 100644 index 000000000..d0fcacc4a --- /dev/null +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -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 { + 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), + } +} diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs new file mode 100644 index 000000000..f308e3ebf --- /dev/null +++ b/crates/ui-egui/src/panels/edit.rs @@ -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, +) { + 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 { + (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::() / (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 = (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 = (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 { + 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::>()); + let color = match ch.as_str() { + "red" => hex("#dd3333"), + "green" => hex("#33bb55"), + "blue" => hex("#3377ee"), + _ => Color32::from_gray(225), + }; + let line: Vec = (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 = 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))); +} diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs new file mode 100644 index 000000000..3c4bc15cc --- /dev/null +++ b/crates/ui-egui/src/panels/grid.rs @@ -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 = 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 = 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 = { + let key = egui::Id::new("grid-last-active"); + let last: Option = 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!({})); + } +} diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs new file mode 100644 index 000000000..761f54666 --- /dev/null +++ b/crates/ui-egui/src/panels/left.rs @@ -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, 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 = 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, 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})); + } + }); +} diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs new file mode 100644 index 000000000..03018efb9 --- /dev/null +++ b/crates/ui-egui/src/panels/masking.rs @@ -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, + } + } + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs new file mode 100644 index 000000000..66a6eb07a --- /dev/null +++ b/crates/ui-egui/src/panels/mod.rs @@ -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); +} diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs new file mode 100644 index 000000000..2c0039a73 --- /dev/null +++ b/crates/ui-egui/src/panels/presets.rs @@ -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> = 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); + }); + }); +} diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs new file mode 100644 index 000000000..e024cb468 --- /dev/null +++ b/crates/ui-egui/src/panels/right.rs @@ -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::(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 = 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})); + } + } + }); +} diff --git a/crates/ui-egui/src/panels/strip.rs b/crates/ui-egui/src/panels/strip.rs new file mode 100644 index 000000000..6d4626ae1 --- /dev/null +++ b/crates/ui-egui/src/panels/strip.rs @@ -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)); +} diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs new file mode 100644 index 000000000..c6317e89f --- /dev/null +++ b/crates/ui-egui/src/panels/topbar.rs @@ -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; + }); +} diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs new file mode 100644 index 000000000..0e19f4d9d --- /dev/null +++ b/crates/ui-egui/src/render.rs @@ -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, + pub ms: f64, +} + +type Queue = Arc<(Mutex>, Condvar)>; + +pub struct Renderer { + queue: Queue, + results_tx: Sender<(Slot, RenderResult, f64)>, + results: Receiver<(Slot, RenderResult, f64)>, + pending: HashMap, + pub textures: HashMap, + 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, 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))); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs new file mode 100644 index 000000000..c1b078083 --- /dev/null +++ b/crates/ui-egui/src/state.rs @@ -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, + /// Open flyouts (curve, mixer, grading…). + pub open_flyouts: Vec, + /// 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, +} + +#[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 }, + 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 + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs new file mode 100644 index 000000000..eab24f286 --- /dev/null +++ b/crates/ui-egui/src/theme.rs @@ -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::(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 = 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; + }); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs new file mode 100644 index 000000000..04ffae84d --- /dev/null +++ b/crates/ui-egui/src/widgets.rs @@ -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, rect: Rect) { + let id = id.into(); + ctx.data_mut(|d| d.get_temp_mut_or_default::(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::(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> { + 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, + 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) -> (Response, Option) { + 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 { + 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 +}