UI.9: integrate shared docking and preserve panel interactions

This commit is contained in:
skylar taylor-barrick committed 2026-10-10 16:22:21 -06:00
1 parent b70ae1741e
commit 9bfabc98b1
37 files changed
+2003 -324

No files matched your search

Generated
+103
View File
@@ -988,6 +988,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colored"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.59.0",
]
[[package]]
name = "combine"
version = "4.6.8"
@@ -1062,6 +1072,15 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
dependencies = [
"egui",
"serde",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -1202,6 +1221,19 @@ dependencies = [
"thousands",
]
[[package]]
name = "dify"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc"
dependencies = [
"anyhow",
"colored",
"getopts",
"image",
"rayon",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -1447,6 +1479,27 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_kittest"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
dependencies = [
"dify",
"eframe",
"egui",
"egui-wgpu",
"image",
"kittest",
"log",
"open",
"pollster 1.0.1",
"serde",
"tempfile",
"toml",
"wgpu",
]
[[package]]
name = "either"
version = "1.18.0"
@@ -2230,6 +2283,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -2588,6 +2650,25 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -2909,6 +2990,16 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kittest"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
dependencies = [
"accesskit",
"accesskit_consumer 0.35.0",
]
[[package]]
name = "kurbo"
version = "0.13.1"
@@ -3343,7 +3434,9 @@ dependencies = [
name = "lightcraft-ui-egui"
version = "0.5.0"
dependencies = [
"craft-ui",
"egui",
"egui_kittest",
"lightcraft-catalog",
"lightcraft-codecs",
"lightcraft-color",
@@ -4169,6 +4262,16 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
dependencies = [
"is-wsl",
"libc",
]
[[package]]
name = "orbclient"
version = "0.3.55"
+1
View File
@@ -10,6 +10,7 @@ rust-version = "1.90"
repository = "https://github.com/storytold/lightcraft"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# internal
lightcraft-denoise-core = { path = "crates/denoise-core" }
lightcraft-denoise = { path = "crates/denoise", default-features = false }
+4
View File
@@ -341,6 +341,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
## Quick start
Workspace builds, including the desktop, web and CLI applications, require Rust 1.95 or newer.
```sh
git clone https://github.com/storytold/lightcraft && cd lightcraft
cargo run --release -p lightcraft # opens your library (~/Pictures/LightCraft Library; a new one starts with demo photos)
@@ -567,3 +569,5 @@ Forks and modified versions must remove them.
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/lightcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
+4
View File
@@ -80,6 +80,8 @@ Image navigation now supports native pinch zoom and two-finger pan in Detail, Co
The whole-image preview still has a configurable size cap (2,560 px default), so 100% on larger photos does not
yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-VIEW-ZOOM).
Widget state styling now uses the shared `craft-ui` theme helper. LightCraft retains its dark palette, typography, spacing and per-state borders.
## Milestones
**Status legend:** ✅ done · 🚧 in progress · ⬜ not started
@@ -240,3 +242,5 @@ with the optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are
characters stay upright in top-to-bottom columns, with newlines starting columns to the
left. This is basic lettering, without tate-chu-yoko, ruby, kinsoku, or general vertical
OpenType shaping. The same coverage renderer serves 8/16/32-bit exports.
- **2026-10-10 (UI docking):** Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. No milestone percentage change.
+53 -2
View File
@@ -192,7 +192,12 @@ fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option<UiS
return (None, Some(msg), true);
}
};
match serde_json::from_slice::<UiState>(&bytes) {
match serde_json::from_slice::<UiState>(&bytes).map_err(|error| error.to_string()).and_then(|ui| {
if let Some(workspace) = &ui.docking {
workspace.validate()?;
}
Ok(ui)
}) {
Ok(ui) => (Some(ui.sanitized()), None, false),
Err(e) if !set_aside_damaged => {
let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display());
@@ -270,6 +275,9 @@ impl PrefsWriter {
return Ok(());
}
let Some(path) = self.path.clone() else { return Ok(()) };
if let Some(workspace) = &ui.docking {
workspace.validate()?;
}
let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?;
if bytes == self.written {
return Ok(());
@@ -608,6 +616,7 @@ OPTIONS:
--version, --help
ENVIRONMENT:
LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder)
LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS,
Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering.
LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: <settings folder>/models/sam3;
@@ -663,10 +672,16 @@ fn control_port_from(what: &str, value: Option<String>) -> Option<u16> {
port
}
/// An isolated profile keeps the emergency crash log with its other app data. With no
/// override (or an empty override), retain the historical system-temp location.
fn panic_log_path_with(env: impl Fn(&str) -> Option<std::ffi::OsString>, temp: std::path::PathBuf) -> std::path::PathBuf {
env("LIGHTCRAFT_CONFIG_DIR").filter(|value| !value.is_empty()).map(std::path::PathBuf::from).unwrap_or(temp).join("lightcraft-panics.log")
}
fn main() -> eframe::Result {
// First, so every start-up record is kept for the log file (`logging`).
let logger = logging::install();
lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log"));
lightcraft_engine::guard::install_hook(panic_log_path_with(|name| std::env::var_os(name), std::env::temp_dir()));
if let Some(logger) = logger {
logging::record_panics(logger);
}
@@ -904,6 +919,29 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn docking_survives_native_settings_file_reload() {
use lightcraft_ui_egui::docking::Panel;
let d = dir("docking-reload");
let path = d.join("ui.json");
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
app.run("ui.dock", serde_json::json!({"operation":"float", "panel":"tools", "rect":[70,80,310,500]})).unwrap();
app.run("ui.dock", serde_json::json!({"operation":"close", "panel":"tools"})).unwrap();
let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() };
writer.save_ui(&app.ui, false).unwrap();
let (restored, warning, keep) = load_prefs_at(&path, false);
assert!(warning.is_none());
assert!(!keep);
let mut restored_app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
restored_app.ui = restored.unwrap();
restored_app.run("ui.dock", serde_json::json!({"operation":"open", "panel":"tools"})).unwrap();
assert_eq!(
restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect,
[70.0, 80.0, 310.0, 500.0]
);
std::fs::remove_dir_all(d).unwrap();
}
/// Issue #164: the writer saves changed settings to its file — and nothing at all without one
/// (`--memory`) or while the session is the temporary one offered when the library can't open.
#[test]
@@ -1056,3 +1094,16 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
}
#[cfg(test)]
mod config_path_tests {
use super::*;
#[test]
fn config_override_keeps_panic_log_in_isolated_profile() {
let temp = std::path::PathBuf::from("temp");
assert_eq!(panic_log_path_with(|_| Some("isolated".into()), temp.clone()), std::path::PathBuf::from("isolated/lightcraft-panics.log"));
assert_eq!(panic_log_path_with(|_| None, temp.clone()), temp.join("lightcraft-panics.log"));
assert_eq!(panic_log_path_with(|_| Some("".into()), temp.clone()), temp.join("lightcraft-panics.log"));
}
}
+1 -1
View File
@@ -363,7 +363,7 @@ pub fn rule_date(s: &str) -> Result<String, Issue> {
_ => false,
}
};
if !shaped(&s) && !(s.get(..19).is_some_and(shaped) && s.get(19..).is_some_and(tail_ok)) {
if !(shaped(&s) || s.get(..19).is_some_and(shaped) && s.get(19..).is_some_and(tail_ok)) {
return Err(Issue::NeedsDate);
}
let part = |a: usize, b: usize| s.get(a..b).and_then(|t| t.parse::<u32>().ok());
+1 -1
View File
@@ -411,7 +411,7 @@ pub fn compute(
if n == 0 || sdr.len() < n || hdr.len() < n {
return Err(Error::Encode("gain map: SDR and HDR renditions must match the image size".into()));
}
if !matches!(o.scale, 1 | 2 | 4) || !(o.gamma > 0.0 && o.gamma.is_finite()) || !(o.offset > 0.0 && o.offset.is_finite()) {
if !(matches!(o.scale, 1 | 2 | 4) && o.gamma > 0.0 && o.gamma.is_finite() && o.offset > 0.0 && o.offset.is_finite()) {
return Err(Error::Encode("gain map: scale must be 1, 2 or 4, gamma and offset positive".into()));
}
let s = o.scale;
+82 -8
View File
@@ -6,21 +6,42 @@
//! face- or denoise-models folder until a host asks for one ([`Session::with_default_face_models`],
//! [`Session::with_default_denoise_models`]), so tests stay hermetic.
use std::ffi::OsString;
use std::path::PathBuf;
use crate::Session;
/// LightCraft's per-user config folder (`…/LightCraft`), if the platform tells us where.
#[derive(Clone, Copy)]
enum Platform {
Mac,
Windows,
Other,
}
/// LightCraft's settings folder. A nonempty `$LIGHTCRAFT_CONFIG_DIR` overrides the platform default.
/// The override is the complete directory; no application-name suffix is appended. An explicitly
/// empty value disables the settings directory instead of falling back to a personal profile.
pub fn config_dir() -> Option<PathBuf> {
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft"))
let platform = if cfg!(target_os = "macos") {
Platform::Mac
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("LightCraft"))
Platform::Windows
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.map(|c| c.join("lightcraft"))
Platform::Other
};
config_dir_with(platform, |name| std::env::var_os(name))
}
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
if let Some(dir) = env("LIGHTCRAFT_CONFIG_DIR") {
return (!dir.is_empty()).then(|| PathBuf::from(dir));
}
match platform {
Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft")),
Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("LightCraft")),
Platform::Other => {
env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config"))).map(|c| c.join("lightcraft"))
}
}
}
@@ -56,3 +77,56 @@ impl Session {
self.denoise.touch();
}
}
#[cfg(test)]
mod config_tests {
use super::*;
fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option<PathBuf> {
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))))
}
#[test]
fn config_override_wins_without_reading_user_profile() {
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
let actual = config_dir_with(platform, |name| {
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
Some(OsString::from("isolated/settings"))
});
assert_eq!(actual, Some(PathBuf::from("isolated/settings")));
}
}
#[test]
fn config_empty_override_disables_profile_without_reading_defaults() {
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
assert_eq!(
config_dir_with(platform, |name| {
assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR");
Some(OsString::new())
}),
None
);
}
}
#[test]
fn config_unset_override_keeps_platform_defaults() {
let vars = [("HOME", "/user"), ("APPDATA", "/roaming"), ("XDG_CONFIG_HOME", "/xdg")];
assert_eq!(resolve(Platform::Mac, &vars), Some(PathBuf::from("/user/Library/Application Support/LightCraft")));
assert_eq!(resolve(Platform::Windows, &vars), Some(PathBuf::from("/roaming/LightCraft")));
assert_eq!(resolve(Platform::Other, &vars), Some(PathBuf::from("/xdg/lightcraft")));
assert_eq!(resolve(Platform::Other, &[("HOME", "/user")]), Some(PathBuf::from("/user/.config/lightcraft")));
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
assert_eq!(resolve(platform, &[]), None);
}
}
#[cfg(unix)]
#[test]
fn config_override_preserves_non_unicode_paths() {
use std::os::unix::ffi::OsStringExt as _;
let value = OsString::from_vec(b"isolated/\xff".to_vec());
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value)));
}
}
+3
View File
@@ -2,6 +2,7 @@
name = "lightcraft-ui-egui"
version.workspace = true
edition.workspace = true
rust-version = "1.95"
license.workspace = true
description = "LightCraft egui frontend (Lightroom-style UI); swappable — nothing below depends on it (L5)"
@@ -9,6 +10,7 @@ description = "LightCraft egui frontend (Lightroom-style UI); swappable — noth
workspace = true
[dependencies]
craft-ui = { workspace = true }
lightcraft-geom = { workspace = true }
lightcraft-meta = { workspace = true }
lightcraft-color = { workspace = true }
@@ -25,6 +27,7 @@ serde_json = { workspace = true }
log = { workspace = true }
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
lightcraft-denoise = { workspace = true, features = ["runtime"] }
lightcraft-codecs = { workspace = true }
lightcraft-faces = { workspace = true }
+8
View File
@@ -94,6 +94,7 @@
"Aqua Hue": "Aquamarin – Farbton",
"Aqua Luminance": "Aquamarin – Luminanz",
"Aqua Saturation": "Aquamarin – Sättigung",
"Arrange Panel": "Bereich anordnen",
"ArtCraft Website": "ArtCraft-Website",
"ArtCraft — more creative apps": "ArtCraft — weitere Kreativ-Apps",
"As Shot": "Wie Aufnahme",
@@ -251,6 +252,7 @@
"Close Window": "Fenster schließen",
"Close all": "Alle schließen",
"Close file": "Datei schließen",
"Close panel": "Bereich schließen",
"Cloudy": "Bewölkt",
"Cmd+Shift+R, keeps the photo and clears its edits.)": "mit ⌘⇧R die Bearbeitungen zurück und behält das Foto.",
"Collapse": "Einklappen",
@@ -556,6 +558,7 @@
"Flip Overlay (⇧O)": "Überlagerung spiegeln (⇧O)",
"Flip V": "V spiegeln",
"Flip Vertical": "Vertikal spiegeln",
"Float panel": "Bereich frei platzieren",
"Flow": "Fluss",
"Fluorescent": "Leuchtstofflicht",
"Focal Length": "Brennweite",
@@ -885,6 +888,7 @@
"Move them": "Verschieben",
"Move to": "Verschieben nach",
"Move to Group": "In Gruppe verschieben",
"Move to group": "In Gruppe verschieben",
"Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Verschieben: Die Dateien werden in den folgenden Ordner verschoben; jedes Original (und seine XMP-Begleitdatei) wird erst nach Überprüfung der Kopie aus der Quelle entfernt. Duplikate und fehlgeschlagene Dateien bleiben an ihrem Speicherort.",
"Moves every photo imported from this disk to Recently Deleted; no file is touched": "Verschiebt alle von diesem Datenträger importierten Fotos nach „Zuletzt gelöscht“; keine Datei wird verändert",
"Moves the photos imported from this folder to Recently Deleted; no file is touched": "Verschiebt die aus diesem Ordner importierten Fotos nach „Zuletzt gelöscht“; keine Datei wird verändert",
@@ -1005,6 +1009,7 @@
"Paint over a distraction on the photo to remove it.": "Übermale ein störendes Element im Foto, um es zu entfernen.",
"Paint overlay": "Überlagerung auftragen",
"Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "RAW-Dateien von Panasonic und Leica, RW2, RWL und ältere RAW-Dateien von DMC-LX1 bis DC-S1R II, werden in",
"Panels": "Bereiche",
"Panorama": "Panorama",
"Panorama Merge": "Zu Panorama zusammenfügen",
"Panorama Merge Preview": "Panoramavorschau",
@@ -1225,6 +1230,7 @@
"Reset Control": "Regler zurücksetzen",
"Reset Crop": "Zuschnitt zurücksetzen",
"Reset Edits": "Bearbeitungen zurücksetzen",
"Reset Panel Layout": "Bereichsanordnung zurücksetzen",
"Reset Parametric Curve": "Parametrische Kurve zurücksetzen",
"Reset Section": "Bereich zurücksetzen",
"Reset Tone Curve": "Gradationskurve zurücksetzen",
@@ -1232,6 +1238,8 @@
"Reset all edits (Cmd+Shift+R)": "Alle Bearbeitungen zurücksetzen (⌘⇧R)",
"Reshuffle": "Neu mischen",
"Resize Face Box": "Gesichtsrahmengröße ändern",
"Resize panel window": "Größe des Bereichsfensters ändern",
"Resize panels": "Bereichsgrößen ändern",
"Resize to": "Größe ändern auf",
"Resolution (ppi)": "Auflösung (ppi)",
"Restore": "Wiederherstellen",
+8
View File
@@ -79,6 +79,7 @@
"Aqua Hue": "Tono aguamarina",
"Aqua Luminance": "Luminancia aguamarina",
"Aqua Saturation": "Saturación aguamarina",
"Arrange Panel": "Organizar panel",
"ArtCraft Website": "Sitio web de ArtCraft",
"ArtCraft — more creative apps": "ArtCraft: más aplicaciones creativas",
"As Shot": "Como se tomó",
@@ -231,6 +232,7 @@
"Close Window": "Cerrar ventana",
"Close all": "Cerrar todo",
"Close file": "Cerrar archivo",
"Close panel": "Cerrar panel",
"Cloudy": "Nublado",
"Collapse": "Contraer",
"Collapse All Stacks": "Contraer todas las pilas",
@@ -523,6 +525,7 @@
"Flip Overlay (⇧O)": "Voltear superposición (⇧O)",
"Flip V": "Voltear V",
"Flip Vertical": "Voltear verticalmente",
"Float panel": "Hacer flotante el panel",
"Flow": "Flujo",
"Fluorescent": "Fluorescente",
"Focal Length": "Distancia focal",
@@ -844,6 +847,7 @@
"Move them": "Moverlas",
"Move to": "Mover a",
"Move to Group": "Mover a un grupo",
"Move to group": "Mover al grupo",
"Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Mover: los archivos se mueven a la carpeta de abajo; cada original (y su archivo XMP complementario) se quita del origen solo después de verificar su copia. Los duplicados y los archivos que fallan quedan donde están.",
"Moves every photo imported from this disk to Recently Deleted; no file is touched": "Mueve todas las fotos importadas desde este disco a Eliminadas recientemente; no se toca ningún archivo",
"Moves the photos imported from this folder to Recently Deleted; no file is touched": "Mueve las fotos importadas desde esta carpeta a Eliminadas recientemente; no se toca ningún archivo",
@@ -961,6 +965,7 @@
"Paint only areas like the one under the brush": "Pintar solo zonas similares a la que está bajo el pincel",
"Paint over a distraction on the photo to remove it.": "Pintar sobre un elemento que distrae en la foto para eliminarlo.",
"Paint overlay": "Superposición de pintura",
"Panels": "Paneles",
"Panorama": "Panorámica",
"Panorama Merge": "Combinación de panorámica",
"Panorama Merge Preview": "Vista previa de combinación de panorámica",
@@ -1174,6 +1179,7 @@
"Reset Control": "Restablecer control",
"Reset Crop": "Restablecer recorte",
"Reset Edits": "Restablecer ediciones",
"Reset Panel Layout": "Restablecer disposición de paneles",
"Reset Parametric Curve": "Restablecer curva paramétrica",
"Reset Section": "Restablecer sección",
"Reset Tone Curve": "Restablecer curva de tonos",
@@ -1181,6 +1187,8 @@
"Reset all edits (Cmd+Shift+R)": "Restablecer todas las ediciones (Cmd+Mayús+R)",
"Reshuffle": "Volver a mezclar",
"Resize Face Box": "Cambiar tamaño del recuadro de cara",
"Resize panel window": "Cambiar tamaño de la ventana del panel",
"Resize panels": "Cambiar tamaño de paneles",
"Resize to": "Cambiar tamaño a",
"Resolution (ppi)": "Resolución (ppp)",
"Restore": "Restaurar",
+9 -1
View File
@@ -1876,5 +1876,13 @@
"Downloading model": "Téléchargement du modèle",
"Finding faces": "Recherche des visages",
"Synchronizing folder": "Synchronisation du dossier",
"Looking for changes": "Recherche des modifications"
"Looking for changes": "Recherche des modifications",
"Arrange Panel": "Arrange Panel",
"Close panel": "Close panel",
"Float panel": "Float panel",
"Move to group": "Move to group",
"Panels": "Panels",
"Reset Panel Layout": "Reset Panel Layout",
"Resize panel window": "Resize panel window",
"Resize panels": "Resize panels"
}
+8
View File
@@ -81,6 +81,7 @@
"Aqua Hue": "アクアの色相",
"Aqua Luminance": "アクアの輝度",
"Aqua Saturation": "アクアの彩度",
"Arrange Panel": "パネルを配置",
"ArtCraft Website": "ArtCraftのウェブサイト",
"ArtCraft — more creative apps": "ArtCraft — その他のクリエイティブアプリ",
"As Shot": "撮影時の設定",
@@ -228,6 +229,7 @@
"Close": "閉じる",
"Close Panel": "パネルを閉じる",
"Close Window": "ウィンドウを閉じる",
"Close panel": "パネルを閉じる",
"Cloudy": "曇り",
"Collapse All Stacks": "すべてのスタックを閉じる",
"Collapse Stack": "スタックを折りたたむ",
@@ -476,6 +478,7 @@
"Fixed keys": "固定キー",
"Flagged as Pick": "採用フラグ付き",
"Flagged as Reject": "不採用フラグ付き",
"Float panel": "パネルをフロート化",
"Focal Length": "焦点距離",
"Focus (assisted culling)": "ピント(選別補助)",
"Food": "料理",
@@ -593,6 +596,7 @@
"Move Preset to New Group": "プリセットを新しいグループに移動",
"Move Up": "上へ移動",
"Move in Stack": "スタック内で移動",
"Move to group": "グループに移動",
"Muted": "落ち着いた色",
"Muted Film": "ミューテッドフィルム",
"My Photos Panel": "「マイフォト」パネル",
@@ -621,6 +625,7 @@
"Original + Settings": "元画像+設定",
"Original extension without the dot": "元の拡張子(ドットなし)",
"Original file name without its extension": "拡張子を除いた元のファイル名",
"Panels": "パネル",
"Panorama": "パノラマ",
"Panorama Merge Preview": "パノラマ合成のプレビュー",
"Panorama with Last Settings": "前回の設定でパノラマ合成",
@@ -694,6 +699,7 @@
"Reorder Masks": "マスクを並べ替え",
"Reset All": "すべてリセット",
"Reset All Keyboard Shortcuts": "キーボードショートカットをすべてリセット",
"Reset Panel Layout": "パネル配置をリセット",
"Reshuffle": "シャッフルし直す",
"File name": "ファイル名",
"Fill Edges": "境界を補完",
@@ -1123,6 +1129,8 @@
"Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "すべてのポイントカーブとパラメトリックカーブをリセット(チャンネルのダブルクリックで個別にリセット)",
"Reset all edits (Cmd+Shift+R)": "すべての編集をリセット(⌘⇧R)",
"Resize Face Box": "顔の枠のサイズを変更",
"Resize panel window": "パネルウィンドウのサイズを変更",
"Resize panels": "パネルのサイズを変更",
"Resize to": "サイズ変更",
"Resolution (ppi)": "解像度(ppi)",
"Restore": "復元",
+8
View File
@@ -84,6 +84,7 @@
"Aqua Hue": "Matiz do ciano",
"Aqua Luminance": "Luminância do ciano",
"Aqua Saturation": "Saturação do ciano",
"Arrange Panel": "Organizar painel",
"ArtCraft Website": "Site do ArtCraft",
"ArtCraft — more creative apps": "ArtCraft — mais aplicativos criativos",
"As Shot": "Como capturado",
@@ -237,6 +238,7 @@
"Close Window": "Fechar janela",
"Close all": "Fechar tudo",
"Close file": "Fechar arquivo",
"Close panel": "Fechar painel",
"Cloudy": "Nublado",
"Collapse": "Recolher",
"Collapse All Stacks": "Recolher todas as pilhas",
@@ -529,6 +531,7 @@
"Flip Overlay (⇧O)": "Inverter sobreposição (⇧O)",
"Flip V": "Espelhar V",
"Flip Vertical": "Espelhar verticalmente",
"Float panel": "Tornar painel flutuante",
"Flow": "Fluxo",
"Fluorescent": "Fluorescente",
"Focal Length": "Distância focal",
@@ -852,6 +855,7 @@
"Move them": "Movê-los",
"Move to": "Mover para",
"Move to Group": "Mover para grupo",
"Move to group": "Mover para grupo",
"Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Mover: os arquivos são movidos para a pasta abaixo; cada original (e seu sidecar XMP) é removido da origem somente após a cópia ser verificada. Duplicatas e arquivos que falham permanecem onde estão.",
"Moves every photo imported from this disk to Recently Deleted; no file is touched": "Move todas as fotos importadas deste disco para Excluídos recentemente; nenhum arquivo é alterado",
"Moves the photos imported from this folder to Recently Deleted; no file is touched": "Move as fotos importadas desta pasta para Excluídos recentemente; nenhum arquivo é alterado",
@@ -971,6 +975,7 @@
"Paint only areas like the one under the brush": "Pinta apenas áreas como a que está sob o pincel",
"Paint over a distraction on the photo to remove it.": "Pinte sobre uma distração na foto para removê-la.",
"Paint overlay": "Sobreposição de pintura",
"Panels": "Painéis",
"Panorama": "Panorama",
"Panorama Merge": "Mesclagem de panorama",
"Panorama Merge Preview": "Prévia da mesclagem de panorama",
@@ -1185,6 +1190,7 @@
"Reset Control": "Redefinir controle",
"Reset Crop": "Redefinir recorte",
"Reset Edits": "Redefinir edições",
"Reset Panel Layout": "Redefinir disposição dos painéis",
"Reset Parametric Curve": "Redefinir curva paramétrica",
"Reset Section": "Redefinir seção",
"Reset Tone Curve": "Redefinir curva de tonalidade",
@@ -1192,6 +1198,8 @@
"Reset all edits (Cmd+Shift+R)": "Redefinir todas as edições (Cmd+Shift+R)",
"Reshuffle": "Embaralhar de novo",
"Resize Face Box": "Redimensionar caixa de rosto",
"Resize panel window": "Redimensionar janela do painel",
"Resize panels": "Redimensionar painéis",
"Resize to": "Redimensionar para",
"Resolution (ppi)": "Resolução (ppi)",
"Restore": "Restaurar",
+8
View File
@@ -60,6 +60,7 @@
"Aqua Hue": "Тон голубого",
"Aqua Luminance": "Яркость голубого",
"Aqua Saturation": "Насыщенность голубого",
"Arrange Panel": "Расположить панель",
"ArtCraft Website": "Сайт ArtCraft",
"ArtCraft — more creative apps": "ArtCraft — другие творческие приложения",
"As Shot": "Как снято",
@@ -194,6 +195,7 @@
"Close Window": "Закрыть окно",
"Close all": "Закрыть все",
"Close file": "Закрыть файл",
"Close panel": "Закрыть панель",
"Cloudy": "Облачно",
"Collapse All Stacks": "Свернуть все стопки",
"Color": "Цвет",
@@ -434,6 +436,7 @@
"Flip Overlay (⇧O)": "Отразить наложение (⇧O)",
"Flip V": "Отразить по верт.",
"Flip Vertical": "Отразить по вертикали",
"Float panel": "Сделать панель плавающей",
"Flow": "Поток",
"Fluorescent": "Флуоресцентная лампа",
"Focal length": "Фокусное расстояние",
@@ -703,6 +706,7 @@
"Move them": "Переместить их",
"Move to": "Переместить в",
"Move to Group": "Переместить в группу",
"Move to group": "Переместить в группу",
"Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Перемещение: файлы перемещаются в указанную ниже папку; каждый оригинал (и его XMP-файл) удаляется из источника только после проверки копии. Дубликаты и файлы с ошибками остаются на месте.",
"Muted": "Приглушённый",
"Muted Film": "Приглушённая плёнка",
@@ -804,6 +808,7 @@
"Paint only areas like the one under the brush": "Закрашивать только области, похожие на ту, что под кистью",
"Paint over a distraction on the photo to remove it.": "Закрасьте лишнее на фото, чтобы убрать это.",
"Paint overlay": "Наложение закраски",
"Panels": "Панели",
"Panorama Merge": "Слияние панорамы",
"Panorama with Last Settings": "Панорама с последними настройками",
"Panorama…": "Панорама…",
@@ -977,12 +982,15 @@
"Reset Control": "Сбросить элемент управления",
"Reset Crop": "Сбросить кадрирование",
"Reset Edits": "Сбросить правки",
"Reset Panel Layout": "Сбросить расположение панелей",
"Reset Parametric Curve": "Сбросить параметрическую кривую",
"Reset Section": "Сбросить раздел",
"Reset Tone Curve": "Сбросить тоновую кривую",
"Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Сбросить все кривые: точечные (все каналы) и параметрическую (двойной щелчок по каналу сбрасывает только его)",
"Reset all edits (Cmd+Shift+R)": "Сбросить все правки (Cmd+Shift+R)",
"Reshuffle": "Перемешать",
"Resize panel window": "Изменить размер окна панели",
"Resize panels": "Изменить размеры панелей",
"Resize to": "Изменить размер до",
"Resolution (ppi)": "Разрешение (ppi)",
"Restore": "Восстановить",
+9 -1
View File
@@ -2070,5 +2070,13 @@
"Downloading model": "Завантаження моделі",
"Finding faces": "Пошук облич",
"Synchronizing folder": "Синхронізація папки",
"Looking for changes": "Пошук змін"
"Looking for changes": "Пошук змін",
"Arrange Panel": "Arrange Panel",
"Close panel": "Close panel",
"Float panel": "Float panel",
"Move to group": "Move to group",
"Panels": "Panels",
"Reset Panel Layout": "Reset Panel Layout",
"Resize panel window": "Resize panel window",
"Resize panels": "Resize panels"
}
+8
View File
@@ -83,6 +83,7 @@
"Aqua Hue": "青色色相",
"Aqua Luminance": "青色明度",
"Aqua Saturation": "青色饱和度",
"Arrange Panel": "排列面板",
"ArtCraft Website": "ArtCraft 网站",
"ArtCraft — more creative apps": "ArtCraft — 更多创意应用",
"As Shot": "拍摄时设置",
@@ -236,6 +237,7 @@
"Close Window": "关闭窗口",
"Close all": "全部关闭",
"Close file": "关闭文件",
"Close panel": "关闭面板",
"Cloudy": "阴天",
"Collapse All Stacks": "折叠所有堆叠",
"Collapse Stack": "折叠堆叠",
@@ -496,6 +498,7 @@
"Fixed keys": "固定按键",
"Flagged as Pick": "已标记为留用",
"Flagged as Reject": "已标记为排除",
"Float panel": "浮动面板",
"Focal Length": "焦距",
"Focus (assisted culling)": "对焦(辅助筛选)",
"Frame time": "帧耗时",
@@ -563,6 +566,7 @@
"Move Preset to New Group": "将预设移动到新分组",
"Move Up": "上移",
"Move in Stack": "在堆叠中移动",
"Move to group": "移到组中",
"Name (A–Z)": "名称(A–Z)",
"Name of the folder the original is in": "原始文件所在文件夹的名称",
"Named People": "已命名的人物",
@@ -580,6 +584,7 @@
"Only": "仅限",
"Original extension without the dot": "不含点的原始扩展名",
"Original file name without its extension": "不含扩展名的原始文件名",
"Panels": "面板",
"Panorama": "全景",
"Panorama Merge Preview": "全景合并预览",
"Panoramas": "全景",
@@ -635,6 +640,7 @@
"Reorder Masks": "重新排序蒙版",
"Reset All": "全部重置",
"Reset All Keyboard Shortcuts": "重置所有键盘快捷键",
"Reset Panel Layout": "重置面板布局",
"Reshuffle": "重新随机排序",
"File name": "文件名",
"Fill Edges": "填充边缘",
@@ -1179,6 +1185,8 @@
"Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "重置所有曲线:点曲线(所有通道)和参数曲线(双击某个通道可单独重置该通道)",
"Reset all edits (Cmd+Shift+R)": "重置所有编辑 (Cmd+Shift+R)",
"Resize Face Box": "调整人脸框大小",
"Resize panel window": "调整面板窗口大小",
"Resize panels": "调整面板大小",
"Resize to": "调整大小为",
"Resolution (ppi)": "分辨率 (ppi)",
"Restore": "恢复",
+8
View File
@@ -83,6 +83,7 @@
"Aqua Hue": "青色色相",
"Aqua Luminance": "青色明度",
"Aqua Saturation": "青色飽和度",
"Arrange Panel": "排列面板",
"ArtCraft Website": "ArtCraft 網站",
"ArtCraft — more creative apps": "ArtCraft — 更多創作應用程式",
"As Shot": "拍攝時設定",
@@ -236,6 +237,7 @@
"Close Window": "關閉視窗",
"Close all": "全部關閉",
"Close file": "關閉檔案",
"Close panel": "關閉面板",
"Cloudy": "陰天",
"Collapse All Stacks": "收合所有堆疊",
"Collapse Stack": "收合堆疊",
@@ -496,6 +498,7 @@
"Fixed keys": "固定按鍵",
"Flagged as Pick": "已標記為精選",
"Flagged as Reject": "已標記為排除",
"Float panel": "浮動面板",
"Focal Length": "焦距",
"Focus (assisted culling)": "焦點(輔助挑選)",
"Frame time": "影格時間",
@@ -563,6 +566,7 @@
"Move Preset to New Group": "將預設集移至新群組",
"Move Up": "上移",
"Move in Stack": "在堆疊中移動",
"Move to group": "移至群組",
"Name (A–Z)": "名稱(A–Z)",
"Name of the folder the original is in": "原始檔所在資料夾的名稱",
"Named People": "已命名的人物",
@@ -580,6 +584,7 @@
"Only": "僅限",
"Original extension without the dot": "不含點的原始副檔名",
"Original file name without its extension": "不含副檔名的原始檔名",
"Panels": "面板",
"Panorama": "全景",
"Panorama Merge Preview": "全景合併預覽",
"Panoramas": "全景",
@@ -635,6 +640,7 @@
"Reorder Masks": "重新排序遮色片",
"Reset All": "全部重設",
"Reset All Keyboard Shortcuts": "重設所有鍵盤快速鍵",
"Reset Panel Layout": "重設面板配置",
"Reshuffle": "重新隨機排序",
"File name": "檔案名稱",
"Fill Edges": "填補邊緣",
@@ -1179,6 +1185,8 @@
"Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "重設所有點曲線通道與參數曲線(按兩下通道可只重設該通道)",
"Reset all edits (Cmd+Shift+R)": "重設所有編輯(Cmd+Shift+R)",
"Resize Face Box": "調整臉部框大小",
"Resize panel window": "調整面板視窗大小",
"Resize panels": "調整面板大小",
"Resize to": "調整尺寸為",
"Resolution (ppi)": "解析度(ppi)",
"Restore": "還原",
+4
View File
@@ -320,9 +320,13 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
lightcraft_develop::presets::deep_merge(&mut v, p);
match serde_json::from_value::<crate::UiState>(v) {
Ok(mut u) => {
if let Err(error) = crate::docking::set_widths(&mut u, p) {
return err(error);
}
u.toast = app.ui.toast.clone();
u.dialog = app.ui.dialog.clone();
u.status = app.ui.status.clone();
crate::docking::cancel_replaced_gesture(app, u.docking.as_ref());
app.ui = u;
crate::i18n::set_language(app.ui.language);
ctx.request_repaint();
+653
View File
@@ -0,0 +1,653 @@
//! Application panel identities and persistence around the shared docking renderer.
//! Document contents remain owned by their existing view implementations.
use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone};
use craft_ui::layout::{SplitAxis, SplitSize};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Panel {
Canvas,
Library,
Tools,
Presets,
}
impl Panel {
pub const ALL: &[Self] = &[Self::Canvas, Self::Library, Self::Tools, Self::Presets];
pub fn id(self) -> &'static str {
match self {
Self::Canvas => "canvas",
Self::Library => "library",
Self::Tools => "tools",
Self::Presets => "presets",
}
}
fn label(self) -> &'static str {
match self {
Self::Canvas => "Photos",
Self::Library => "Library",
Self::Tools => "Tools",
Self::Presets => "Presets",
}
}
fn default_side(self) -> Zone {
match self {
Self::Canvas => Zone::Right,
Self::Library => Zone::Left,
Self::Tools => Zone::Right,
Self::Presets => Zone::Right,
}
}
fn parse(id: &str) -> Result<Self, String> {
Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
}
}
fn permissions(panel: &Panel) -> Permissions {
if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() }
}
fn tabs(panel: Panel) -> Node<Panel> {
Node::Tabs { panels: vec![panel], active: 0 }
}
fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Panel>) -> Node<Panel> {
Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Workspace {
pub layout: Layout<Panel>,
/// Closed panels retain their most recent group, side, size or floating rectangle.
pub hidden: Vec<(Panel, Location<Panel>)>,
}
impl Default for Workspace {
fn default() -> Self {
Self {
layout: Layout {
root: Some(split(
SplitAxis::Horizontal,
SplitSize::FixedFirst(crate::state::LEFT_WIDTH.default),
tabs(Panel::Library),
split(
SplitAxis::Horizontal,
SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.default),
split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)),
tabs(Panel::Tools),
),
)),
floating: Vec::new(),
},
hidden: Vec::new(),
}
}
}
impl Workspace {
pub fn validate(&self) -> Result<(), String> {
self.layout.validate().map_err(|error| error.to_string())?;
if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() {
return Err("The panel layout must contain its document view and bounded hidden panels".into());
}
// Saved settings cannot turn the protected document view into a floating or closable tab.
if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) {
return Err("The document view cannot float".into());
}
let mut nodes: Vec<_> = self.layout.root.iter().collect();
while let Some(node) = nodes.pop() {
match node {
Node::Split { first, second, .. } => {
nodes.push(first);
nodes.push(second);
}
Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => {
return Err("The document view cannot share a tab group".into());
}
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => {
return Err("The document view cannot be collapsed".into());
}
_ => {}
}
}
let mut seen = std::collections::HashSet::new();
for (panel, location) in &self.hidden {
if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) {
return Err("A hidden panel has an invalid or duplicate identity".into());
}
if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) {
return Err("A hidden panel cannot restore over the document view".into());
}
let mut probe = self.layout.clone();
if let Err(error) = probe.restore(*panel, location)
&& error != craft_ui::docking::DockError::MissingTarget
{
return Err(error.to_string());
}
}
Ok(())
}
pub fn apply(&mut self, action: Action<Panel>) -> Result<(), String> {
self.validate()?;
let mut next = self.clone();
if let Action::Close { panel } = &action {
let location = next.layout.location(panel).map_err(|error| error.to_string())?;
next.hidden.retain(|(id, _)| id != panel);
next.hidden.push((*panel, location));
} else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action {
next.hidden.retain(|(id, _)| id != panel);
}
next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?;
next.validate()?;
*self = next;
Ok(())
}
pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> {
if self.layout.contains(&panel) == visible {
return Ok(());
}
if !visible {
return self.apply(Action::Close { panel });
}
self.validate()?;
let mut next = self.clone();
let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone());
next.hidden.retain(|(id, _)| *id != panel);
if let Some(saved) = &mut location
&& saved.floating.is_some()
&& saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor)))
{
// The other members of a floating group may have been closed too.
saved.anchor = None;
saved.placement = Placement::Tab { before: None };
}
let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok());
if !restored {
next.layout
.apply_with_permissions(
Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) },
permissions,
)
.map_err(|error| error.to_string())?;
}
next.validate()?;
*self = next;
Ok(())
}
/// Membership is passive state; an explicit command also exposes the panel's body.
pub fn reveal(&mut self, panel: Panel) -> Result<(), String> {
self.set_visible(panel, true)?;
self.apply(Action::Activate { panel })
}
pub fn exposed(&self, panel: Panel) -> bool {
if self.layout.floating.iter().any(|group| group.panels.get(group.active) == Some(&panel)) {
return true;
}
let mut nodes = self.layout.root.as_ref().map(|node| vec![node]).unwrap_or_default();
while let Some(node) = nodes.pop() {
match node {
Node::Tabs { panels, active } if panels.get(*active) == Some(&panel) => return true,
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == panel && entry.open) => return true,
Node::Split { first, second, .. } => {
nodes.push(first);
nodes.push(second);
}
_ => {}
}
}
false
}
fn command(&mut self, params: &Value) -> Result<(), String> {
self.validate()?;
if let Some(action) = params.get("action") {
let action: Action<Panel> = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?;
return self.apply(action);
}
let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
if operation == "resizeSplit" {
let path: Vec<bool> =
serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?;
let size: SplitSize =
serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?;
return self.apply(Action::ResizeSplit { path, size });
}
let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?;
let action = match operation {
"open" => return self.reveal(panel),
"close" => Action::Close { panel },
"activate" => Action::Activate { panel },
"setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? },
"resizeStack" => {
let value = params.get("height").ok_or("height is required")?;
let height = if value.is_null() {
None
} else {
Some(
value
.as_f64()
.filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height))
.ok_or("height must be finite and bounded")? as f32,
)
};
Action::ResizeStack { panel, height }
}
"float" | "moveFloating" => {
let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?;
let mut values = [0.0f32; 4];
for (output, value) in values.iter_mut().zip(rect) {
*output = value
.as_f64()
.filter(|number| number.is_finite() && number.abs() <= 1_000_000.0)
.ok_or("rect values must be finite and bounded")? as f32;
}
if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } }
}
"move" => {
let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?;
let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") {
"center" => Placement::Split(Zone::Center),
"left" => Placement::Split(Zone::Left),
"right" => Placement::Split(Zone::Right),
"top" => Placement::Split(Zone::Top),
"bottom" => Placement::Split(Zone::Bottom),
_ => return Err("zone must be center, left, right, top or bottom".into()),
};
let placement = if let Some(before) = params.get("before") {
if !matches!(placement, Placement::Split(Zone::Center)) {
return Err("before only applies to a center move".into());
}
Placement::Tab {
before: if before.is_null() {
None
} else {
Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?)
},
}
} else {
placement
};
Action::Move { panel, anchor, placement }
}
_ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()),
};
self.apply(action)
}
}
fn from_ui(app: &crate::LightcraftApp) -> Workspace {
let mut workspace = Workspace::default();
if let Some(Node::Split { size, second, .. }) = workspace.layout.root.as_mut() {
*size = SplitSize::FixedFirst(crate::state::LEFT_WIDTH.clamp(app.ui.left_width));
if let Node::Split { size, .. } = second.as_mut() {
*size = SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.clamp(app.ui.right_width));
}
}
workspace
}
fn sync(workspace: &mut Workspace, app: &crate::LightcraftApp) -> Result<(), String> {
workspace.set_visible(Panel::Library, app.ui.left_panel)?;
workspace.set_visible(Panel::Tools, app.ui.right != crate::state::RightPanel::None)?;
workspace.set_visible(Panel::Presets, app.ui.presets)
}
fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) {
app.ui.left_panel = workspace.layout.contains(&Panel::Library);
app.ui.presets = workspace.layout.contains(&Panel::Presets);
if !workspace.layout.contains(&Panel::Tools) {
app.ui.right = crate::state::RightPanel::None;
} else if app.ui.right == crate::state::RightPanel::None {
app.ui.right = crate::state::RightPanel::Edit;
}
for (panel, width, limits) in
[(Panel::Library, &mut app.ui.left_width, crate::state::LEFT_WIDTH), (Panel::Tools, &mut app.ui.right_width, crate::state::RIGHT_WIDTH)]
{
let Ok(location) = workspace.layout.location(&panel) else {
continue;
};
let value = match (location.floating, location.placement, location.size) {
(Some([_, _, width, _]), _, _)
| (None, Placement::Split(Zone::Left), Some(SplitSize::FixedFirst(width)))
| (None, Placement::Split(Zone::Right), Some(SplitSize::FixedSecond(width))) => Some(width),
// A vertical split stores height, and the opposite fixed side sizes a sibling.
_ => None,
};
if let Some(value) = value {
*width = limits.clamp(value);
}
}
}
pub(crate) fn exposed(app: &crate::LightcraftApp, panel: Panel) -> bool {
app.ui.docking.as_ref().is_none_or(|workspace| workspace.exposed(panel))
}
pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result<(), String> {
let panel = if id == "panel.presets" && app.ui.presets {
Some(Panel::Presets)
} else if (id.starts_with("panel.") && id != "panel.close" && id != "panel.presets"
|| id.starts_with("section.")
|| id.starts_with("tool.") && id != "tool.none")
&& app.ui.right != crate::state::RightPanel::None
{
Some(Panel::Tools)
} else {
None
};
if let Some(panel) = panel {
let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app));
sync(&mut workspace, app)?;
workspace.reveal(panel)?;
cancel_replaced_gesture(app, Some(&workspace));
app.ui.docking = Some(workspace);
}
Ok(())
}
/// Check the target's actual egui layer even while a different widget owns the drag.
pub(crate) fn drop_target(ui: &egui::Ui, rect: egui::Rect, pointer: Option<egui::Pos2>) -> bool {
pointer.is_some_and(|pos| rect.intersect(ui.clip_rect()).contains(pos) && ui.ctx().layer_id_at(pos) == Some(ui.layer_id()))
}
/// Explicit host commands replace the snapshot before rendering. Cancel only their old
/// gestures; actions returned by the renderer must keep continuous resizing alive.
pub(crate) fn cancel_replaced_gesture(app: &crate::LightcraftApp, workspace: Option<&Workspace>) {
let replaced = match (app.ui.docking.as_ref(), workspace) {
(Some(old), Some(next)) => old.layout != next.layout || old.hidden != next.hidden,
(None, None) => false,
_ => true,
};
if replaced && let Some(ctx) = &app.tasks.repaint {
craft_ui::docking::cancel_drag::<Panel>(ctx, egui::Id::new("lightcraft-docking"));
}
}
pub fn command(app: &mut crate::LightcraftApp, reset: bool, params: &Value) -> Result<Value, String> {
command_impl(app, reset, params, false)
}
fn command_impl(app: &mut crate::LightcraftApp, reset: bool, params: &Value, renderer_action: bool) -> Result<Value, String> {
let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app));
if reset {
workspace = Workspace::default();
// The usual LightCraft starting workspace shows its current tool beside the photo.
workspace.set_visible(Panel::Library, false)?;
workspace.set_visible(Panel::Presets, false)?;
} else {
sync(&mut workspace, app)?;
workspace.command(params)?;
}
if !renderer_action {
cancel_replaced_gesture(app, Some(&workspace));
}
publish_visibility(app, &workspace);
app.ui.docking = Some(workspace);
Ok(json!({"docking": app.ui.docking}))
}
pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::get(ui.ctx());
let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app));
if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) {
app.ui.toast = Some((format!("Panel layout could not be restored: {error}"), ui.input(|input| input.time) + 8.0, None));
workspace = from_ui(app);
let _ = sync(&mut workspace, app);
}
// Legacy visibility commands may replace the layout before this render starts.
cancel_replaced_gesture(app, Some(&workspace));
// Tool/Canvas callbacks dispatch app commands and must see the real workspace.
app.ui.docking = Some(workspace.clone());
let mut style = DockStyle::from_ui(ui);
style.min_pane = 96.0;
style.float_label = crate::i18n::tr("Float panel").into();
style.close_label = crate::i18n::tr("Close panel").into();
style.move_label = crate::i18n::tr("Move to group").into();
style.panels_label = crate::i18n::tr("Panels").into();
style.resize_label = crate::i18n::tr("Resize panels").into();
style.resize_window_label = crate::i18n::tr("Resize panel window").into();
style.background = t.chrome;
style.tab_background = t.chrome;
style.active_background = t.canvas;
style.text = t.text;
style.inactive_text = t.text_dim;
style.border = egui::Stroke::new(1.0, t.divider);
style.accent = t.accent;
style.font = t.font(12.0);
// Tool and preset panels publish hover previews before the photo consumes them.
let mut canvas = None;
let output = DockArea::new(egui::Id::new("lightcraft-docking")).show_with_limits(
ui,
&workspace.layout,
&style,
|panel| crate::i18n::tr(panel.label()).into(),
permissions,
panel_limits,
|ui, panel| match panel {
Panel::Canvas => {
canvas = Some(ui.new_child(egui::UiBuilder::new().id_salt("photo-view").max_rect(ui.max_rect())));
}
Panel::Library => {
crate::widgets::register(ui.ctx(), "panel:left_panel", ui.max_rect());
crate::panels::left::show(app, ui);
}
Panel::Tools => {
crate::widgets::register(ui.ctx(), "panel:right_panel", ui.max_rect());
crate::panels::right::show(app, ui);
}
Panel::Presets => crate::panels::presets::show(app, ui),
},
);
if let Some(mut canvas) = canvas {
let ui = &mut canvas;
crate::panels::bottombar::show(app, ui);
let bg = if matches!(
app.ui.view,
crate::state::ViewMode::Detail | crate::state::ViewMode::Compare | crate::state::ViewMode::Survey | crate::state::ViewMode::Reference
) {
t.canvas
} else {
t.grid_bg
};
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| match app.ui.view {
crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid => crate::panels::grid::show(app, ui),
crate::state::ViewMode::Detail => crate::panels::detail::show(app, ui),
crate::state::ViewMode::Compare => crate::panels::compare::show_compare(app, ui),
crate::state::ViewMode::Survey => crate::panels::compare::show_survey(app, ui),
crate::state::ViewMode::Reference => crate::panels::compare::show_reference(app, ui),
crate::state::ViewMode::People => crate::panels::people::show(app, ui),
});
}
let rendered = workspace;
let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app));
let _ = sync(&mut workspace, app);
let stale = workspace.layout != rendered.layout || workspace.hidden != rendered.hidden;
if stale {
craft_ui::docking::cancel_drag::<Panel>(ui.ctx(), egui::Id::new("lightcraft-docking"));
}
publish_visibility(app, &workspace);
app.ui.docking = Some(workspace);
if stale {
return;
}
for action in output.actions {
if let Err(error) = serde_json::to_value(action)
.map_err(|error| error.to_string())
.and_then(|action| command_impl(app, false, &json!({"action": action}), true))
{
app.ui.toast = Some((format!("Panel move was not applied: {error}"), ui.input(|input| input.time) + 8.0, None));
}
}
}
/// Keep the established ui.set width controls connected to the panel's current parent split.
pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(), String> {
state.left_width = crate::state::LEFT_WIDTH.clamp(state.left_width);
state.right_width = crate::state::RIGHT_WIDTH.clamp(state.right_width);
let Some(workspace) = state.docking.as_mut() else {
return Ok(());
};
workspace.validate()?;
for (key, panel, width) in [("leftWidth", Panel::Library, state.left_width), ("rightWidth", Panel::Tools, state.right_width)] {
if params.get(key).is_none() {
continue;
}
if let Some((_, location)) = workspace.hidden.iter_mut().find(|(id, _)| *id == panel) {
if let Some(rect) = &mut location.floating {
rect[2] = width;
}
match location.placement {
Placement::Split(Zone::Left) => location.size = Some(SplitSize::FixedFirst(width)),
Placement::Split(Zone::Right) => location.size = Some(SplitSize::FixedSecond(width)),
_ => {} // Width controls do not rewrite a saved vertical split's height.
}
continue;
}
if let Some(group) = workspace.layout.floating.iter().find(|group| group.panels.contains(&panel)) {
let mut rect = group.rect;
rect[2] = width;
workspace.apply(Action::MoveFloating { panel, rect })?;
continue;
}
let mut nodes = workspace.layout.root.as_ref().map(|node| vec![(node, Vec::new())]).unwrap_or_default();
let mut change = None;
while let Some((node, path)) = nodes.pop() {
if let Node::Split { axis, first, second, .. } = node {
let contains = |node: &Node<Panel>| match node {
Node::Tabs { panels, .. } => panels.contains(&panel),
Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel),
Node::Split { .. } => false,
};
if *axis == SplitAxis::Horizontal {
if contains(first) {
change = Some(Action::ResizeSplit { path: path.clone(), size: SplitSize::FixedFirst(width) });
}
if contains(second) {
change = Some(Action::ResizeSplit { path: path.clone(), size: SplitSize::FixedSecond(width) });
}
}
let mut second_path = path.clone();
second_path.push(true);
let mut first_path = path;
first_path.push(false);
nodes.push((second, second_path));
nodes.push((first, first_path));
}
}
if let Some(action) = change {
workspace.apply(action)?;
}
}
Ok(())
}
fn panel_limits(panel: &Panel) -> PanelLimits {
let (min, max_width) = match panel {
Panel::Canvas => (egui::vec2(crate::state::MIN_PHOTO_WIDTH, 160.0), 1_000_000.0),
Panel::Library => (egui::vec2(crate::state::LEFT_WIDTH.min, 120.0), crate::state::LEFT_WIDTH.max),
Panel::Tools => (egui::vec2(crate::state::RIGHT_WIDTH.min, 120.0), crate::state::RIGHT_WIDTH.max),
Panel::Presets => (egui::vec2(250.0, 120.0), 520.0),
};
PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) }
}
#[cfg(test)]
mod tests {
use super::*;
fn app_with_widths() -> crate::LightcraftApp {
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default());
app.ui.left_width = 333.0;
app.ui.right_width = 410.0;
app
}
fn tools_split(axis: SplitAxis, size: SplitSize, first: bool) -> Workspace {
let rest = split(
SplitAxis::Horizontal,
SplitSize::FixedFirst(333.0),
tabs(Panel::Library),
split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)),
);
let root = if first { split(axis, size, tabs(Panel::Tools), rest) } else { split(axis, size, rest, tabs(Panel::Tools)) };
Workspace { layout: Layout { root: Some(root), floating: Vec::new() }, hidden: Vec::new() }
}
#[test]
fn explicit_reveal_opens_collapsed_tools_and_survives_reload() {
let mut app = app_with_widths();
app.ui.right = crate::state::RightPanel::Crop;
let entries = [Panel::Tools, Panel::Library, Panel::Presets]
.into_iter()
.map(|panel| craft_ui::docking::StackEntry { panel, open: panel != Panel::Tools, height: Some(180.0) })
.collect();
app.ui.docking = Some(Workspace {
layout: Layout {
root: Some(split(SplitAxis::Horizontal, SplitSize::FixedSecond(350.0), tabs(Panel::Canvas), Node::Stack { entries })),
floating: Vec::new(),
},
hidden: Vec::new(),
});
let undo = app.session.undo.len();
app.run("panel.crop", json!({})).unwrap();
assert_eq!(app.ui.right, crate::state::RightPanel::Crop);
let workspace = app.ui.docking.as_ref().unwrap();
assert!(workspace.exposed(Panel::Tools));
assert_eq!(app.session.undo.len(), undo);
let saved = serde_json::to_string(workspace).unwrap();
let reloaded: Workspace = serde_json::from_str(&saved).unwrap();
reloaded.validate().unwrap();
assert!(reloaded.exposed(Panel::Tools));
}
#[test]
fn legacy_width_publication_ignores_heights_and_sibling_widths() {
for (axis, size, first) in [
(SplitAxis::Vertical, SplitSize::FixedFirst(180.0), true),
(SplitAxis::Vertical, SplitSize::FixedSecond(180.0), false),
(SplitAxis::Horizontal, SplitSize::FixedSecond(180.0), true),
(SplitAxis::Horizontal, SplitSize::FixedFirst(180.0), false),
] {
let workspace = tools_split(axis, size, first);
workspace.validate().unwrap();
let mut app = app_with_widths();
publish_visibility(&mut app, &workspace);
assert_eq!((app.ui.left_width, app.ui.right_width), (333.0, 410.0), "{axis:?} {size:?}, first={first}");
}
}
#[test]
fn legacy_width_publication_reads_own_horizontal_and_floating_widths() {
for (size, first) in [(SplitSize::FixedFirst(310.0), true), (SplitSize::FixedSecond(310.0), false)] {
let workspace = tools_split(SplitAxis::Horizontal, size, first);
let mut app = app_with_widths();
publish_visibility(&mut app, &workspace);
assert_eq!((app.ui.left_width, app.ui.right_width), (333.0, 310.0));
}
let mut workspace = Workspace::default();
workspace.apply(Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap();
let mut app = app_with_widths();
publish_visibility(&mut app, &workspace);
assert_eq!(app.ui.right_width, 390.0);
}
#[test]
fn legacy_width_update_preserves_hidden_top_panel_height() {
let mut workspace = Workspace::default();
workspace.apply(Action::Move { panel: Panel::Tools, anchor: Panel::Canvas, placement: Placement::Split(Zone::Top) }).unwrap();
workspace.set_visible(Panel::Tools, false).unwrap();
let height = workspace.hidden.iter().find(|(panel, _)| *panel == Panel::Tools).unwrap().1.size;
let mut state = crate::UiState { docking: Some(workspace), right_width: 410.0, ..Default::default() };
set_widths(&mut state, &json!({"rightWidth":410.0})).unwrap();
assert_eq!(state.docking.as_ref().unwrap().hidden.iter().find(|(panel, _)| *panel == Panel::Tools).unwrap().1.size, height);
}
}
+11 -1
View File
@@ -2493,9 +2493,19 @@ mod tests {
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t);
h.request("ui.set", json!({"view": "detail", "right": "info"}), t);
let before = h.app.session.catalog.photo(id).unwrap().captured.clone().unwrap();
// The dock header leaves less vertical space; bring the metadata action into view.
let panel = h.app.widgets.iter().find(|(id, _)| id == "panel:right_panel").unwrap().1;
let clipped_action = h.app.widgets.iter().find(|(id, _)| id == "icon:editCaptureTime").unwrap().1;
assert!(!panel.contains_rect(clipped_action), "the capture-time action starts below the visible panel: {clipped_action:?}");
h.request("ui.move", json!({"x": panel.center().x, "y": panel.center().y}), t);
h.request("ui.scroll", json!({"dy": -160.0}), t);
h.settle(SETTLE);
let action = h.app.widgets.iter().find(|(id, _)| id == "icon:editCaptureTime").unwrap().1;
assert!(panel.contains_rect(action), "capture-time action must be visible before clicking: {action:?} in {panel:?}");
let r = h.request("ui.clickWidget", json!({"id": "icon:editCaptureTime"}), t);
assert_eq!(r["ok"], true, "{r}");
h.request("ui.clickWidget", json!({"id": "button:captureMode-2"}), t);
let r = h.request("ui.clickWidget", json!({"id": "button:captureMode-2"}), t);
assert_eq!(r["ok"], true, "{r}");
if let Some(crate::state::Dialog::CaptureTime { zone, .. }) = &mut h.app.ui.dialog {
*zone = -3.0;
} else {
+8 -25
View File
@@ -10,6 +10,7 @@ pub mod album_picker;
pub mod control;
pub mod credits;
pub mod date_picker;
pub mod docking;
pub mod export_task;
pub mod headless;
pub mod i18n;
@@ -415,6 +416,9 @@ impl LightcraftApp {
return result;
}
if let Some(r) = menus::run_ui_command(self, id, &params) {
if r.is_ok() {
docking::reveal_command(self, id)?;
}
return r;
}
let r = self.session.execute(id, &params).map_err(|e| e.to_string());
@@ -1037,31 +1041,7 @@ impl LightcraftApp {
panels::topbar::show(self, ui);
panels::library_problem::banner(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 | state::ViewMode::Survey | state::ViewMode::Reference)
{
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 => panels::detail::show(self, ui),
state::ViewMode::Compare => panels::compare::show_compare(self, ui),
state::ViewMode::Survey => panels::compare::show_survey(self, ui),
state::ViewMode::Reference => panels::compare::show_reference(self, ui),
state::ViewMode::People => panels::people::show(self, ui),
});
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| docking::show(self, ui));
panels::second::show(self, &ctx);
panels::notices::show(self, &ctx);
panels::dialogs::show(self, &ctx);
@@ -1616,3 +1596,6 @@ mod cache_tests {
#[cfg(test)]
mod tests_model_setup;
#[cfg(test)]
mod tests_docking_acceptance;
+12 -3
View File
@@ -51,6 +51,8 @@ pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> {
}
pub const UI_COMMANDS: &[UiCommand] = &[
("ui.dock", "Arrange Panel", None, ""),
("ui.dock.reset", "Reset Panel Layout", None, "Window"),
("modelSetup.cancel", "Cancel Pending Photo Action", None, ""),
("view.photoGrid", "Photo Grid", None, "View"),
("view.squareGrid", "Square Grid", None, "View"),
@@ -204,7 +206,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
];
fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) {
if app.ui.right == p {
if app.ui.right == p && crate::docking::exposed(app, crate::docking::Panel::Tools) {
app.ui.right = RightPanel::None;
app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = crate::i18n::tr(name)));
} else {
@@ -266,6 +268,9 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> {
/// Handle UI commands; `None` means "not a UI command — send it to the engine".
pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
if matches!(id, "ui.dock" | "ui.dock.reset") {
return Some(crate::docking::command(app, id == "ui.dock.reset", p));
}
if matches!(id, "library.inspectLightroom" | "library.importLightroom") {
// the app's own context: a fresh one's repaints reach no window and its clock starts at zero
let ctx = app.tasks.repaint.clone().unwrap_or_default();
@@ -751,7 +756,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
"panel.profiles" => {
// toggles between the profile browser and the Edit panel it belongs to
app.ui.right = if app.ui.right == RightPanel::Profiles { RightPanel::Edit } else { RightPanel::Profiles };
app.ui.right = if app.ui.right == RightPanel::Profiles && crate::docking::exposed(app, crate::docking::Panel::Tools) {
RightPanel::Edit
} else {
RightPanel::Profiles
};
if !matches!(app.ui.view, ViewMode::Detail) {
app.ui.view = ViewMode::Detail;
}
@@ -793,7 +802,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(Value::Null)
}
"panel.presets" => {
app.ui.presets = !app.ui.presets;
app.ui.presets = !app.ui.presets || !crate::docking::exposed(app, crate::docking::Panel::Presets);
if app.ui.presets && app.ui.view != ViewMode::Detail {
app.ui.view = ViewMode::Detail;
}
+221 -35
View File
@@ -10,6 +10,15 @@ use crate::state::{BeforeAfter, ViewMode, Zoom};
use crate::theme::Tokens;
use crate::widgets::{icon_button, register, stars};
const VIEW_MODES: &[(&str, Icon, ViewMode, &str)] = &[
("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"),
("squareGrid", Icon::GridSquare, ViewMode::SquareGrid, "Square Grid (G toggles)"),
("detail", Icon::Single, ViewMode::Detail, "Detail (D)"),
("compare", Icon::Compare, ViewMode::Compare, "Compare (Shift+C)"),
("survey", Icon::Survey, ViewMode::Survey, "Survey (N)"),
("people", Icon::Subject, ViewMode::People, "People"),
];
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::bottom("bottom_bar")
@@ -17,32 +26,52 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
.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();
let left_end = ui
let compact_zoom = full.width() < 360.0;
let right_start = right_side(app, ui, full, compact_zoom);
let left_rect = Rect::from_min_max(full.min, pos2((right_start - 12.0).max(full.left()), full.bottom()));
let mut left_ui = ui.new_child(egui::UiBuilder::new().max_rect(left_rect));
let left_end = left_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 (G toggles)"),
("detail", Icon::Single, ViewMode::Detail, "Detail (D)"),
("compare", Icon::Compare, ViewMode::Compare, "Compare (Shift+C)"),
("survey", Icon::Survey, ViewMode::Survey, "Survey (N)"),
("people", Icon::Subject, ViewMode::People, "People"),
] {
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!({}));
if left_rect.width() >= 270.0 {
for &(id, icon, mode, tip) in VIEW_MODES {
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));
} else {
let current_icon = VIEW_MODES.iter().find(|(_, _, mode, _)| *mode == app.ui.view).map_or(Icon::More, |(_, icon, _, _)| *icon);
let more = icon_button(ui, "viewOptions", current_icon, vec2(40.0, 32.0), true, true, "View");
paint(
ui.painter(),
Rect::from_center_size(more.rect.right_bottom() - vec2(5.0, 5.0), vec2(7.0, 7.0)),
Icon::ChevronDown,
t.text,
);
egui::Popup::menu(&more).show(|ui| {
for &(id, _, mode, label) in VIEW_MODES {
if ui.selectable_label(app.ui.view == mode, crate::i18n::tr(label)).clicked() {
let _ = app.run(&format!("view.{id}"), json!({}));
ui.close();
}
}
ui.separator();
ui.menu_button(crate::i18n::tr("Sort"), |ui| sort_menu(app, ui));
if compact_zoom && matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) {
ui.menu_button(crate::i18n::tr_format!("Click {}:1", app.ui.click_zoom / 100), |ui| click_zoom_menu(app, ui));
}
});
}
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));
})
.response
.rect
.right();
let right_start = right_side(app, ui, full);
centre(app, ui, full, left_end + 12.0, right_start - 12.0);
});
}
@@ -123,11 +152,11 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to:
}
/// The right-hand group (zoom, view toggles or thumbnail size); returns its left edge.
fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 {
fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, compact_zoom: bool) -> f32 {
let t = Tokens::get(ui.ctx());
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()))
.max_rect(Rect::from_min_max(pos2((full.right() - 360.0).max(full.left()), full.top()), full.right_bottom()))
.layout(egui::Layout::right_to_left(egui::Align::Center)),
);
child.spacing_mut().item_spacing.x = 6.0;
@@ -191,21 +220,17 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 {
}
}
});
child.add_space(6.0);
let cz = crate::widgets::dropdown(
&mut child,
"clickZoom",
&crate::i18n::tr_format!("Click {}:1", app.ui.click_zoom / 100),
t.font(13.0),
t.text_label,
);
egui::Popup::menu(&cz).show(|ui| {
for pct in crate::state::CLICK_ZOOMS {
if ui.selectable_label(app.ui.click_zoom == pct, format!("{}:1", pct / 100)).clicked() {
let _ = app.run("view.clickZoom", json!({"ratio": pct / 100}));
}
}
});
if !compact_zoom {
child.add_space(6.0);
let cz = crate::widgets::dropdown(
&mut child,
"clickZoom",
&crate::i18n::tr_format!("Click {}:1", app.ui.click_zoom / 100),
t.font(13.0),
t.text_label,
);
egui::Popup::menu(&cz).show(|ui| click_zoom_menu(app, ui));
}
} else {
// thumbnail size slider
let (r, resp) = child.allocate_exact_size(vec2(110.0, 20.0), Sense::click_and_drag());
@@ -225,6 +250,14 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 {
if child.min_rect().width() > 0.0 { child.min_rect().left() } else { full.right() }
}
fn click_zoom_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
for pct in crate::state::CLICK_ZOOMS {
if ui.selectable_label(app.ui.click_zoom == pct, format!("{}:1", pct / 100)).clicked() {
let _ = app.run("view.clickZoom", json!({"ratio": pct / 100}));
}
}
}
fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
use lightcraft_catalog::GroupBy;
use lightcraft_catalog::SortKey::*;
@@ -270,3 +303,156 @@ fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
}
}
#[cfg(test)]
mod visual_regressions {
use super::*;
fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput {
let mut input = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() };
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale);
input
}
#[test]
fn capture_bottom_bar_visual_fixtures() {
let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return };
std::fs::create_dir_all(&directory).unwrap();
for width in [280.0, 400.0, 600.0, 1000.0] {
for view in [
ViewMode::PhotoGrid,
ViewMode::SquareGrid,
ViewMode::Detail,
ViewMode::Compare,
ViewMode::Survey,
ViewMode::Reference,
ViewMode::People,
] {
for scale in [1.0, 1.5, 2.0] {
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Default::default());
app.ui.view = view;
let mut ready = false;
let mut harness =
egui_kittest::Harness::builder().with_size(vec2(width, 100.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
move |ui, app: &mut LightcraftApp| {
if !ready {
crate::theme::install_fonts(ui.ctx());
crate::theme::apply(ui.ctx());
ready = true;
return;
}
crate::widgets::take_registry(ui.ctx());
show(app, ui);
},
app,
);
harness.input_mut().max_texture_side = Some(8192);
harness.run_steps(4);
let stem = format!("lightcraft-bottom-{view:?}-{width}-{scale}x");
harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap();
let panel = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new("bottom_bar")).unwrap().outer_rect;
let controls: serde_json::Map<String, serde_json::Value> = crate::widgets::take_registry(&harness.ctx)
.into_iter()
.map(|(id, r)| (id, json!([r.left(), r.top(), r.width(), r.height()])))
.collect();
std::fs::write(
directory.join(format!("{stem}.json")),
serde_json::to_vec_pretty(&json!({
"screen": [width, 100.0], "scale": scale,
"regions": {"bottom-bar": [panel.left(), panel.top(), panel.width(), panel.height()]},
"controls": controls
}))
.unwrap(),
)
.unwrap();
}
}
}
}
#[test]
fn bottom_bar_controls_do_not_overlap_in_narrow_canvas() {
for width in [280.0, 400.0, 600.0, 1000.0] {
for view in [
ViewMode::PhotoGrid,
ViewMode::SquareGrid,
ViewMode::Detail,
ViewMode::Compare,
ViewMode::Survey,
ViewMode::Reference,
ViewMode::People,
] {
for scale in [1.0, 1.5, 2.0] {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx);
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Default::default());
app.ui.view = view;
let mut output = ctx.run_ui(scaled_input(vec2(width, 100.0), scale), |ui| show(&mut app, ui));
output.textures_delta.clear();
assert_eq!(ctx.content_rect().size(), vec2(width, 100.0));
assert_eq!(ctx.pixels_per_point(), scale);
let controls = crate::widgets::take_registry(&ctx);
assert!(!controls.is_empty());
for (index, (id, rect)) in controls.iter().enumerate() {
assert!(rect.left() >= 0.0 && rect.right() <= width, "{view:?} at {width}: {id} outside viewport: {rect:?}");
for (other_id, other) in controls.iter().skip(index + 1) {
assert!(!rect.intersect(*other).is_positive(), "{view:?} at {width}: {id} overlaps {other_id}: {rect:?} / {other:?}");
}
}
}
}
}
}
#[test]
fn compact_view_menu_opens_and_changes_view_by_keyboard() {
use egui_kittest::kittest::Queryable;
for scale in [1.0, 1.5, 2.0] {
let app = LightcraftApp::new(lightcraft_engine::Session::new(), Default::default());
let mut ready = false;
let mut harness = egui_kittest::Harness::builder().with_size(vec2(280.0, 640.0)).with_pixels_per_point(scale).build_ui_state(
move |ui, app: &mut LightcraftApp| {
if !ready {
crate::theme::install_fonts(ui.ctx());
crate::theme::apply(ui.ctx());
ready = true;
return;
}
crate::shortcuts::handle(app, ui.ctx());
crate::widgets::take_registry(ui.ctx());
show(app, ui);
},
app,
);
harness.run_steps(4);
assert_eq!(harness.ctx.content_rect().size(), vec2(280.0, 640.0));
assert_eq!(harness.ctx.pixels_per_point(), scale);
let mut focused = false;
for _ in 0..12 {
harness.key_press(egui::Key::Tab);
harness.run_steps(3);
focused = harness.get_by_label("View").is_focused();
if focused {
break;
}
}
assert!(focused, "Tab reaches the compact View control at {scale}x");
harness.key_press(egui::Key::Space);
harness.run_steps(3);
let mut people_focused = false;
for _ in 0..20 {
harness.key_press(egui::Key::Tab);
harness.run_steps(3);
people_focused = harness.get_by_label("People").is_focused();
if people_focused {
break;
}
}
assert!(people_focused, "Tab reaches People at {scale}x");
harness.key_press(egui::Key::Space);
harness.run_steps(3);
assert_eq!(harness.state().ui.view, ViewMode::People, "keyboard activation dispatches the view command");
}
}
}
+1 -1
View File
@@ -107,7 +107,7 @@ pub(crate) fn follow(app: &mut LightcraftApp, from: &str, to: &str) {
/// The pointer is on the part of `rect` that is shown: inside it and inside the panel's visible
/// (scrolled) area, so a drop never lands on a row or title hidden under the top bar.
fn shown_under(ui: &egui::Ui, rect: Rect, pointer: Option<egui::Pos2>) -> bool {
pointer.is_some_and(|p| rect.contains(p) && ui.clip_rect().contains(p))
crate::docking::drop_target(ui, rect, pointer)
}
/// A keyword being dragged, each frame (after the panels, which take the drop): its name follows
+3 -7
View File
@@ -180,8 +180,7 @@ fn sidebar_section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str,
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider));
let width = app.ui.left_width;
let resized = super::resizable_side(ui, true, "left_panel", frame, width, crate::state::LEFT_WIDTH, 0.0, |ui| {
frame.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, crate::i18n::tr("My Photos"), t.semibold(15.0), t.text);
@@ -300,9 +299,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
});
});
if let Some(w) = resized {
app.ui.left_width = w;
}
}
/// How many library photos have no file (Local browse records are not checked; see
@@ -775,7 +771,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare
/// Whether the pointer is over `rect` and over what the panel shows of it (a row scrolled out of
/// sight is not a drop target).
fn pointer_over(ui: &egui::Ui, rect: Rect) -> bool {
ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| rect.contains(p) && ui.clip_rect().contains(p))
crate::docking::drop_target(ui, rect, ui.input(|i| i.pointer.latest_pos()))
}
/// How close to the top or bottom edge of the sidebar (points) a dragged album starts scrolling it.
@@ -1010,7 +1006,7 @@ pub fn album_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) {
/// release there adds them.
fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, a: &Album) {
let Some(ids) = app.ui.dragging_photos.clone() else { return };
let over = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| resp.rect.contains(p));
let over = pointer_over(ui, resp.rect);
if !over {
return;
}
+197 -215
View File
@@ -19,224 +19,206 @@ const THUMB_EDGE: usize = 128;
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, crate::i18n::tr("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::create_preset());
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, crate::i18n::tr("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::create_preset());
}
let more = icon_button(&mut hdr, "presetMore", Icon::More, vec2(28.0, 28.0), false, true, "More preset options");
egui::Popup::menu(&more).show(|ui| {
let r = ui.checkbox(&mut app.ui.preset_thumbs, crate::i18n::tr("Show Thumbnails"));
register(ui.ctx(), "presetMenu:thumbnails", r.rect);
ui.separator();
if ui.button(crate::i18n::tr("Import Presets…")).clicked() {
let _ = app.run("file.importPresets", json!({}));
}
let more = icon_button(&mut hdr, "presetMore", Icon::More, vec2(28.0, 28.0), false, true, "More preset options");
egui::Popup::menu(&more).show(|ui| {
let r = ui.checkbox(&mut app.ui.preset_thumbs, crate::i18n::tr("Show Thumbnails"));
register(ui.ctx(), "presetMenu:thumbnails", r.rect);
ui.separator();
if ui.button(crate::i18n::tr("Import Presets…")).clicked() {
let _ = app.run("file.importPresets", json!({}));
}
let any_user = app.session.presets.iter().any(|p| !p.builtin);
if ui.add_enabled(any_user, egui::Button::new(crate::i18n::tr("Export User Presets…"))).clicked() {
let _ = app.run("file.exportPresets", json!({}));
}
});
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);
}
let active = app.session.active();
let current = active.and_then(|id| app.session.develop_of(id)).map(|d| (*d).clone());
let thumbs = app.ui.preset_thumbs && active.is_some();
let row_h = if thumbs { 54.0 } else { 26.0 };
let scroll = egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let mut groups: BTreeMap<String, Vec<Preset>> = BTreeMap::new();
for p in &app.session.presets {
groups.entry(p.group.clone()).or_default().push(p.clone());
}
for (g, items) in groups {
// A group holding stock presets is a stock group (shown in the UI language), even
// when user presets share its name; a group of user presets keeps its name.
let builtin = items.iter().any(|p| p.builtin);
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,
crate::i18n::builtin_label(&g, builtin),
t.semibold(13.0),
t.text_label,
);
if resp.clicked() {
ui.data_mut(|d| d.insert_temp(open_id, !open));
}
resp.context_menu(|ui| {
if ui.button(crate::i18n::tr("Export Group…")).clicked() {
let _ = app.run("file.exportPresets", json!({"group": g}));
}
});
if !open {
continue;
}
for pr in items {
let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
register(ui.ctx(), format!("preset:{pid}"), r);
let is_last = last.as_ref().is_some_and(|(l, _)| *l == pid);
if is_last {
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));
}
let look = if thumbs || resp.hovered() { current.as_ref().map(|d| pr.apply(d, 1.0)) } else { None };
let mut text_x = r.left() + 40.0;
if thumbs && let (Some(id), Some(s)) = (active, look.as_ref()) {
let tr = Rect::from_min_size(pos2(r.left() + 36.0, r.top() + 5.0), vec2(66.0, row_h - 10.0));
ui.painter().rect_filled(tr, 2.0, t.canvas);
if let Some(job) = app.session.variant_job(id, s, THUMB_EDGE)
&& let Some(tex) = app.renderer.variant(job)
{
ui.painter().image(tex.tex.id(), tr, cover_uv(tr, tex.size), Color32::WHITE);
}
text_x = tr.right() + 10.0;
}
ui.painter().text(pos2(text_x, 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,
);
}
// resting on a preset previews it in the loupe (no history entry)
if resp.hovered()
&& let Some(s) = look
{
let label = crate::i18n::tr_format!("Preset: {name}", name = name);
app.hover_preview = Some(HoverPreview { label: label.clone(), settings: s });
if let Some(id) = active {
ui.data_mut(|d| d.insert_temp(egui::Id::new("last-preset-hover"), (id, pr.clone(), label)));
}
}
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)));
ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(egui::Id::new("last-preset-hover")));
app.toast(ui.ctx(), crate::i18n::tr_format!("Preset: {name}", name = name));
}
let (builtin, group) = (pr.builtin, pr.group.clone());
resp.context_menu(|ui| {
if ui.button(crate::i18n::tr(if fav { "Remove from Favorites" } else { "Add to Favorites" })).clicked() {
let _ = app.run("preset.favorite", json!({"id": pid}));
}
if builtin {
return;
}
ui.separator();
if ui
.add_enabled(app.session.active().is_some(), egui::Button::new(crate::i18n::tr("Update with Current Settings")))
.clicked()
{
match app.run("preset.update", json!({"id": pid})) {
Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Updated “{name}”", name = name)),
Err(e) => app.toast(ui.ctx(), e),
}
}
if ui.button(crate::i18n::tr("Rename…")).clicked() {
crate::panels::dialogs::prompt(
app,
"Rename Preset",
"Preset name",
&name,
"preset.rename",
json!({"id": pid}),
"name",
);
}
ui.menu_button(crate::i18n::tr("Move to Group"), |ui| {
let mut groups: Vec<String> = app.session.presets.iter().filter(|p| !p.builtin).map(|p| p.group.clone()).collect();
groups.sort();
groups.dedup();
for g in groups.iter().filter(|g| **g != group) {
if ui.button(g).clicked() {
let _ = app.run("preset.move", json!({"id": pid, "group": g}));
}
}
if ui.button(crate::i18n::tr("New Group…")).clicked() {
crate::panels::dialogs::prompt(
app,
"Move Preset to New Group",
"Group name",
"",
"preset.move",
json!({"id": pid}),
"group",
);
}
});
ui.separator();
if ui.button(crate::i18n::tr("Delete Preset")).clicked() {
let _ = app.run("preset.delete", json!({"id": pid}));
}
});
}
}
ui.add_space(30.0);
});
let last_hover_id = egui::Id::new("last-preset-hover");
let pointer_in_scroll = ui.ctx().input(|i| i.pointer.hover_pos()).is_some_and(|pos| scroll.inner_rect.contains(pos));
if pointer_in_scroll {
if app.hover_preview.is_none()
&& let (Some(id), Some(curr)) = (active, current.as_ref())
&& let Some((saved_id, saved_preset, saved_label)) =
ui.data(|d| d.get_temp::<(lightcraft_catalog::PhotoId, Preset, String)>(last_hover_id))
&& saved_id == id
{
app.hover_preview = Some(HoverPreview { label: saved_label, settings: saved_preset.apply(curr, 1.0) });
}
} else {
ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(last_hover_id));
let any_user = app.session.presets.iter().any(|p| !p.builtin);
if ui.add_enabled(any_user, egui::Button::new(crate::i18n::tr("Export User Presets…"))).clicked() {
let _ = app.run("file.exportPresets", json!({}));
}
});
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);
}
let active = app.session.active();
let current = active.and_then(|id| app.session.develop_of(id)).map(|d| (*d).clone());
let thumbs = app.ui.preset_thumbs && active.is_some();
let row_h = if thumbs { 54.0 } else { 26.0 };
let scroll = egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let mut groups: BTreeMap<String, Vec<Preset>> = BTreeMap::new();
for p in &app.session.presets {
groups.entry(p.group.clone()).or_default().push(p.clone());
}
for (g, items) in groups {
// A group holding stock presets is a stock group (shown in the UI language), even
// when user presets share its name; a group of user presets keeps its name.
let builtin = items.iter().any(|p| p.builtin);
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,
crate::i18n::builtin_label(&g, builtin),
t.semibold(13.0),
t.text_label,
);
if resp.clicked() {
ui.data_mut(|d| d.insert_temp(open_id, !open));
}
resp.context_menu(|ui| {
if ui.button(crate::i18n::tr("Export Group…")).clicked() {
let _ = app.run("file.exportPresets", json!({"group": g}));
}
});
if !open {
continue;
}
for pr in items {
let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
register(ui.ctx(), format!("preset:{pid}"), r);
let is_last = last.as_ref().is_some_and(|(l, _)| *l == pid);
if is_last {
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));
}
let look = if thumbs || resp.hovered() { current.as_ref().map(|d| pr.apply(d, 1.0)) } else { None };
let mut text_x = r.left() + 40.0;
if thumbs && let (Some(id), Some(s)) = (active, look.as_ref()) {
let tr = Rect::from_min_size(pos2(r.left() + 36.0, r.top() + 5.0), vec2(66.0, row_h - 10.0));
ui.painter().rect_filled(tr, 2.0, t.canvas);
if let Some(job) = app.session.variant_job(id, s, THUMB_EDGE)
&& let Some(tex) = app.renderer.variant(job)
{
ui.painter().image(tex.tex.id(), tr, cover_uv(tr, tex.size), Color32::WHITE);
}
text_x = tr.right() + 10.0;
}
ui.painter().text(pos2(text_x, 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);
}
// resting on a preset previews it in the loupe (no history entry)
if resp.hovered()
&& let Some(s) = look
{
let label = crate::i18n::tr_format!("Preset: {name}", name = name);
app.hover_preview = Some(HoverPreview { label: label.clone(), settings: s });
if let Some(id) = active {
ui.data_mut(|d| d.insert_temp(egui::Id::new("last-preset-hover"), (id, pr.clone(), label)));
}
}
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)));
ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(egui::Id::new("last-preset-hover")));
app.toast(ui.ctx(), crate::i18n::tr_format!("Preset: {name}", name = name));
}
let (builtin, group) = (pr.builtin, pr.group.clone());
resp.context_menu(|ui| {
if ui.button(crate::i18n::tr(if fav { "Remove from Favorites" } else { "Add to Favorites" })).clicked() {
let _ = app.run("preset.favorite", json!({"id": pid}));
}
if builtin {
return;
}
ui.separator();
if ui
.add_enabled(app.session.active().is_some(), egui::Button::new(crate::i18n::tr("Update with Current Settings")))
.clicked()
{
match app.run("preset.update", json!({"id": pid})) {
Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Updated “{name}”", name = name)),
Err(e) => app.toast(ui.ctx(), e),
}
}
if ui.button(crate::i18n::tr("Rename…")).clicked() {
crate::panels::dialogs::prompt(app, "Rename Preset", "Preset name", &name, "preset.rename", json!({"id": pid}), "name");
}
ui.menu_button(crate::i18n::tr("Move to Group"), |ui| {
let mut groups: Vec<String> = app.session.presets.iter().filter(|p| !p.builtin).map(|p| p.group.clone()).collect();
groups.sort();
groups.dedup();
for g in groups.iter().filter(|g| **g != group) {
if ui.button(g).clicked() {
let _ = app.run("preset.move", json!({"id": pid, "group": g}));
}
}
if ui.button(crate::i18n::tr("New Group…")).clicked() {
crate::panels::dialogs::prompt(
app,
"Move Preset to New Group",
"Group name",
"",
"preset.move",
json!({"id": pid}),
"group",
);
}
});
ui.separator();
if ui.button(crate::i18n::tr("Delete Preset")).clicked() {
let _ = app.run("preset.delete", json!({"id": pid}));
}
});
}
}
ui.add_space(30.0);
});
let last_hover_id = egui::Id::new("last-preset-hover");
let pointer_in_scroll = ui.ctx().input(|i| i.pointer.hover_pos()).is_some_and(|pos| scroll.inner_rect.contains(pos));
if pointer_in_scroll {
if app.hover_preview.is_none()
&& let (Some(id), Some(curr)) = (active, current.as_ref())
&& let Some((saved_id, saved_preset, saved_label)) =
ui.data(|d| d.get_temp::<(lightcraft_catalog::PhotoId, Preset, String)>(last_hover_id))
&& saved_id == id
{
app.hover_preview = Some(HoverPreview { label: saved_label, settings: saved_preset.apply(curr, 1.0) });
}
} else {
ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(last_hover_id));
}
});
}
/// The part of a `size` image that covers `r` (centre crop), as texture coordinates.
+1 -7
View File
@@ -14,10 +14,7 @@ use crate::widgets::{divider, register, slider, text_button};
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider));
// the presets column and the left sidebar are laid out after this panel: leave them their room
let reserve = if app.ui.presets { t.panel_w } else { 0.0 } + if app.ui.left_panel { crate::state::LEFT_WIDTH.min } else { 0.0 };
let width = app.ui.right_width;
let resized = super::resizable_side(ui, false, "right_panel", frame, width, crate::state::RIGHT_WIDTH, reserve, |ui| {
frame.show(ui, |ui| {
let Some(id) = app.session.active() else {
// the Keyword List is the library's, not a photo's: it is there without a selection
if app.ui.right == RightPanel::Keywords {
@@ -52,9 +49,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
});
});
if let Some(w) = resized {
app.ui.right_width = w;
}
}
pub fn header(ui: &mut egui::Ui, title: &str) {
+22 -1
View File
@@ -300,12 +300,33 @@ fn grid_bracket_command(keymap: &Keymap, grid: bool, id: &'static str, shortcut:
if keymap.iter().any(|(other, sc)| other != id && find_bindable(other).is_some() && parse(sc) == Some(shortcut)) { None } else { Some(command) }
}
/// Content widgets keep keyboard ownership, including text fields and arrow-driven sliders.
/// Dock chrome owns activation, traversal, resizing and cancellation; its other keys reach commands.
fn keyboard_owned(app: &LightcraftApp, ctx: &egui::Context) -> bool {
if !ctx.egui_wants_keyboard_input() {
return false;
}
let chrome = app
.ui
.docking
.as_ref()
.is_some_and(|workspace| craft_ui::docking::DockArea::new(egui::Id::new("lightcraft-docking")).chrome_has_focus(ctx, &workspace.layout));
if !chrome {
return true;
}
ctx.input(|input| {
[Key::Enter, Key::Space, Key::Tab, Key::Escape, Key::ArrowLeft, Key::ArrowRight, Key::ArrowUp, Key::ArrowDown]
.iter()
.any(|key| input.key_pressed(*key))
})
}
pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
if !matches!(app.ui.dialog, Some(crate::state::Dialog::Shortcuts)) {
app.recording_shortcut = None;
}
// don't steal keys from text fields or from the keymap editor recording a shortcut
if ctx.egui_wants_keyboard_input() || app.recording_shortcut.is_some() || app.ui.name_edit.is_some() {
if keyboard_owned(app, ctx) || app.recording_shortcut.is_some() || app.ui.name_edit.is_some() {
return;
}
let mut fire: Vec<String> = Vec::new();
+3
View File
@@ -280,6 +280,8 @@ pub enum KeywordingView {
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct UiState {
/// Shared panel arrangement; absent in legacy settings until their first layout.
pub docking: Option<crate::docking::Workspace>,
#[serde(default = "crate::i18n::default_language")]
pub language: crate::i18n::Locale,
/// The Build Previews run last announced (its identity, finished?).
@@ -780,6 +782,7 @@ pub struct SyncCounts {
impl Default for UiState {
fn default() -> Self {
UiState {
docking: None,
language: crate::i18n::default_language(),
preview_build_seen: None,
unsaved_seen: false,
@@ -0,0 +1,334 @@
//! Dock changes exercise the same real catalog and command handlers as the application.
use crate::{LightcraftApp, Services, docking::Panel, headless::Headless, state::RightPanel};
use egui::{Event, Modifiers, PointerButton};
use serde_json::json;
fn app() -> Headless {
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
app.ui.left_panel = true;
let mut h = Headless::new(app, [1400.0, 1000.0], 1.0);
for _ in 0..5 {
h.step();
}
h
}
fn dock(h: &mut Headless, p: serde_json::Value) {
h.app.run("ui.dock", p).unwrap();
for _ in 0..4 {
h.step();
}
}
fn rect(h: &Headless, id: &str) -> egui::Rect {
h.app.widgets.iter().find(|(key, _)| key == id).unwrap().1
}
fn release(h: &mut Headless, pos: egui::Pos2) {
h.app.synthetic.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.app.synthetic.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.step();
}
#[test]
fn inactive_same_mode_commands_reveal_without_closing_or_editing() {
let mut h = app();
dock(&mut h, json!({"operation":"move","panel":"tools","target":"library","zone":"center"}));
for command in ["panel.crop", "panel.crop", "tool.brush", "section.light", "panel.profiles"] {
dock(&mut h, json!({"operation":"activate","panel":"library"}));
let undo = h.app.session.undo.len();
let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap();
h.app.run(command, json!({})).unwrap();
assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Tools), "{command}");
assert_ne!(h.app.ui.right, RightPanel::None);
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap(), photos);
h.step();
}
h.app.ui.presets = true;
h.step();
dock(&mut h, json!({"operation":"move","panel":"presets","target":"library","zone":"center"}));
dock(&mut h, json!({"operation":"activate","panel":"library"}));
h.app.run("panel.presets", json!({})).unwrap();
assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Presets));
dock(&mut h, json!({"operation":"activate","panel":"library"}));
dock(&mut h, json!({"operation":"open","panel":"presets"}));
assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Presets));
}
#[test]
fn crop_canvas_double_click_reveals_edit_in_actual_custom_workspace() {
let mut h = app();
h.app.ui.view = crate::state::ViewMode::Detail;
h.app.run("panel.crop", json!({})).unwrap();
assert!(h.settle(std::time::Duration::from_secs(120)), "the demo photo settles before pointer input");
h.app.ui.presets = true;
h.step();
dock(&mut h, json!({"operation":"move","panel":"tools","target":"presets","zone":"center"}));
dock(&mut h, json!({"operation":"activate","panel":"presets"}));
assert_eq!(h.app.ui.right, RightPanel::Crop);
assert!(!h.app.ui.docking.as_ref().unwrap().exposed(Panel::Tools));
let library = h.app.ui.docking.as_ref().unwrap().layout.location(&Panel::Library).unwrap();
let id = h.app.session.active().unwrap();
let photo = serde_json::to_value(h.app.session.catalog.photo(id).unwrap().as_ref()).unwrap();
let undo = h.app.session.undo.len();
let center = h.app.image_rect.expect("crop photo is rendered").center();
release(&mut h, center);
release(&mut h, center);
for _ in 0..3 {
h.step();
}
assert_eq!(h.app.ui.right, RightPanel::Edit, "the real Canvas double-click completes Crop");
let workspace = h.app.ui.docking.as_ref().unwrap();
assert!(workspace.exposed(Panel::Tools), "tool.done reveals Tools after the Canvas callback");
assert_eq!(workspace.layout.location(&Panel::Tools).unwrap().anchor, Some(Panel::Presets));
assert_eq!(workspace.layout.location(&Panel::Library).unwrap(), library);
assert_eq!(h.app.session.active(), Some(id));
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(serde_json::to_value(h.app.session.catalog.photo(id).unwrap().as_ref()).unwrap(), photo);
}
#[test]
fn floating_panel_blocks_photo_album_drop_then_uncovered_target_adds_exact_selection() {
let mut h = app();
let album = h.app.run("album.create", json!({"name":"A Dock Target", "addSelected":false})).unwrap()["id"].as_u64().unwrap();
h.step();
h.step();
let row = rect(&h, &format!("source:album:{album}"));
let ids: Vec<_> = h.app.session.visible_cloned().iter().take(2).map(|id| id.0).collect();
dock(&mut h, json!({"operation":"float","panel":"tools","rect":[row.left()-10.0,row.top()-30.0,330,500]}));
let undo = h.app.session.undo.len();
h.app.ui.dragging_photos = Some(ids.clone());
release(&mut h, row.center());
assert!(h.app.session.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.is_empty());
assert_eq!(h.app.session.undo.len(), undo);
dock(&mut h, json!({"operation":"moveFloating","panel":"tools","rect":[800,100,330,500]}));
h.app.ui.dragging_photos = Some(ids.clone());
release(&mut h, row.center());
assert_eq!(
h.app.session.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos,
ids.into_iter().map(lightcraft_catalog::PhotoId).collect::<Vec<_>>()
);
assert_eq!(h.app.session.undo.len(), undo + 1);
}
#[test]
fn preset_hover_survives_float_movement_without_mutating_photo() {
let mut h = app();
h.app.run("panel.presets", json!({})).unwrap();
dock(&mut h, json!({"operation":"float","panel":"presets","rect":[500,100,300,700]}));
let undo = h.app.session.undo.len();
let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap();
for x in [500, 650] {
dock(&mut h, json!({"operation":"moveFloating","panel":"presets","rect":[x,100,300,700]}));
let row = h.app.widgets.iter().find(|(id, _)| id.starts_with("preset:")).unwrap().1;
h.app.synthetic.push(Event::PointerMoved(row.center()));
h.step();
h.step();
assert!(h.app.hover_preview.is_some());
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap(), photos);
}
h.app.synthetic.push(Event::PointerMoved(egui::pos2(5.0, 5.0)));
h.step();
h.step();
assert!(h.app.hover_preview.is_none());
}
fn key(h: &mut Headless, key: egui::Key) {
h.app.synthetic.push(Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.step();
h.app.synthetic.push(Event::Key { key, physical_key: Some(key), pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.step();
}
#[test]
fn dock_tab_keeps_activation_keys_and_global_command_remains_reachable() {
let mut h = app();
h.app.run("app.setShortcut", json!({"id":"panel.crop","shortcut":"K"})).unwrap();
dock(&mut h, json!({"operation":"move","panel":"tools","target":"library","zone":"center"}));
h.app.run("panel.crop", json!({})).unwrap();
h.step();
// The test chooses the renderer's actual registered tab, rather than an arbitrary focus owner.
let id = egui::Id::new("lightcraft-docking").with(("tab", Panel::Tools));
h.view.ctx.memory_mut(|memory| memory.request_focus(id));
h.step();
key(&mut h, egui::Key::Enter);
assert_eq!(h.app.ui.right, RightPanel::Crop, "Enter activates the tab without tool.done");
key(&mut h, egui::Key::K);
assert_eq!(h.app.ui.right, RightPanel::None, "the global crop toggle is reachable from tab focus");
}
#[test]
fn content_text_edit_retains_command_keys_and_typed_text() {
let mut h = app();
h.app.run("app.setShortcut", json!({"id":"panel.crop","shortcut":"K"})).unwrap();
h.app.run("panel.keywords", json!({})).unwrap();
h.step();
h.step();
let result = h.request("ui.clickWidget", json!({"id":"field:keywordFilter"}), std::time::Duration::from_secs(20));
assert_eq!(result["ok"], true, "{result}");
key(&mut h, egui::Key::K);
h.app.synthetic.push(Event::Text("dock search".into()));
h.step();
assert_eq!(h.app.ui.right, RightPanel::Keywords);
assert_eq!(h.view.ctx.data(|data| data.get_temp::<String>(egui::Id::new("keyword-list-filter"))).as_deref(), Some("dock search"));
}
#[test]
fn floating_presets_block_keyword_photo_drop_then_uncovered_target_changes_exact_metadata() {
let mut h = app();
h.app.run("panel.keywords", json!({})).unwrap();
h.app.run("keyword.create", json!({"name":"A Dock Keyword"})).unwrap();
h.step();
h.step();
let row = rect(&h, "keywordRow:A Dock Keyword");
h.app.ui.presets = true;
h.step();
let ids: Vec<_> = h.app.session.visible_cloned().iter().take(2).map(|id| id.0).collect();
dock(&mut h, json!({"operation":"float","panel":"presets","rect":[row.left()-10.0,row.top()-30.0,300,500]}));
let undo = h.app.session.undo.len();
let before: Vec<_> = ids.iter().map(|id| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(*id)).unwrap().meta.keywords.clone()).collect();
h.app.ui.dragging_photos = Some(ids.clone());
release(&mut h, row.center());
for (id, expected) in ids.iter().zip(&before) {
assert_eq!(&h.app.session.catalog.photo(lightcraft_catalog::PhotoId(*id)).unwrap().meta.keywords, expected);
}
assert_eq!(h.app.session.undo.len(), undo);
dock(&mut h, json!({"operation":"moveFloating","panel":"presets","rect":[400,100,300,500]}));
h.app.ui.dragging_photos = Some(ids.clone());
release(&mut h, row.center());
for (id, mut expected) in ids.iter().zip(before) {
expected.push("A Dock Keyword".into());
let mut actual = h.app.session.catalog.photo(lightcraft_catalog::PhotoId(*id)).unwrap().meta.keywords.clone();
expected.sort();
actual.sort();
assert_eq!(actual, expected);
}
assert_eq!(h.app.session.undo.len(), undo + 1);
}
#[test]
fn covered_folder_rejects_album_move_and_uncovered_folder_nests_exact_album() {
let mut h = app();
let folder = h.app.run("album.create", json!({"name":"A Dock Folder","folder":true})).unwrap()["id"].as_u64().unwrap();
let child = h.app.run("album.create", json!({"name":"B Dock Album","addSelected":false})).unwrap()["id"].as_u64().unwrap();
h.step();
h.step();
let row = rect(&h, &format!("source:folder:{folder}"));
dock(&mut h, json!({"operation":"float","panel":"tools","rect":[row.left()-10.0,row.top()-30.0,330,500]}));
let undo = h.app.session.undo.len();
h.app.ui.dragging_album = Some(child);
release(&mut h, row.center());
assert_eq!(h.app.session.catalog.album(lightcraft_catalog::AlbumId(child)).unwrap().parent, None);
assert_eq!(h.app.session.undo.len(), undo);
dock(&mut h, json!({"operation":"moveFloating","panel":"tools","rect":[800,100,330,500]}));
h.app.ui.dragging_album = Some(child);
release(&mut h, row.center());
assert_eq!(h.app.session.catalog.album(lightcraft_catalog::AlbumId(child)).unwrap().parent, Some(lightcraft_catalog::AlbumId(folder)));
assert_eq!(h.app.session.undo.len(), undo + 1);
}
#[test]
fn covered_keyword_rejects_hierarchy_move_and_uncovered_target_moves_exact_keyword() {
let mut h = app();
h.app.run("panel.keywords", json!({})).unwrap();
h.app.run("keyword.create", json!({"name":"A Dock Parent"})).unwrap();
h.app.run("keyword.create", json!({"name":"B Dock Child"})).unwrap();
h.app.ui.presets = true;
h.step();
h.step();
let row = rect(&h, "keywordRow:A Dock Parent");
dock(&mut h, json!({"operation":"float","panel":"presets","rect":[row.left()-10.0,row.top()-30.0,300,500]}));
let undo = h.app.session.undo.len();
h.app.ui.dragging_keyword = Some("B Dock Child".into());
release(&mut h, row.center());
assert!(h.app.session.catalog.has_keyword("B Dock Child"));
assert!(!h.app.session.catalog.has_keyword("A Dock Parent|B Dock Child"));
assert_eq!(h.app.session.undo.len(), undo);
dock(&mut h, json!({"operation":"moveFloating","panel":"presets","rect":[400,100,300,500]}));
h.app.ui.dragging_keyword = Some("B Dock Child".into());
release(&mut h, row.center());
assert!(!h.app.session.catalog.has_keyword("B Dock Child"));
assert!(h.app.session.catalog.has_keyword("A Dock Parent|B Dock Child"));
assert_eq!(h.app.session.undo.len(), undo + 1);
}
#[test]
fn reset_during_held_divider_drag_keeps_replacement_layout_on_later_movement() {
let mut h = app();
let divider = egui::Id::new("lightcraft-docking").with(("split", Vec::<bool>::new()));
let from = h.view.ctx.read_response(divider).expect("the custom workspace root divider is rendered").rect.center();
let before = h.app.ui.docking.as_ref().unwrap().layout.clone();
h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.app.synthetic.push(Event::PointerMoved(from + egui::vec2(40.0, 0.0)));
h.step();
assert_ne!(h.app.ui.docking.as_ref().unwrap().layout, before, "fixture performs a real divider resize");
assert_eq!(h.view.ctx.dragged_id(), Some(divider));
let resized = h.app.ui.docking.as_ref().unwrap().layout.clone();
h.app.synthetic.push(Event::PointerMoved(from + egui::vec2(55.0, 0.0)));
h.step();
assert_ne!(h.app.ui.docking.as_ref().unwrap().layout, resized, "renderer-generated actions preserve continuous dragging");
let undo = h.app.session.undo.len();
h.app.run("ui.dock.reset", json!({})).unwrap();
let reset = serde_json::to_value(&h.app.ui.docking).unwrap();
h.app.synthetic.push(Event::PointerMoved(from + egui::vec2(90.0, 0.0)));
h.step();
assert_eq!(serde_json::to_value(&h.app.ui.docking).unwrap(), reset, "a held old divider cannot resize the replacement tree");
h.app.synthetic.push(Event::PointerButton {
pos: from + egui::vec2(90.0, 0.0),
button: PointerButton::Primary,
pressed: false,
modifiers: Modifiers::NONE,
});
h.step();
assert_eq!(serde_json::to_value(&h.app.ui.docking).unwrap(), reset);
assert_eq!(h.app.session.undo.len(), undo);
}
fn held_divider_replacement_route(route: &str) {
let mut h = app();
let divider = egui::Id::new("lightcraft-docking").with(("split", Vec::<bool>::new()));
let from = h.view.ctx.read_response(divider).unwrap().rect.center();
let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap();
h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.app.synthetic.push(Event::PointerMoved(from + egui::vec2(40.0, 0.0)));
h.step();
assert_eq!(h.view.ctx.dragged_id(), Some(divider));
let undo = h.app.session.undo.len();
match route {
"ui.set" => {
let response = h.request("ui.set", json!({"rightWidth":410.0}), std::time::Duration::from_secs(20));
assert_eq!(response["ok"], true, "{response}");
assert_eq!(h.app.ui.right_width, 410.0);
}
"panel.close" => {
h.app.run("panel.close", json!({})).unwrap();
h.step();
}
"public-action" => {
let action = craft_ui::docking::Action::Float { panel: Panel::Tools, rect: [800.0, 100.0, 330.0, 500.0] };
h.app.run("ui.dock", json!({"action":action})).unwrap();
h.step();
}
_ => panic!("unknown test route"),
}
let replacement = serde_json::to_value(&h.app.ui.docking).unwrap();
h.app.synthetic.push(Event::PointerMoved(from + egui::vec2(90.0, 0.0)));
h.step();
assert_eq!(serde_json::to_value(&h.app.ui.docking).unwrap(), replacement, "{route} cancels the previous divider gesture");
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::<Vec<_>>()).unwrap(), photos);
}
#[test]
fn actual_ui_set_width_replacement_cancels_held_divider() {
held_divider_replacement_route("ui.set");
}
#[test]
fn panel_close_sync_replacement_cancels_held_divider() {
held_divider_replacement_route("panel.close");
}
#[test]
fn externally_serialized_action_replacement_cancels_held_divider() {
held_divider_replacement_route("public-action");
}
+10
View File
@@ -143,6 +143,16 @@ fn mask_adjustments_hide_overlay_without_disabling_the_mask() {
// Changing the shape needs the overlay; changing the image needs an unobscured preview.
exec(&mut h, "mask.refine", json!({"value": 10.0}));
assert!(h.app.ui.mask_overlay);
// The dock header pushes this adjustment below the initial viewport. Exercise the
// actual visible slider rather than sending a synthetic click outside its clipped body.
let panel = h.app.widgets.iter().find(|(id, _)| id == "panel:right_panel").unwrap().1;
let slider = h.app.widgets.iter().find(|(id, _)| id == "slider:exposure").unwrap().1;
assert!(!panel.contains_rect(slider), "exposure initially needs scrolling: {slider:?} in {panel:?}");
h.request("ui.move", json!({"x": panel.center().x, "y": panel.center().y}), T);
h.request("ui.scroll", json!({"dy": -160.0}), T);
h.settle(SETTLE);
let slider = h.app.widgets.iter().find(|(id, _)| id == "slider:exposure").unwrap().1;
assert!(panel.contains_rect(slider), "exposure is visible before clicking: {slider:?} in {panel:?}");
let r = h.request("ui.clickWidget", json!({"id": "slider:exposure", "fx": 0.7}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(!h.app.ui.mask_overlay, "the exposure slider hides the overlay after release");
+17 -1
View File
@@ -194,7 +194,9 @@ impl<'a> TextField<'a> {
let right = ui.input(|i| i.pointer.button_down(egui::PointerButton::Secondary) || i.pointer.button_released(egui::PointerButton::Secondary));
let selection = egui::text_edit::TextEditState::load(ui.ctx(), id).and_then(|s| s.cursor.char_range());
let mut response = ui.add(edit);
if right && response.contains_pointer() {
// The menu may take focus on a primary-button release. egui collapses a
// TextEdit selection when focus leaves; retain the range our own menu acts on.
if right && response.contains_pointer() || memo.menu_open && !ui.memory(|m| m.has_focus(id)) {
let mut state = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default();
state.cursor.set_char_range(selection);
state.store(ui.ctx(), id);
@@ -514,6 +516,20 @@ mod tests {
assert_eq!(rig.text, "Kyoto");
}
#[test]
fn clicking_back_into_field_dismisses_its_menu_and_places_a_new_caret() {
let mut rig = Rig::new("Lisbon");
rig.select_all_by_keys();
rig.click(FIELD, PointerButton::Secondary);
let rect = rig.rect(FIELD).unwrap();
rig.click_at(egui::pos2(rect.left() + 2.0, rect.center().y), PointerButton::Primary);
let range = egui::text_edit::TextEditState::load(&rig.view.ctx, id(FIELD)).unwrap().cursor.char_range().unwrap();
assert!(range.is_empty(), "a click back in the field places a caret: {range:?}");
rig.type_text("X");
assert_eq!(rig.text, "XLisbon");
assert!(rig.endings.is_empty(), "dismissing into the field keeps the edit open");
}
/// With nothing selected, Cut and Copy are greyed out: clicking one does nothing, as in a native
/// menu, and neither closes the menu nor ends the edit.
#[test]
+5 -12
View File
@@ -237,18 +237,11 @@ pub fn apply(ctx: &egui::Context) {
v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(140) };
v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 30, spread: 0, color: Color32::from_black_alpha(160) };
let w = &mut v.widgets;
for (wv, fill) in [
(&mut w.noninteractive, t.chrome),
(&mut w.inactive, t.button),
(&mut w.hovered, t.hover),
(&mut w.active, t.pressed),
(&mut w.open, t.hover),
] {
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);
}
craft_ui::theme::WidgetPalette { noninteractive: t.chrome, inactive: t.button, hovered: t.hover, active: t.pressed, open: t.hover }.apply_to(
w,
CornerRadius::same(4),
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);
+126
View File
@@ -0,0 +1,126 @@
//! Real library/tool panel moves preserve the selected tool and saved arrangement.
use egui::{Event, Modifiers, PointerButton, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use lightcraft_ui_egui::{LightcraftApp, Services, UiState, docking::Panel, state::RightPanel};
use serde_json::json;
use std::time::{Duration, Instant};
fn harness(state: Option<UiState>, width: f32, scale: f32) -> Harness<'static, LightcraftApp> {
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
if let Some(state) = state {
app.ui = state;
} else {
app.ui.left_panel = true;
app.ui.right = RightPanel::Edit;
}
let mut h = Harness::builder().with_size(vec2(width, 900.0)).with_pixels_per_point(scale).build_ui_state(
|ui, app: &mut LightcraftApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
app,
);
h.run_steps(8);
h
}
fn drag(h: &mut Harness<'_, LightcraftApp>, from: egui::Pos2, to: egui::Pos2) {
h.event(Event::PointerMoved(from));
h.run_steps(2);
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(2);
for step in 1..=8 {
h.event(Event::PointerMoved(from.lerp(to, step as f32 / 8.0)));
h.run_steps(1);
}
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(4);
}
fn settle_photo(h: &mut Harness<'_, LightcraftApp>) {
let started = Instant::now();
let mut quiet = 0;
while quiet < 4 {
h.run_steps(2);
if h.state().renderer.in_flight() == 0 && h.state().loupe_shown.is_some_and(|(_, source)| source == "render") {
quiet += 1;
} else {
quiet = 0;
}
assert!(started.elapsed() < Duration::from_secs(30), "the synthetic photo and panel thumbnails must finish rendering before capture");
std::thread::sleep(Duration::from_millis(5));
}
}
#[test]
fn real_drag_float_close_reopen_and_state_reload_keep_photo_tool_mode() {
let mut h = harness(None, 1400.0, 1.0);
let source = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").rect().center();
let target = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Library").rect().center();
drag(&mut h, source, target);
assert_eq!(h.state().ui.docking.as_ref().unwrap().layout.location(&Panel::Tools).unwrap().anchor, Some(Panel::Library));
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").click_button(PointerButton::Secondary);
h.run_steps(3);
h.get_by_label("Float panel").click();
h.run_steps(5);
let rect = h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect;
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").click_button(PointerButton::Secondary);
h.run_steps(3);
h.get_by_label("Close panel").click();
h.run_steps(4);
assert_eq!(h.state().ui.right, RightPanel::None);
h.state_mut().ui.right = RightPanel::Crop;
h.run_steps(4);
assert_eq!(h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect, rect);
let saved = serde_json::to_string(&h.state().ui).unwrap();
let expected = serde_json::to_value(&h.state().ui.docking).unwrap();
let mut reloaded = harness(Some(serde_json::from_str(&saved).unwrap()), 1400.0, 1.0);
assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), expected);
assert_eq!(reloaded.state().ui.right, RightPanel::Crop);
reloaded.state_mut().run("ui.dock", json!({"operation":"move","panel":"tools","target":"library","zone":"center"})).unwrap();
reloaded.run_steps(4);
assert_eq!(reloaded.state().ui.right, RightPanel::Crop);
reloaded.state_mut().run("ui.dock", json!({"operation":"close","panel":"library"})).unwrap();
reloaded.run_steps(3);
assert!(!reloaded.state().ui.left_panel);
assert_eq!(reloaded.state().ui.right, RightPanel::Crop);
}
#[test]
fn invalid_docking_requests_preserve_layout_and_selected_tool() {
let mut h = harness(None, 1400.0, 1.0);
let before = serde_json::to_value(&h.state().ui.docking).unwrap();
for params in [
json!({"operation":"close","panel":"canvas"}),
json!({"operation":"move","panel":"tools","target":"canvas","zone":"center"}),
json!({"operation":"float","panel":"tools","rect":[0,0,-1,100]}),
json!({"operation":"move","panel":"unknown","target":"library","zone":"center"}),
] {
assert!(h.state_mut().run("ui.dock", params).is_err());
assert_eq!(serde_json::to_value(&h.state().ui.docking).unwrap(), before);
assert_eq!(h.state().ui.right, RightPanel::Edit);
}
}
#[test]
fn docking_renders_real_photo_panels_default_floating_and_redocked() {
for width in [900.0, 1400.0] {
for scale in [1.0, 2.0] {
let mut h = harness(None, width, scale);
for state in ["default", "floating", "redocked"] {
if state == "floating" {
h.state_mut().run("ui.dock", json!({"operation":"float","panel":"tools","rect":[120,90,320,600]})).unwrap();
} else if state == "redocked" {
h.state_mut().run("ui.dock", json!({"operation":"move","panel":"tools","target":"library","zone":"center"})).unwrap();
}
h.run_steps(8);
settle_photo(&mut h);
h.state().ui.docking.as_ref().unwrap().validate().unwrap();
if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") {
std::fs::create_dir_all(&directory).unwrap();
h.render().unwrap().save(format!("{directory}/light-{width}-{scale}-{state}.png")).unwrap();
}
}
}
}
}
+47
View File
@@ -22,6 +22,34 @@ that hit an error reply should reconnect. The port has no authentication, so onl
mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation:
`crates/ui-egui/src/control.rs` (methods) and `apps/lightcraft/src/control_server.rs` (transport).
## Isolated automation profiles
Set `LIGHTCRAFT_CONFIG_DIR` to a fresh directory to isolate UI preferences, app logs, the emergency
panic log, the GPU initialization marker, camera profiles and default model folders. The nonempty
value is used as the complete settings directory. Unset retains the normal platform paths; an explicit
empty value disables the settings directory, while the emergency panic log retains its system-temp path.
Explicit model-directory environment variables still take precedence over their defaults.
Also pass `--library DIR` with a separate scratch library: the configuration override does not change
the default photo-library location. Do not use `--memory` or `LIGHTCRAFT_NO_PREFS` when testing persistence.
A native acceptance run must reject the stderr message `rendering headlessly`: `ui.screenshot`
automatically uses the CPU fallback after two seconds without a presented frame. That fallback is useful
for headless scripts, but cannot establish native-window acceptance. Control input injects egui events;
it does not establish operating-system input delivery or screen-reader acceptance.
On macOS, the native menu owns its keyboard accelerators. `ui.key` injects egui key events and
does not synthesize the Cocoa menu events for those shortcuts. Use `ui.menu.invoke` with the
command ID (for example `edit.undo`, `edit.redo`, `view.filmstrip` or `view.leftPanel`) to test
their command behavior. That API invokes the app command dispatcher directly; it does not traverse
Cocoa menu dispatch. Native menu activation and keyboard accelerator delivery require a separate test.
Before capturing a finished Detail view, poll `ui.inspect` until the expected `loupe.photo` is active,
`loupe.source` is `render` or `cached`, `loupe.pending` is false, and the render queues are empty.
For the loupe, `cached` is a prior view render keyed by the current photo and develop settings;
`thumb`, `small` and `embedded` are separate fallback sources. A presented frame can otherwise
contain an in-progress rendering placeholder. Require loaded thumbnail textures only when the
filmstrip is visible.
## Methods
| Method | Params | Result |
@@ -127,3 +155,22 @@ one pixel.
renders are in flight. Each request runs frames until it is answered and its injected input
(clicks, keys, drags) has played out. Widget ids for `ui.clickWidget` come from `ui.widgets`
(e.g. `button:upright-auto`). A 1600×1000 demo snapshot takes ~1–2 s (debug build).
## Panel docking
Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents.
Panel IDs: `library`, `tools`, `presets`. The protected center is `canvas`.
`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tools","rect":[100,100,320,440]}}`.
Advanced `ui.dock` operations use the same validated transaction as pointer gestures:
`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and
`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`),
`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null.
A center `move` can include `before` (panel ID or null to append) for tab order.
The equivalent `{"action": ...}` form accepts a serialized shared docking action;
this is also the form emitted by the renderer. Protected-canvas and app-specific panel
rules apply equally to both forms.
+2 -2
View File
@@ -90,7 +90,7 @@ fn intra_layer_allowed(from: &str, to: &str) -> bool {
/// External crates that constitute a UI toolkit / windowing dependency.
/// Entries ending in `*` are prefixes.
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"];
pub const UI_CRATES: &[&str] = &["craft-ui", "egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"];
/// First layer allowed to use UI crates.
pub const UI_MIN_LAYER: u8 = 5;
@@ -291,7 +291,7 @@ mod tests {
#[test]
fn ui_crates_forbidden_below_l5() {
for dep in ["egui", "eframe", "winit", "rfd"] {
for dep in ["craft-ui", "egui", "eframe", "winit", "rfd"] {
let v = check(&[c("lightcraft-engine", &[(dep, Normal, false)])]);
assert!(matches!(v[..], [Violation::UiBelowL5 { .. }]), "{dep}");
}