mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
UI.9: integrate shared docking and preserve panel interactions
This commit is contained in:
1 parent
b70ae1741e
commit
9bfabc98b1
37 files changed
+2003
-324
No files matched your search
Generated
+103
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
[](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.
|
||||
@@ -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.
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "復元",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Восстановить",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "恢复",
|
||||
|
||||
@@ -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": "還原",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ¶ms) {
|
||||
if r.is_ok() {
|
||||
docking::reveal_command(self, id)?;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
let r = self.session.execute(id, ¶ms).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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user