mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 02:06:54 -06:00
Merge atomic-save: MCP: exported files replace the target atomically (#436)
Conflict: ROADMAP.md: both progress-log entries, next's newer ones first Conflict: apps/designcraft/src/gpu.rs: next's side; the branch's test fix targets the startup record tests next replaced Conflict: apps/designcraft/src/main.rs: next's gpu startup and macOS IME helper; config_dir() dropped for the branch's profile::config_dir(), recovery_dir from profile::recovery_dir() Conflict: crates/ui-egui/src/canvas.rs: next's guard (menu_open, keyboard_pane already limits keys to the focused pane) Conflict: crates/ui-egui/src/lib.rs: modules from both sides
This commit is contained in:
Generated
+10
@@ -808,6 +808,15 @@ dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -1143,6 +1152,7 @@ dependencies = [
|
||||
name = "designcraft-ui-egui"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
"designcraft-doc",
|
||||
|
||||
+3
-1
@@ -7,7 +7,7 @@ default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtas
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/designcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -70,6 +70,8 @@ zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
indd = { git = "https://github.com/aiongg/indd-utils", branch = "main" }
|
||||
|
||||
# ui
|
||||
# Shared frontend components; keep this dependency above the engine boundary.
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
|
||||
@@ -98,6 +98,8 @@ by its own renderer. Try it yourself with `File → New → Sample Document`, or
|
||||
|
||||
## Quick start
|
||||
|
||||
The egui frontend requires Rust 1.95 or newer (including its shared `craft-ui` components).
|
||||
|
||||
```sh
|
||||
cargo run --release -p designcraft # desktop app (start screen)
|
||||
cargo run --release -p designcraft -- --sample # open the sample magazine
|
||||
|
||||
@@ -139,6 +139,7 @@ Newest first.
|
||||
start page and per-edge bleed and slug.
|
||||
- **2026-10-11** · macOS: fonts that font services and managers activate (Adobe Fonts,
|
||||
RightFont) are listed, read from the files CoreText has available (#261, #327).
|
||||
- **2026-10-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change.
|
||||
- **2026-10-10** · Version 0.6.0; releases add a Linux riscv64 tar.gz (cross-compiled, CLI
|
||||
smoke-tested under QEMU).
|
||||
- **2026-10-10** · Readiness per audience: full ~47% (method aligned with the standard, was
|
||||
|
||||
@@ -3,6 +3,7 @@ name = "designcraft-web"
|
||||
description = "DesignCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
name = "designcraft"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
default-run = "designcraft"
|
||||
|
||||
@@ -20,6 +20,7 @@ mod gpu;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod profile;
|
||||
|
||||
use designcraft_engine::Session;
|
||||
use designcraft_ui_egui::{DesignApp, Services};
|
||||
@@ -93,27 +94,13 @@ fn discard_marked_text() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-user settings directory: `ui.json`, `prefs.json`, `gpu.json` and `logs/` live here.
|
||||
fn config_dir() -> Option<std::path::PathBuf> {
|
||||
if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/DesignCraft"))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("DesignCraft"))
|
||||
} else {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join("designcraft"))
|
||||
}
|
||||
}
|
||||
|
||||
/// `DESIGNCRAFT_NO_PREFS`: read and write nothing in the settings directory (tests, demos).
|
||||
fn no_prefs() -> bool {
|
||||
std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some()
|
||||
}
|
||||
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
config_dir().map(|b| b.join("ui.json"))
|
||||
profile::config_dir().map(|b| b.join("ui.json"))
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut DesignApp) {
|
||||
@@ -122,7 +109,7 @@ fn load_prefs(app: &mut DesignApp) {
|
||||
}
|
||||
if let Some(p) = prefs_path()
|
||||
&& let Ok(bytes) = std::fs::read(&p)
|
||||
&& let Ok(ui) = serde_json::from_slice::<designcraft_ui_egui::UiState>(&bytes)
|
||||
&& let Ok(ui) = designcraft_ui_egui::UiState::from_preferences_json(&bytes)
|
||||
{
|
||||
app.ui = ui;
|
||||
}
|
||||
@@ -337,7 +324,7 @@ fn main() -> std::process::ExitCode {
|
||||
}
|
||||
// The log file lives under the settings directory; opened after the arguments, so `--version`
|
||||
// leaves no file behind. Records logged until now are written to it first.
|
||||
if let (Some(logger), Some(dir)) = (logger, config_dir()) {
|
||||
if let (Some(logger), Some(dir)) = (logger, profile::config_dir()) {
|
||||
match logger.attach_dir(&dir.join("logs")) {
|
||||
Ok(path) => log::info!("DesignCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
|
||||
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
|
||||
@@ -362,7 +349,7 @@ fn main() -> std::process::ExitCode {
|
||||
// The graphics backend, decided before the window exists: a driver that faults takes the
|
||||
// process down before any Rust code can catch it (see `gpu::backend`).
|
||||
let fallback = gpu::backend::Fallback::begin(
|
||||
if no_prefs() { None } else { config_dir().map(|d| d.join(gpu::backend::FILE)) },
|
||||
if no_prefs() { None } else { profile::config_dir().map(|d| d.join(gpu::backend::FILE)) },
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
&gpu::backend::skipped_backends(&skip),
|
||||
);
|
||||
@@ -441,7 +428,7 @@ fn main() -> std::process::ExitCode {
|
||||
}
|
||||
let mut session = Session::new();
|
||||
// Crash recovery: reopen what a previous run left unsaved, then keep it current.
|
||||
session.recovery_dir = designcraft_engine::recovery::default_dir();
|
||||
session.recovery_dir = profile::recovery_dir();
|
||||
let recovered = session.execute("file.recovery.open", &serde_json::json!({})).ok();
|
||||
let mut app = DesignApp::new(session, services());
|
||||
if let Some(line) = restarted.or_else(|| created.status_line()) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Native settings and recovery paths, including isolated automation profiles.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
config_dir_with(std::env::consts::OS, |key| std::env::var_os(key))
|
||||
}
|
||||
|
||||
fn config_dir_with(os: &str, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
if let Some(root) = env("DESIGNCRAFT_CONFIG_DIR") {
|
||||
// An explicitly empty override must not fall back to the user's profile.
|
||||
return (!root.is_empty()).then(|| PathBuf::from(root));
|
||||
}
|
||||
match os {
|
||||
"macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support/DesignCraft")),
|
||||
"windows" => env("APPDATA").map(|p| PathBuf::from(p).join("DesignCraft")),
|
||||
_ => {
|
||||
env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join("designcraft"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn recovery_dir() -> Option<PathBuf> {
|
||||
recovery_dir_with(std::env::var_os("DESIGNCRAFT_CONFIG_DIR"), designcraft_engine::recovery::default_dir)
|
||||
}
|
||||
|
||||
fn recovery_dir_with(root: Option<OsString>, default: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> {
|
||||
match root {
|
||||
Some(root) => (!root.is_empty()).then(|| PathBuf::from(root).join("Recovery")),
|
||||
None => default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn environment(key: &str) -> Option<OsString> {
|
||||
match key {
|
||||
"HOME" => Some("/user".into()),
|
||||
"APPDATA" => Some("/roaming".into()),
|
||||
"XDG_CONFIG_HOME" => Some("/xdg-config".into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_profile_overrides_every_platform_and_recovery() {
|
||||
for os in ["macos", "windows", "linux"] {
|
||||
let dir = config_dir_with(os, |key| if key == "DESIGNCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) });
|
||||
assert_eq!(dir, Some(PathBuf::from("/isolated")));
|
||||
}
|
||||
assert_eq!(recovery_dir_with(Some("/isolated".into()), || Some("/user/recovery".into())), Some(PathBuf::from("/isolated/Recovery")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_override_never_reads_or_writes_the_default_profile() {
|
||||
assert_eq!(config_dir_with("macos", |key| { if key == "DESIGNCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) } }), None);
|
||||
assert_eq!(recovery_dir_with(Some(OsString::new()), || Some("/user/recovery".into())), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_override_preserves_existing_platform_paths() {
|
||||
assert_eq!(config_dir_with("macos", environment), Some("/user/Library/Application Support/DesignCraft".into()));
|
||||
assert_eq!(config_dir_with("windows", environment), Some("/roaming/DesignCraft".into()));
|
||||
assert_eq!(config_dir_with("linux", environment), Some("/xdg-config/designcraft".into()));
|
||||
assert_eq!(config_dir_with("linux", |key| if key == "HOME" { environment(key) } else { None }), Some("/user/.config/designcraft".into()));
|
||||
assert_eq!(recovery_dir_with(None, || Some("/existing/recovery".into())), Some("/existing/recovery".into()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Crash-safe file replacement: how documents, exports and other user files reach the disk.
|
||||
//!
|
||||
//! [`write_atomic`] never writes the destination in place. It writes a uniquely named temporary
|
||||
//! file in the same folder, flushes it (`sync_all`), renames it over the destination and then
|
||||
//! flushes the folder (Unix) so the rename survives a power loss. When any step before the rename
|
||||
//! fails, the temporary file is removed and the destination keeps its old bytes.
|
||||
//!
|
||||
//! - A replaced file keeps its permissions (Unix mode).
|
||||
//! - A symbolic link keeps pointing at its file: the file it names is replaced.
|
||||
//! - Windows: antivirus scanners and the search indexer briefly hold files open, which makes the
|
||||
//! replacing rename fail with `PermissionDenied`; the rename is retried with a short backoff.
|
||||
//! - A failed rename (cross-device, unwritable folder) is returned as an error; there is no
|
||||
//! fallback to writing in place.
|
||||
//!
|
||||
//! The web has no file system, so this module is not built for `wasm32`.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Write as _};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// How many times a replacing rename is tried before its error is returned.
|
||||
const RENAME_TRIES: u32 = if cfg!(windows) { 5 } else { 1 };
|
||||
/// The pause before the first rename retry; it doubles up to [`RENAME_MAX_DELAY_MS`].
|
||||
const RENAME_FIRST_DELAY_MS: u64 = 10;
|
||||
const RENAME_MAX_DELAY_MS: u64 = 200;
|
||||
/// Symbolic links followed before giving up (a loop, or an absurd chain).
|
||||
const MAX_LINKS: usize = 40;
|
||||
|
||||
/// Atomically replace (or create) `path` with `bytes`. See the module docs.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
write_atomic_with(path, |f| f.write_all(bytes))
|
||||
}
|
||||
|
||||
/// [`write_atomic`] for a command's `path` parameter: the error names the path.
|
||||
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> crate::Result<()> {
|
||||
write_atomic(Path::new(path), bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
/// Atomically replace (or create) `path` with what `fill` writes into the temporary file. When
|
||||
/// `fill`, the flush or the rename fails, the temporary file is removed, `path` is untouched and
|
||||
/// the error is returned.
|
||||
pub fn write_atomic_with(path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
|
||||
let target = resolve_links(path)?;
|
||||
let name = target
|
||||
.file_name()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let dir = match target.parent() {
|
||||
Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
|
||||
_ => PathBuf::from("."),
|
||||
};
|
||||
let existing = std::fs::metadata(&target).ok();
|
||||
if existing.is_some() {
|
||||
check_writable(&target)?;
|
||||
}
|
||||
let (tmp, mut file) = create_temp(&dir, &name)?;
|
||||
let written = fill(&mut file).and_then(|()| file.sync_all()).and_then(|()| existing.map_or(Ok(()), |m| keep_permissions(&file, &m)));
|
||||
drop(file);
|
||||
let placed = written.and_then(|()| retry_rename(RENAME_TRIES, || std::fs::rename(&tmp, &target)));
|
||||
if let Err(e) = placed {
|
||||
// Best effort: the temporary file is all there is to clean up.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
// The new bytes are in place. A failed folder flush can't be undone and must not report the
|
||||
// save as failed.
|
||||
let _ = sync_dir(&dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a fresh temporary file beside the target (the same folder, so the rename stays on one
|
||||
/// volume). Names are unique per process and call; a leftover of the same name is skipped.
|
||||
fn create_temp(dir: &Path, name: &str) -> io::Result<(PathBuf, File)> {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let mut last = None;
|
||||
for _ in 0..8 {
|
||||
let n = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
let tmp = dir.join(format!(".{name}.{}-{n}.tmp", std::process::id()));
|
||||
match OpenOptions::new().write(true).create_new(true).open(&tmp) {
|
||||
Ok(f) => return Ok((tmp, f)),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => last = Some(e),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(last.unwrap_or_else(|| io::Error::from(io::ErrorKind::AlreadyExists)))
|
||||
}
|
||||
|
||||
/// The file a chain of symbolic links ends at (`path` itself when it is not a link). A dangling
|
||||
/// link resolves to the missing file it names, which is then created.
|
||||
fn resolve_links(path: &Path) -> io::Result<PathBuf> {
|
||||
let mut p = path.to_path_buf();
|
||||
for _ in 0..MAX_LINKS {
|
||||
match std::fs::symlink_metadata(&p) {
|
||||
Ok(m) if m.file_type().is_symlink() => {
|
||||
let to = std::fs::read_link(&p)?;
|
||||
p = match p.parent() {
|
||||
Some(dir) if to.is_relative() => dir.join(to),
|
||||
_ => to,
|
||||
};
|
||||
}
|
||||
_ => return Ok(p),
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{}: too many levels of symbolic links", path.display())))
|
||||
}
|
||||
|
||||
/// A rename replaces a file the user can't write (Unix only needs the folder to be writable);
|
||||
/// refuse, as writing it in place would. Opening for writing without truncating changes nothing.
|
||||
#[cfg(unix)]
|
||||
fn check_writable(target: &Path) -> io::Result<()> {
|
||||
OpenOptions::new().write(true).open(target).map(drop)
|
||||
}
|
||||
|
||||
/// Windows refuses to rename over a read-only file by itself.
|
||||
#[cfg(not(unix))]
|
||||
fn check_writable(_: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The replacement keeps the replaced file's mode (Unix; Windows files keep only a read-only flag,
|
||||
/// and a read-only file can't be replaced).
|
||||
#[cfg(unix)]
|
||||
fn keep_permissions(tmp: &File, replaced: &std::fs::Metadata) -> io::Result<()> {
|
||||
tmp.set_permissions(replaced.permissions())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn keep_permissions(_: &File, _: &std::fs::Metadata) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `rename` up to `tries` times, pausing with a doubling delay while it fails with
|
||||
/// `PermissionDenied` (a scanner holding the file on Windows). Other errors return at once.
|
||||
fn retry_rename(tries: u32, mut rename: impl FnMut() -> io::Result<()>) -> io::Result<()> {
|
||||
let mut delay = RENAME_FIRST_DELAY_MS;
|
||||
let mut left = tries.max(1);
|
||||
loop {
|
||||
left = left.saturating_sub(1);
|
||||
match rename() {
|
||||
Err(e) if e.kind() == io::ErrorKind::PermissionDenied && left > 0 => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(delay));
|
||||
delay = delay.saturating_mul(2).min(RENAME_MAX_DELAY_MS);
|
||||
}
|
||||
r => return r,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush a folder's entries (Unix); Windows can't open a folder as a file, and needs no flush.
|
||||
#[cfg(unix)]
|
||||
fn sync_dir(dir: &Path) -> io::Result<()> {
|
||||
File::open(dir)?.sync_all()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn sync_dir(_: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("dc-atomic-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// The names in `d`, sorted.
|
||||
fn names(d: &Path) -> Vec<String> {
|
||||
let mut v: Vec<String> = std::fs::read_dir(d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_and_creates_without_leftovers() {
|
||||
let d = dir("replace");
|
||||
let path = d.join("doc.designcraft");
|
||||
write_atomic(&path, b"first").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"first");
|
||||
write_atomic(&path, b"second, longer").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"second, longer");
|
||||
write_atomic(&path, b"3").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"3");
|
||||
assert_eq!(names(&d), ["doc.designcraft"], "no temporary file is left");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_leaves_the_original_and_no_temporary_file() {
|
||||
let d = dir("fail");
|
||||
let path = d.join("doc.designcraft");
|
||||
std::fs::write(&path, b"original").unwrap();
|
||||
let r = write_atomic_with(&path, |f| {
|
||||
f.write_all(b"half a docu")?;
|
||||
Err(io::Error::other("disk full"))
|
||||
});
|
||||
assert_eq!(r.unwrap_err().to_string(), "disk full");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"original");
|
||||
assert_eq!(names(&d), ["doc.designcraft"], "the temporary file is removed");
|
||||
// A folder that doesn't exist: an error, nothing written.
|
||||
assert!(write_atomic(&d.join("missing").join("x.designcraft"), b"x").is_err());
|
||||
assert_eq!(names(&d), ["doc.designcraft"]);
|
||||
// A folder in the way: an error, the folder stays and nothing is left beside it.
|
||||
std::fs::create_dir(d.join("folder")).unwrap();
|
||||
assert!(write_atomic(&d.join("folder"), b"x").is_err());
|
||||
assert_eq!(names(&d), ["doc.designcraft", "folder"]);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rename_is_retried_only_while_access_is_denied() {
|
||||
let mut calls = 0;
|
||||
let r = retry_rename(3, || {
|
||||
calls += 1;
|
||||
if calls < 3 { Err(io::Error::from(io::ErrorKind::PermissionDenied)) } else { Ok(()) }
|
||||
});
|
||||
assert!(r.is_ok() && calls == 3);
|
||||
calls = 0;
|
||||
let r = retry_rename(3, || {
|
||||
calls += 1;
|
||||
Err(io::Error::from(io::ErrorKind::PermissionDenied))
|
||||
});
|
||||
assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::PermissionDenied));
|
||||
assert_eq!(calls, 3, "gives up after the last try");
|
||||
calls = 0;
|
||||
let r = retry_rename(3, || {
|
||||
calls += 1;
|
||||
Err(io::Error::from(io::ErrorKind::CrossesDevices))
|
||||
});
|
||||
assert!(r.is_err() && calls == 1, "other errors are not retried");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn permissions_are_kept_and_unwritable_files_are_not_replaced() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let d = dir("perms");
|
||||
let path = d.join("private.designcraft");
|
||||
std::fs::write(&path, b"old").unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
write_atomic(&path, b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
// A read-only file stays as it is (root may write it anyway).
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap();
|
||||
if OpenOptions::new().write(true).open(&path).is_err() {
|
||||
assert!(write_atomic(&path, b"newer").is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
assert_eq!(names(&d), ["private.designcraft"]);
|
||||
}
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A folder the user can't write: the save fails and the original stays.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_unwritable_folder_fails_without_touching_the_file() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let d = dir("ro-dir");
|
||||
let path = d.join("doc.designcraft");
|
||||
std::fs::write(&path, b"original").unwrap();
|
||||
std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o555)).unwrap();
|
||||
// Root writes anyway; the check only means something for an ordinary user.
|
||||
let probe = d.join("probe");
|
||||
let unwritable = std::fs::write(&probe, b"").is_err();
|
||||
if unwritable {
|
||||
assert!(write_atomic(&path, b"new").is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"original");
|
||||
}
|
||||
std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
assert_eq!(names(&d), ["doc.designcraft"]);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_symbolic_link_keeps_pointing_at_the_replaced_file() {
|
||||
let d = dir("link");
|
||||
std::fs::create_dir(d.join("real")).unwrap();
|
||||
let target = d.join("real").join("doc.designcraft");
|
||||
std::fs::write(&target, b"old").unwrap();
|
||||
let link = d.join("link.designcraft");
|
||||
std::os::unix::fs::symlink("real/doc.designcraft", &link).unwrap();
|
||||
write_atomic(&link, b"new").unwrap();
|
||||
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"new");
|
||||
assert_eq!(names(&d), ["link.designcraft", "real"]);
|
||||
assert_eq!(names(&d.join("real")), ["doc.designcraft"]);
|
||||
// A loop of links is an error, not a hang.
|
||||
std::os::unix::fs::symlink("b", d.join("a")).unwrap();
|
||||
std::os::unix::fs::symlink("a", d.join("b")).unwrap();
|
||||
assert!(write_atomic(&d.join("a"), b"x").is_err());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ fn no_book() -> EngineError {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn save(b: &Book) -> Result<()> {
|
||||
let bytes = serde_json::to_vec_pretty(b).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
std::fs::write(&b.path, bytes).map_err(|e| EngineError::Other(format!("{}: {e}", b.path)))
|
||||
crate::atomic::write_file(&b.path, &bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -54,7 +54,7 @@ fn load(path: &str) -> Result<designcraft_doc::Document> {
|
||||
|
||||
fn store(path: &str, d: &designcraft_doc::Document) -> Result<()> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return std::fs::write(path, super::file::to_bytes(d)).map_err(|e| EngineError::Other(format!("{path}: {e}")));
|
||||
return crate::atomic::write_file(path, &super::file::to_bytes(d));
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (path, d);
|
||||
@@ -221,7 +221,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages}))
|
||||
}
|
||||
None => Ok(json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages})),
|
||||
|
||||
@@ -45,7 +45,7 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, &r.bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(path, &r.bytes)?;
|
||||
Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings}))
|
||||
}
|
||||
None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})),
|
||||
@@ -60,7 +60,7 @@ fn export_html(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(path, text.as_bytes())?;
|
||||
Ok(json!({"path": path, "bytes": text.len()}))
|
||||
}
|
||||
None => Ok(json!({"bytes": text.len(), "text": text})),
|
||||
@@ -92,7 +92,7 @@ fn export_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match path {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(path, text.as_bytes())?;
|
||||
Ok(json!({"path": path, "bytes": text.len()}))
|
||||
}
|
||||
None => Ok(json!({"bytes": text.len(), "text": text})),
|
||||
@@ -161,7 +161,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match p.get("path").and_then(Value::as_str) {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, &bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(path, &bytes)?;
|
||||
Ok(serde_json::json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})),
|
||||
@@ -647,7 +647,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
crate::atomic::write_file(path, bytes)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
|
||||
|
||||
@@ -49,7 +49,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let path = with_document_extension(path);
|
||||
let bytes = to_bytes(&s.doc()?.doc);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}),
|
||||
cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert),
|
||||
@@ -357,7 +357,7 @@ fn file_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let bytes = to_bytes(&st.doc);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(&path, &bytes)?;
|
||||
st.path = Some(path.clone());
|
||||
st.saved_revision = st.revision;
|
||||
st.saved_doc = st.doc.clone();
|
||||
@@ -656,7 +656,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)}))
|
||||
}
|
||||
None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})),
|
||||
@@ -970,6 +970,37 @@ mod layout_place_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod save_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Saving over an existing document replaces it whole and leaves nothing beside it.
|
||||
#[test]
|
||||
fn save_replaces_the_existing_file_without_leftovers() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-save-atomic-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("doc.designcraft");
|
||||
std::fs::write(&path, vec![b'x'; 1 << 20]).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Saved whole", "caret": false})).unwrap();
|
||||
s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
s.execute("file.saveACopy", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let saved = super::from_bytes(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert!(saved.stories.values().any(|st| st.text == "Saved whole"));
|
||||
let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||
assert_eq!(names, ["doc.designcraft"], "no temporary file is left");
|
||||
// A folder that doesn't exist: an error naming the path.
|
||||
let missing = dir.join("missing").join("doc.designcraft");
|
||||
let e = s.execute("file.save", &json!({"path": missing.to_string_lossy()})).unwrap_err();
|
||||
assert!(e.to_string().contains("missing"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod document_fonts_tests {
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -26,7 +26,7 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})),
|
||||
|
||||
@@ -29,7 +29,7 @@ impl Library {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(p) = &self.path {
|
||||
let bytes = serde_json::to_vec_pretty(self).map_err(|e| crate::EngineError::Other(e.to_string()))?;
|
||||
std::fs::write(p, bytes).map_err(|e| crate::EngineError::Other(format!("{p}: {e}")))?;
|
||||
crate::atomic::write_file(p, &bytes)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -944,7 +944,11 @@ fn save_swatches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bytes = designcraft_color::ase::write(&chosen);
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &bytes).map_err(|e| bad("swatch.save", format!("{path}: {e}")))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let written = crate::atomic::write_atomic(std::path::Path::new(path), &bytes);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let written = std::fs::write(path, &bytes);
|
||||
written.map_err(|e| bad("swatch.save", format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
None => Ok(json!({"base64": super::file::base64_encode(&bytes)})),
|
||||
|
||||
@@ -1042,7 +1042,7 @@ fn distribute_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
for key in ["row", "story", "table"] {
|
||||
if let Some(v) = p.get(key)
|
||||
&& !v.as_u64().is_some_and(|n| usize::try_from(n).is_ok())
|
||||
&& v.as_u64().is_none_or(|n| usize::try_from(n).is_err())
|
||||
{
|
||||
return Err(bad(ID, format!("{key} must be a non-negative integer")));
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::fs::write(path, text.as_bytes()).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::atomic::write_file(path, text.as_bytes())?;
|
||||
Ok(json!({"path": path, "bytes": text.len()}))
|
||||
}
|
||||
None => Ok(json!({"text": text, "bytes": text.len()})),
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod atomic;
|
||||
pub mod cmd;
|
||||
pub mod dtd;
|
||||
pub mod guard;
|
||||
|
||||
@@ -228,7 +228,7 @@ impl Headless {
|
||||
let png = img.to_png();
|
||||
match s(p, "path") {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &png).map_err(|e| format!("write {path}: {e}"))?;
|
||||
designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?;
|
||||
Ok(json!({"path": path, "width": img.width, "height": img.height}))
|
||||
}
|
||||
None => Ok(json!({"width": img.width, "height": img.height, "pngBase64": designcraft_engine::cmd::base64_encode(&png)})),
|
||||
@@ -248,7 +248,7 @@ impl Headless {
|
||||
.ok_or_else(|| format!("no page {page}"))?;
|
||||
let lower = path.to_ascii_lowercase();
|
||||
let bytes = if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() };
|
||||
std::fs::write(path, &bytes).map_err(|e| format!("write {path}: {e}"))?;
|
||||
designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| format!("write {path}: {e}"))?;
|
||||
Ok(json!({"path": path, "width": img.width, "height": img.height}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,7 +499,7 @@ fn render_page(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||||
let path = a.get("path").and_then(Value::as_str);
|
||||
if let Some(path) = path {
|
||||
let png = designcraft_engine::cmd::base64_decode(&b64);
|
||||
std::fs::write(path, png).map_err(|e| format!("write {path}: {e}"))?;
|
||||
designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?;
|
||||
}
|
||||
Ok(ToolResult::image(b64, &json!({"width": r.get("width"), "height": r.get("height"), "path": path})))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
name = "designcraft-ui-egui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
description = "DesignCraft egui frontend (InDesign-style UI); swappable — nothing below depends on it"
|
||||
|
||||
@@ -9,6 +10,7 @@ description = "DesignCraft egui frontend (InDesign-style UI); swappable — noth
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-ui = { workspace = true }
|
||||
designcraft-geom = { workspace = true }
|
||||
designcraft-color = { workspace = true }
|
||||
designcraft-doc = { workspace = true }
|
||||
|
||||
@@ -307,7 +307,7 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest
|
||||
app.ui.control_bar = v;
|
||||
}
|
||||
if p.get("closePanel").is_some() {
|
||||
app.ui.open_panel = None;
|
||||
r = crate::panel_docking::close_visible(app).map(|_| Value::Null);
|
||||
}
|
||||
if let Some(v) = p.get("dockExpanded").and_then(Value::as_bool) {
|
||||
app.ui.dock_expanded = v;
|
||||
|
||||
+125
-7
@@ -57,6 +57,9 @@ pub fn panel_label(id: &str) -> &'static str {
|
||||
}
|
||||
|
||||
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
if crate::panel_docking::show(app, ui) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// Expanded panel stack (outermost, 253 pt) — declared first so it sits at the far right.
|
||||
if app.ui.dock_expanded {
|
||||
@@ -74,7 +77,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
ui.ctx(),
|
||||
crate::i18n::tr(&app.ui.language, label),
|
||||
semibold(11.5),
|
||||
if active { Color32::from_rgb(0xf3, 0xf3, 0xf3) } else { t.text_dim },
|
||||
if active { t.text_strong } else { t.text_dim },
|
||||
);
|
||||
let w = g.size().x + 26.0;
|
||||
let r = egui::Rect::from_min_size(egui::pos2(x, strip.min.y), vec2(w, 27.0));
|
||||
@@ -83,9 +86,15 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
ui.painter().galley(egui::pos2(r.min.x + 13.0, r.center().y - g.size().y / 2.0), g, Color32::WHITE);
|
||||
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
|
||||
if ui.interact(r, ui.id().with(("docktab", *id)), Sense::click()).clicked() {
|
||||
let response = craft_ui::tabs::Tab::new(ui.id().with(("docktab", *id)), label, active).sense(Sense::click_and_drag()).show_at(
|
||||
ui,
|
||||
r,
|
||||
|_, _| {},
|
||||
);
|
||||
if response.clicked() {
|
||||
app.ui.dock_tab = id.to_string();
|
||||
}
|
||||
crate::panel_docking::legacy_tab(app, ui, id, &response);
|
||||
x += w;
|
||||
}
|
||||
let menu_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 24.0, strip.min.y + 4.0), vec2(18.0, 18.0));
|
||||
@@ -108,13 +117,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
for (id, label, icon) in ICON_PANELS {
|
||||
let open = app.ui.open_panel.as_deref() == Some(*id);
|
||||
let floats = app.ui.floating.iter().any(|(p, _)| p == id);
|
||||
if icons::button(ui, icon, 28.0, open || floats, crate::i18n::tr(&app.ui.language, label)).clicked() {
|
||||
let response =
|
||||
icons::button(ui, icon, 28.0, open || floats, crate::i18n::tr(&app.ui.language, label)).interact(Sense::click_and_drag());
|
||||
if response.clicked() {
|
||||
if floats {
|
||||
ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating_panel", *id))));
|
||||
} else {
|
||||
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
|
||||
}
|
||||
}
|
||||
crate::panel_docking::legacy_tab(app, ui, id, &response);
|
||||
ui.add_space(1.0);
|
||||
}
|
||||
});
|
||||
@@ -131,6 +143,9 @@ fn dock_header(ui: &mut egui::Ui, t: &Tokens, chevron: &str) {
|
||||
|
||||
/// A panel opened from the icon column, shown as a floating flyout next to the dock.
|
||||
pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
if app.ui.docking.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(id) = app.ui.open_panel.clone() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let label = crate::i18n::tr(&app.ui.language, panel_label(&id)).to_owned();
|
||||
@@ -178,6 +193,9 @@ pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
/// The contents of an icon-column panel.
|
||||
pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) {
|
||||
match id {
|
||||
"properties" => panels::properties::show(app, ui),
|
||||
"pages" => panels::pages::show(app, ui),
|
||||
"layers" => panels::layers::show(app, ui),
|
||||
"swatches" => panels::swatches::show(app, ui),
|
||||
"paragraphStyles" => panels::styles::paragraph(app, ui),
|
||||
"characterStyles" => panels::styles::character(app, ui),
|
||||
@@ -218,20 +236,27 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) {
|
||||
|
||||
/// Tear panel `id` off the dock into a floating window at `at`.
|
||||
pub fn float_panel(app: &mut DesignApp, id: &str, at: egui::Pos2) {
|
||||
if app.ui.open_panel.as_deref() == Some(id) {
|
||||
app.ui.open_panel = None;
|
||||
if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.float", &serde_json::json!({"panel":id,"x":at.x,"y":at.y})) {
|
||||
app.status(error);
|
||||
}
|
||||
app.ui.floating.retain(|(p, _)| p != id);
|
||||
app.ui.floating.push((id.to_string(), [at.x, at.y]));
|
||||
}
|
||||
|
||||
/// Put a floating panel back in the dock's icon column.
|
||||
pub fn dock_panel(app: &mut DesignApp, id: &str) {
|
||||
if app.ui.docking.is_some() {
|
||||
if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.dock", &serde_json::json!({"panel":id})) {
|
||||
app.status(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
app.ui.floating.retain(|(p, _)| p != id);
|
||||
}
|
||||
|
||||
/// Floating panels: movable, resizable windows; "Dock" returns one to the icon column.
|
||||
pub fn floating(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
if app.ui.docking.is_some() {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
for (id, at) in app.ui.floating.clone() {
|
||||
let label = crate::i18n::tr(&app.ui.language, panel_label(&id)).to_owned();
|
||||
@@ -310,3 +335,96 @@ fn dock_glyph(p: &egui::Painter, r: egui::Rect, c: Color32) {
|
||||
p.rect_stroke(b, 0.0, Stroke::new(1.0, c), egui::StrokeKind::Middle);
|
||||
p.rect_filled(egui::Rect::from_min_max(egui::pos2(b.max.x - 3.5, b.min.y), b.max), 0.0, c);
|
||||
}
|
||||
|
||||
#[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(egui::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
|
||||
}
|
||||
|
||||
/// Opt-in evidence for native screenshot review; ordinary test runs stay headless and fast.
|
||||
#[test]
|
||||
fn capture_dock_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 brightness in crate::theme::Brightness::ALL {
|
||||
for width in [800.0, 1280.0] {
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
|
||||
app.ui.dock_expanded = true;
|
||||
app.ui.dock_tab = "properties".into();
|
||||
let mut ready = false;
|
||||
let mut harness =
|
||||
egui_kittest::Harness::builder().with_size(vec2(width, 640.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
|
||||
move |ui, app: &mut DesignApp| {
|
||||
if !ready {
|
||||
crate::theme::install_fonts(ui.ctx(), "en");
|
||||
crate::theme::apply(ui.ctx(), &Tokens::for_brightness(brightness));
|
||||
ready = true;
|
||||
return;
|
||||
}
|
||||
show(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.input_mut().max_texture_side = Some(8192);
|
||||
harness.run_steps(4);
|
||||
let stem = format!("designcraft-dock-{}-{width}-{scale}x", brightness.id());
|
||||
harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap();
|
||||
let panel = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new("dock")).unwrap().outer_rect;
|
||||
let region = serde_json::json!({
|
||||
"screen": [width, 640.0], "scale": scale,
|
||||
"regions": {"dock-tabs": [panel.left(), panel.top(), panel.width(), 45.0]}
|
||||
});
|
||||
std::fs::write(directory.join(format!("{stem}.json")), serde_json::to_vec_pretty(®ion).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn luminance(c: Color32) -> f32 {
|
||||
let linear = |v: u8| {
|
||||
let v = f32::from(v) / 255.0;
|
||||
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
};
|
||||
0.2126 * linear(c.r()) + 0.7152 * linear(c.g()) + 0.0722 * linear(c.b())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_dock_tab_has_readable_rendered_contrast_in_every_theme() {
|
||||
for brightness in crate::theme::Brightness::ALL {
|
||||
for width in [800.0, 1280.0] {
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx, "en");
|
||||
let tokens = Tokens::for_brightness(brightness);
|
||||
crate::theme::apply(&ctx, &tokens);
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
|
||||
app.ui.dock_expanded = true;
|
||||
app.ui.dock_tab = "properties".into();
|
||||
let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| show(&mut app, ui));
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(ctx.content_rect().size(), vec2(width, 640.0));
|
||||
assert_eq!(ctx.pixels_per_point(), scale);
|
||||
let text = output
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|shape| match &shape.shape {
|
||||
egui::Shape::Text(text) if text.galley.job.text == "Properties" => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the active Properties tab is painted");
|
||||
let color = text.galley.job.sections.first().expect("tab text has a format").format.color;
|
||||
let a = luminance(color);
|
||||
let b = luminance(tokens.panel);
|
||||
let contrast = (a.max(b) + 0.05) / (a.min(b) + 0.05);
|
||||
assert!(contrast >= 4.5, "{brightness:?} at {width}: selected tab contrast is {contrast:.2}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-12
@@ -18,7 +18,9 @@ pub mod icons;
|
||||
pub mod menus;
|
||||
mod new_document;
|
||||
mod object_control;
|
||||
mod panel_docking;
|
||||
pub mod panels;
|
||||
mod prefs;
|
||||
pub mod render_worker;
|
||||
mod rtl;
|
||||
mod section_options;
|
||||
@@ -105,6 +107,11 @@ pub struct SavedWorkspace {
|
||||
pub dock_expanded: bool,
|
||||
pub open_panel: Option<String>,
|
||||
pub floating: Vec<(String, [f32; 2])>,
|
||||
/// Custom panel membership and placement; absent in older workspace files.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub docking: Option<craft_ui::docking::Layout<String>>,
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub docking_hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
|
||||
}
|
||||
|
||||
/// A missing on-by-default bool stays on. `bool::default` is false.
|
||||
@@ -225,6 +232,20 @@ pub struct UiState {
|
||||
pub open_panel: Option<String>,
|
||||
/// Panels torn off the dock into their own floating windows: (panel id, top-left position).
|
||||
pub floating: Vec<(String, [f32; 2])>,
|
||||
/// Shared panel layout after the user customizes the legacy dock.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub docking: Option<craft_ui::docking::Layout<String>>,
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub docking_hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
|
||||
/// Invalidates in-flight gestures when a workspace is replaced.
|
||||
#[serde(skip)]
|
||||
pub docking_generation: u64,
|
||||
/// One-frame reveal request; z-order is never persisted.
|
||||
#[serde(skip)]
|
||||
pub docking_raise: Option<String>,
|
||||
/// Last explicitly revealed customized panel, for the legacy closePanel control.
|
||||
#[serde(skip)]
|
||||
pub docking_opened: Option<String>,
|
||||
pub dock_expanded: bool,
|
||||
pub units: Unit,
|
||||
pub workspace: String,
|
||||
@@ -326,6 +347,11 @@ impl Default for UiState {
|
||||
dock_tab: "properties".into(),
|
||||
open_panel: None,
|
||||
floating: Vec::new(),
|
||||
docking: None,
|
||||
docking_hidden: Default::default(),
|
||||
docking_generation: 0,
|
||||
docking_raise: None,
|
||||
docking_opened: None,
|
||||
dock_expanded: true,
|
||||
units: Unit::Picas,
|
||||
workspace: "Essentials".into(),
|
||||
@@ -1015,6 +1041,9 @@ impl DesignApp {
|
||||
let t0 = now_ms();
|
||||
let t = theme::Tokens::get(&ctx);
|
||||
let presenting = self.ui.screen_mode == ScreenMode::Presentation;
|
||||
if presenting || self.ui.hidden_panels != 0 {
|
||||
panel_docking::cancel_gesture(self, &ctx);
|
||||
}
|
||||
let dbg = std::env::var_os("DESIGNCRAFT_DEBUG_LAYOUT").is_some();
|
||||
if dbg {
|
||||
eprintln!("root start {:?}", ui.available_rect_before_wrap());
|
||||
@@ -1046,16 +1075,29 @@ impl DesignApp {
|
||||
if !presenting {
|
||||
chrome::doc_tabs(self, ui);
|
||||
}
|
||||
if self.split && !presenting {
|
||||
let split_layout = (self.split && !presenting)
|
||||
.then(|| {
|
||||
craft_ui::layout::split_rect(
|
||||
ui.available_rect_before_wrap(),
|
||||
craft_ui::layout::SplitAxis::Horizontal,
|
||||
craft_ui::layout::SplitSize::Ratio(0.5),
|
||||
2.0,
|
||||
0.0,
|
||||
)
|
||||
})
|
||||
.flatten();
|
||||
if let Some(layout) = split_layout {
|
||||
// Two panes with a divider; each has its own view (zoom, scroll) and render cache.
|
||||
let r = ui.available_rect_before_wrap();
|
||||
let mid = r.center().x;
|
||||
let halves = [
|
||||
egui::Rect::from_min_max(r.min, egui::pos2(mid - 1.0, r.max.y)),
|
||||
egui::Rect::from_min_max(egui::pos2(mid + 1.0, r.min.y), r.max),
|
||||
];
|
||||
ui.painter().rect_filled(egui::Rect::from_min_max(egui::pos2(mid - 1.0, r.min.y), egui::pos2(mid + 1.0, r.max.y)), 0.0, t.border);
|
||||
for (k, half) in halves.into_iter().enumerate() {
|
||||
ui.painter().rect_filled(layout.divider, 0.0, t.border);
|
||||
// Resolve the clicked pane before either canvas handles this frame's keys.
|
||||
if let Some(p) = ui.input(|i| i.pointer.any_pressed().then(|| i.pointer.interact_pos()).flatten()) {
|
||||
if layout.first.contains(p) {
|
||||
self.focus_pane = 0;
|
||||
} else if layout.second.contains(p) {
|
||||
self.focus_pane = 1;
|
||||
}
|
||||
}
|
||||
for (k, half) in [layout.first, layout.second].into_iter().enumerate() {
|
||||
self.switch_pane(k as u8);
|
||||
// A new pane starts at the other pane's view.
|
||||
if k == 1 && self.view().is_none() {
|
||||
@@ -1068,9 +1110,6 @@ impl DesignApp {
|
||||
}
|
||||
}
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(half), |ui| canvas::show(self, ui));
|
||||
if ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| half.contains(p))) {
|
||||
self.focus_pane = k as u8;
|
||||
}
|
||||
}
|
||||
self.switch_pane(self.focus_pane);
|
||||
} else {
|
||||
|
||||
@@ -169,6 +169,22 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
"{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links|table}",
|
||||
),
|
||||
("window.floatPanel", "Float Panel", None, "{panel, x?, y?} — the panel in its own movable window"),
|
||||
(
|
||||
"window.panel.layout",
|
||||
"Change Panel Layout",
|
||||
None,
|
||||
"{action} — serialized docking action, including floating geometry, divider sizes and accordion state",
|
||||
),
|
||||
(
|
||||
"window.panel.move",
|
||||
"Move Panel",
|
||||
None,
|
||||
"{panel, anchor, zone?: tab|center|left|right|top|bottom, before?: panel} — group, split or reorder a panel",
|
||||
),
|
||||
("window.panel.float", "Float Panel", None, "{panel, x?, y?, width?, height?} — detach a panel inside the application window"),
|
||||
("window.panel.dock", "Dock Panel", None, "{panel, anchor?, zone?, before?} — return a floating panel to a dock group"),
|
||||
("window.panel.activate", "Activate Panel", None, "{panel} — show and select a panel in its current group"),
|
||||
("window.panel.close", "Close Panel", None, "{panel} — hide a panel"),
|
||||
("window.dockPanel", "Dock Panel", None, "{panel} — back into the dock's icon column"),
|
||||
("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"),
|
||||
("window.split", "Split Window", None, "{on?: bool} — two views of the document side by side, each with its own zoom and scroll"),
|
||||
@@ -766,6 +782,9 @@ fn toggle_tabs_panel(app: &mut DesignApp) {
|
||||
|
||||
/// Run a UI command; `None` if `id` isn't one.
|
||||
pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
if let Some(result) = crate::panel_docking::command(app, id, p) {
|
||||
return Some(result);
|
||||
}
|
||||
let rect = app.canvas_rect;
|
||||
let flag = |b: &mut bool| {
|
||||
*b = !*b;
|
||||
@@ -1482,6 +1501,8 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
||||
dock_expanded: u.dock_expanded,
|
||||
open_panel: u.open_panel.clone(),
|
||||
floating: u.floating.clone(),
|
||||
docking: u.docking.clone(),
|
||||
docking_hidden: u.docking_hidden.clone(),
|
||||
};
|
||||
app.ui.custom_workspaces.retain(|x| x.name != name);
|
||||
app.ui.custom_workspaces.push(w);
|
||||
@@ -1505,6 +1526,9 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
||||
return run_ui(app, "window.workspace", &json!({"name": name}));
|
||||
}
|
||||
"window.workspace" => {
|
||||
app.ui.docking_generation = app.ui.docking_generation.wrapping_add(1);
|
||||
app.ui.docking_raise = None;
|
||||
app.ui.docking_opened = None;
|
||||
let name = p.get("name").and_then(Value::as_str).unwrap_or("Essentials");
|
||||
if let Some(w) = app.ui.custom_workspaces.iter().find(|w| w.name == name).cloned() {
|
||||
app.ui.control_bar = w.control_bar;
|
||||
@@ -1515,10 +1539,17 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
||||
app.ui.dock_expanded = w.dock_expanded;
|
||||
app.ui.open_panel = w.open_panel;
|
||||
app.ui.floating = w.floating;
|
||||
app.ui.docking = w.docking;
|
||||
app.ui.docking_hidden = w.docking_hidden;
|
||||
app.ui.workspace = w.name;
|
||||
return Some(Ok(Value::Null));
|
||||
}
|
||||
// Workspaces choose which bars and panels are visible.
|
||||
if app.ui.docking.is_some() {
|
||||
app.ui.floating.clear();
|
||||
}
|
||||
app.ui.docking = None;
|
||||
app.ui.docking_hidden.clear();
|
||||
app.ui.control_bar = matches!(name, "Advanced" | "Typography" | "Printing and Proofing" | "Book");
|
||||
let dock_tab = match name {
|
||||
"Typography" => Some("properties"),
|
||||
@@ -2830,6 +2861,170 @@ mod tests {
|
||||
frame(&mut app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_window_dispatches_text_once_to_the_shared_document() {
|
||||
for focus_pane in [0, 1] {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let made = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap();
|
||||
let story = made["story"].as_u64().unwrap();
|
||||
app.session.set_tool("type");
|
||||
app.run("text.select", json!({"story": story, "anchor": 0, "focus": 0})).unwrap();
|
||||
assert!(app.session.wants_text());
|
||||
app.run("window.split", json!({"on": true})).unwrap();
|
||||
app.focus_pane = focus_pane;
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut crate::DesignApp, events| {
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
frame(&mut app, vec![]);
|
||||
frame(&mut app, vec![]);
|
||||
frame(&mut app, vec![]);
|
||||
let undo_before = app.session.active().unwrap().history.undo.len();
|
||||
frame(&mut app, vec![egui::Event::Text("A".into())]);
|
||||
let text = app.run("story.get", json!({"story": story})).unwrap();
|
||||
assert_eq!(text["text"], "A", "one key press with pane {focus_pane} focused");
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo_before + 1);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.run("story.get", json!({"story": story})).unwrap()["text"], "");
|
||||
|
||||
// A press and key arriving together must use the newly focused pane, whichever
|
||||
// canvas is drawn first. Middle-click changes pane focus without moving the caret.
|
||||
let other = app.other_pane.as_ref().and_then(|p| p.1).unwrap().center();
|
||||
frame(&mut app, vec![egui::Event::PointerMoved(other)]);
|
||||
frame(
|
||||
&mut app,
|
||||
vec![
|
||||
egui::Event::PointerButton { pos: other, button: egui::PointerButton::Middle, pressed: true, modifiers: egui::Modifiers::NONE },
|
||||
egui::Event::Text("B".into()),
|
||||
],
|
||||
);
|
||||
assert_eq!(app.focus_pane, 1 - focus_pane);
|
||||
assert_eq!(app.run("story.get", json!({"story": story})).unwrap()["text"], "B", "a key in the pane-switch frame is dispatched once");
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo_before + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_window_click_focus_routes_menu_zoom_but_hover_scroll_stays_local() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("window.split", json!({"on": true})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let mut time = 0.0;
|
||||
let mut frame = |app: &mut crate::DesignApp, events| {
|
||||
time += 0.1;
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
|
||||
time: Some(time),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
for _ in 0..3 {
|
||||
frame(&mut app, vec![]);
|
||||
}
|
||||
let left = app.canvas_rect.unwrap().center();
|
||||
let right = app.other_pane.as_ref().and_then(|p| p.1).unwrap().center();
|
||||
let cameras = |app: &mut crate::DesignApp| {
|
||||
let current = app.pane;
|
||||
app.switch_pane(0);
|
||||
let left = *app.view().unwrap();
|
||||
app.switch_pane(1);
|
||||
let right = *app.view().unwrap();
|
||||
app.switch_pane(current);
|
||||
[left, right]
|
||||
};
|
||||
let button =
|
||||
|pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
frame(&mut app, vec![egui::Event::PointerMoved(right)]);
|
||||
frame(&mut app, vec![button(right, true)]);
|
||||
frame(&mut app, vec![button(right, false)]);
|
||||
assert_eq!((app.pane, app.focus_pane), (1, 1));
|
||||
let before = cameras(&mut app);
|
||||
app.run("view.zoom", json!({"zoom": 2.0})).unwrap();
|
||||
let zoomed = cameras(&mut app);
|
||||
assert_eq!(zoomed[0], before[0], "menu zoom leaves the other camera alone");
|
||||
assert!((zoomed[1].zoom - 2.0).abs() < 1e-9);
|
||||
|
||||
frame(&mut app, vec![egui::Event::PointerMoved(left)]);
|
||||
frame(
|
||||
&mut app,
|
||||
vec![egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, -40.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
}],
|
||||
);
|
||||
let scrolled = cameras(&mut app);
|
||||
assert_eq!(scrolled[1], zoomed[1], "hover scroll leaves the focused camera alone");
|
||||
assert_ne!(scrolled[0].origin, zoomed[0].origin);
|
||||
assert_eq!((app.pane, app.focus_pane), (1, 1), "hovering does not move menu focus");
|
||||
|
||||
frame(&mut app, vec![button(left, true)]);
|
||||
frame(&mut app, vec![button(left, false)]);
|
||||
assert_eq!((app.pane, app.focus_pane), (0, 0));
|
||||
app.run("view.zoom", json!({"zoom": 1.0})).unwrap();
|
||||
let final_views = cameras(&mut app);
|
||||
assert!((final_views[0].zoom - 1.0).abs() < 1e-9);
|
||||
assert_eq!(final_views[1], scrolled[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_window_contextual_bars_have_independent_widget_ids() {
|
||||
fn id_warnings(shape: &egui::Shape, found: &mut Vec<String>) {
|
||||
match shape {
|
||||
egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| id_warnings(shape, found)),
|
||||
egui::Shape::Text(text) if text.galley.text().contains("use of widget ID") => found.push(text.galley.text().to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
for rect in [[36, 36, 280, 180], [300, 36, 550, 180]] {
|
||||
app.run("frame.create", json!({"rect": rect, "content": "unassigned"})).unwrap();
|
||||
}
|
||||
app.run("edit.selectAll", json!({})).unwrap();
|
||||
app.run("window.split", json!({"on": true})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
ctx.options_mut(|options| options.warn_on_id_clash = true);
|
||||
let mut warnings = Vec::new();
|
||||
for _ in 0..4 {
|
||||
let input =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() };
|
||||
let mut output = ctx.run_ui(input, |ui| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
});
|
||||
warnings.clear();
|
||||
for shape in &output.shapes {
|
||||
id_warnings(&shape.shape, &mut warnings);
|
||||
}
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
assert!(warnings.is_empty(), "both bars reused the same interactive widget IDs: {warnings:?}");
|
||||
let bars: Vec<_> =
|
||||
(0_u8..2).map(|pane| ctx.memory(|memory| memory.area_rect(egui::Id::new(("task_bar", egui::ViewportId::ROOT, pane)))).unwrap()).collect();
|
||||
assert!(!bars[0].intersects(bars[1]), "each pane keeps its own bar: {bars:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_language_switches_and_validates() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
//! Application-owned panel IDs and commands around the shared docking layout.
|
||||
|
||||
use craft_ui::docking::{Action, Floating, Layout, Node, Placement, Zone};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::DesignApp;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct LegacyDrag {
|
||||
layout: Option<Layout<String>>,
|
||||
open_panel: Option<String>,
|
||||
floating: Vec<(String, [f32; 2])>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_gesture(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
cancel_legacy_drag(app, ctx);
|
||||
craft_ui::docking::cancel_drag::<String>(ctx, egui::Id::new("designcraft-panel-docking"));
|
||||
}
|
||||
|
||||
fn cancel_legacy_drag(app: &mut DesignApp, ctx: &egui::Context) -> bool {
|
||||
let key = egui::Id::new("designcraft-panel-docking").with("legacy-origin");
|
||||
let previous = ctx.data_mut(|data| data.remove_temp::<LegacyDrag>(key));
|
||||
if let Some(previous) = previous {
|
||||
craft_ui::docking::cancel_drag::<String>(ctx, egui::Id::new("designcraft-panel-docking"));
|
||||
if previous.generation != app.ui.docking_generation {
|
||||
return false;
|
||||
}
|
||||
app.ui.open_panel = previous.open_panel;
|
||||
app.ui.floating = previous.floating;
|
||||
app.ui.docking = previous.layout;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize(raw: &str) -> Option<&'static str> {
|
||||
crate::dock::DOCK_TABS
|
||||
.iter()
|
||||
.chain(crate::dock::ICON_PANELS)
|
||||
.find(|(id, label, _)| id.eq_ignore_ascii_case(raw.trim()) || label.eq_ignore_ascii_case(raw.trim()))
|
||||
.map(|(id, _, _)| *id)
|
||||
}
|
||||
|
||||
pub(crate) fn valid(layout: &Layout<String>) -> bool {
|
||||
layout.validate().is_ok() && layout.panels().iter().all(|id| normalize(id) == Some(id.as_str()))
|
||||
}
|
||||
|
||||
fn legacy(app: &DesignApp) -> Layout<String> {
|
||||
let panels: Vec<String> = crate::dock::DOCK_TABS.iter().map(|p| p.0.to_string()).collect();
|
||||
let active = panels.iter().position(|p| p == &app.ui.dock_tab).unwrap_or(0);
|
||||
let mut layout = Layout { root: Some(Node::Tabs { panels, active }), floating: Vec::new() };
|
||||
for (id, at) in &app.ui.floating {
|
||||
if normalize(id).is_some() && !layout.contains(id) && at.iter().all(|v| v.is_finite()) {
|
||||
layout.floating.push(Floating { panels: vec![id.clone()], active: 0, rect: [at[0], at[1], 280.0, 360.0] });
|
||||
}
|
||||
}
|
||||
if let Some(id) = &app.ui.open_panel
|
||||
&& normalize(id).is_some()
|
||||
&& !layout.contains(id)
|
||||
{
|
||||
// Preserve the visible flyout when the first unrelated docking gesture succeeds.
|
||||
if let Some(Node::Tabs { panels, active }) = &mut layout.root {
|
||||
panels.push(id.clone());
|
||||
*active = panels.len().saturating_sub(1);
|
||||
}
|
||||
}
|
||||
layout
|
||||
}
|
||||
|
||||
fn first_root(layout: &Layout<String>, except: &str) -> Option<String> {
|
||||
let root = Layout { root: layout.root.clone(), floating: Vec::new() };
|
||||
root.panels().into_iter().find(|p| p.as_str() != except).cloned()
|
||||
}
|
||||
|
||||
fn ensure(layout: &mut Layout<String>, panel: &str) -> Result<(), String> {
|
||||
let anchor = first_root(layout, panel);
|
||||
layout.apply(Action::Open { panel: panel.into(), anchor }).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn number(params: &Value, key: &str, default: f32) -> Result<f32, String> {
|
||||
match params.get(key) {
|
||||
None => Ok(default),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|v| v.is_finite() && v.abs() <= 1_000_000.0)
|
||||
.map(|v| v as f32)
|
||||
.ok_or_else(|| format!("{key} must be a finite coordinate")),
|
||||
}
|
||||
}
|
||||
|
||||
fn placement(params: &Value) -> Result<Placement<String>, String> {
|
||||
if let Some(before) = params.get("before") {
|
||||
let id = before.as_str().and_then(normalize).ok_or("before must name a panel")?;
|
||||
return Ok(Placement::Tab { before: Some(id.into()) });
|
||||
}
|
||||
let zone = match params.get("zone") {
|
||||
None => "tab",
|
||||
Some(value) => value.as_str().ok_or("zone must be a string")?,
|
||||
};
|
||||
Ok(match zone {
|
||||
"tab" => Placement::Tab { before: None },
|
||||
"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 tab, center, left, right, top or bottom".into()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn command(app: &mut DesignApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
|
||||
if id == "app.tablePanel" && app.ui.docking.is_some() {
|
||||
let panel = "table".to_string();
|
||||
let visible = app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&panel));
|
||||
return Some(change(app, if visible { "close" } else { "activate" }, &json!({"panel": panel})));
|
||||
}
|
||||
if id == "window.panel.layout" {
|
||||
return Some(
|
||||
params
|
||||
.get("action")
|
||||
.ok_or_else(|| "action is required".to_string())
|
||||
.and_then(|value| serde_json::from_value::<Action<String>>(value.clone()).map_err(|error| error.to_string()))
|
||||
.and_then(|action| apply_action(app, action)),
|
||||
);
|
||||
}
|
||||
let operation = match id {
|
||||
"window.panel.move" => "move",
|
||||
"window.panel.float" | "window.floatPanel" => "float",
|
||||
"window.panel.dock" | "window.dockPanel" => "dock",
|
||||
"window.panel.activate" => "activate",
|
||||
"window.panel.close" => "close",
|
||||
"window.panel" if app.ui.docking.is_some() => "activate",
|
||||
_ => return None,
|
||||
};
|
||||
Some(change(app, operation, params))
|
||||
}
|
||||
|
||||
// UI output and automation share this atomic dispatcher. The application owns the registry
|
||||
// and saved return locations; craft-ui owns tree validation and structural changes.
|
||||
fn apply_action(app: &mut DesignApp, action: Action<String>) -> Result<Value, String> {
|
||||
let registered = |id: &String| normalize(id) == Some(id.as_str());
|
||||
let known = match &action {
|
||||
Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => {
|
||||
registered(panel)
|
||||
&& registered(anchor)
|
||||
&& match placement {
|
||||
Placement::Tab { before: Some(id) } => registered(id),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
Action::Open { panel, anchor } => registered(panel) && anchor.as_ref().is_none_or(registered),
|
||||
Action::Float { panel, .. }
|
||||
| Action::Close { panel }
|
||||
| Action::Activate { panel }
|
||||
| Action::MoveFloating { panel, .. }
|
||||
| Action::SetStackOpen { panel, .. }
|
||||
| Action::ResizeStack { panel, .. } => registered(panel),
|
||||
Action::ResizeSplit { path, .. } => path.len() <= craft_ui::docking::MAX_DEPTH,
|
||||
};
|
||||
if !known {
|
||||
return Err("docking action contains an unknown panel or an excessive split path".into());
|
||||
}
|
||||
if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) {
|
||||
return Err("saved panel layout is invalid; reset the workspace first".into());
|
||||
}
|
||||
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
|
||||
let returning = match &action {
|
||||
Action::Close { panel } => layout.location(panel).ok().map(|location| (panel.clone(), location)),
|
||||
Action::Float { panel, .. } => {
|
||||
layout.location(panel).ok().filter(|location| location.floating.is_none()).map(|location| (panel.clone(), location))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let reveal = match &action {
|
||||
Action::Activate { panel } | Action::Open { panel, .. } | Action::OpenAt { panel, .. } => Some(panel.clone()),
|
||||
_ => None,
|
||||
};
|
||||
layout.apply(action).map_err(|error| error.to_string())?;
|
||||
if let Some((panel, location)) = returning {
|
||||
app.ui.docking_hidden.insert(panel, location);
|
||||
}
|
||||
app.ui.docking = Some(layout);
|
||||
sync_legacy(app);
|
||||
if let Some(panel) = reveal {
|
||||
app.ui.docking_opened = Some(panel.clone());
|
||||
app.ui.docking_raise = Some(panel);
|
||||
app.ui.dock_expanded = true;
|
||||
} else if app.ui.docking_opened.as_ref().is_some_and(|panel| !app.ui.docking.as_ref().is_some_and(|layout| layout.contains(panel))) {
|
||||
app.ui.docking_opened = None;
|
||||
}
|
||||
app.ui.open_panel = None;
|
||||
Ok(json!({"layout": app.ui.docking}))
|
||||
}
|
||||
|
||||
fn change(app: &mut DesignApp, operation: &str, params: &Value) -> Result<Value, String> {
|
||||
let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?;
|
||||
if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) {
|
||||
return Err("saved panel layout is invalid; reset the workspace first".into());
|
||||
}
|
||||
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
|
||||
let mut hidden: std::collections::BTreeMap<_, _> =
|
||||
app.ui.docking_hidden.iter().filter(|(id, _)| normalize(id).is_some()).take(64).map(|(id, at)| (id.clone(), at.clone())).collect();
|
||||
let mut restored = false;
|
||||
if operation == "dock"
|
||||
&& params.get("anchor").or_else(|| params.get("onto")).is_none()
|
||||
&& let Some(location) = hidden.get(panel).filter(|location| {
|
||||
location.floating.is_none()
|
||||
&& location.anchor.as_ref().is_none_or(|anchor| Layout { root: layout.root.clone(), floating: Vec::new() }.contains(anchor))
|
||||
})
|
||||
{
|
||||
let mut candidate = layout.clone();
|
||||
if candidate.contains(&panel.to_string()) {
|
||||
candidate.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if candidate.restore(panel.into(), location).is_ok() {
|
||||
layout = candidate;
|
||||
restored = true;
|
||||
hidden.remove(panel);
|
||||
}
|
||||
}
|
||||
match operation {
|
||||
"dock" if restored => {}
|
||||
"float" => {
|
||||
let rect = [number(params, "x", 400.0)?, number(params, "y", 140.0)?, number(params, "width", 300.0)?, number(params, "height", 400.0)?];
|
||||
ensure(&mut layout, panel)?;
|
||||
if let Ok(location) = layout.location(&panel.to_string())
|
||||
&& location.floating.is_none()
|
||||
{
|
||||
hidden.insert(panel.into(), location);
|
||||
}
|
||||
layout.apply(Action::Float { panel: panel.into(), rect }).map_err(|e| e.to_string())?;
|
||||
}
|
||||
"move" | "dock" => {
|
||||
if operation == "move" && params.get("anchor").or_else(|| params.get("onto")).is_none() {
|
||||
return Err("move requires a destination panel".into());
|
||||
}
|
||||
let anchor = match params.get("anchor").or_else(|| params.get("onto")) {
|
||||
Some(v) => Some(v.as_str().and_then(normalize).ok_or("anchor must name a registered panel")?.to_string()),
|
||||
None => first_root(&layout, panel),
|
||||
};
|
||||
let placement = placement(params)?;
|
||||
if let Some(anchor) = anchor {
|
||||
ensure(&mut layout, panel)?;
|
||||
layout.apply(Action::Move { panel: panel.into(), anchor, placement }).map_err(|e| e.to_string())?;
|
||||
} else if operation == "dock" {
|
||||
if layout.contains(&panel.to_string()) {
|
||||
layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?;
|
||||
}
|
||||
layout.apply(Action::Open { panel: panel.into(), anchor: None }).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
return Err("move requires an existing destination panel".into());
|
||||
}
|
||||
}
|
||||
"close" => {
|
||||
hidden.insert(panel.into(), layout.location(&panel.to_string()).map_err(|e| e.to_string())?);
|
||||
layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?;
|
||||
}
|
||||
_ => {
|
||||
if !layout.contains(&panel.to_string())
|
||||
&& let Some(location) = hidden.get(panel)
|
||||
{
|
||||
// A missing neighbor is allowed: the normal visible dock becomes the fallback.
|
||||
let _ = layout.restore(panel.into(), location);
|
||||
}
|
||||
ensure(&mut layout, panel)?;
|
||||
hidden.remove(panel);
|
||||
}
|
||||
}
|
||||
app.ui.docking = Some(layout);
|
||||
app.ui.docking_hidden = hidden;
|
||||
sync_legacy(app);
|
||||
app.ui.open_panel = None;
|
||||
app.ui.docking_opened = if operation == "close" { None } else { Some(panel.to_owned()) };
|
||||
app.ui.docking_raise = if operation == "close" { None } else { Some(panel.to_owned()) };
|
||||
if operation != "close" {
|
||||
app.ui.dock_expanded = true;
|
||||
}
|
||||
Ok(json!({"panel": panel, "floating": app.ui.floating.iter().map(|(id, _)| id).collect::<Vec<_>>(), "layout": app.ui.docking}))
|
||||
}
|
||||
|
||||
pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool {
|
||||
let area = egui::Id::new("designcraft-panel-docking");
|
||||
let changed_workspace = ui.ctx().data_mut(|data| {
|
||||
let previous = data.get_temp::<u64>(area.with("generation"));
|
||||
data.insert_temp(area.with("generation"), app.ui.docking_generation);
|
||||
previous.is_some_and(|generation| generation != app.ui.docking_generation)
|
||||
});
|
||||
if changed_workspace {
|
||||
craft_ui::docking::cancel_drag::<String>(ui.ctx(), area);
|
||||
ui.ctx().data_mut(|data| data.remove::<LegacyDrag>(area.with("legacy-origin")));
|
||||
}
|
||||
|
||||
let cancelled =
|
||||
ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape) || (!input.pointer.primary_down() && !input.pointer.any_released()));
|
||||
if cancelled && cancel_legacy_drag(app, ui.ctx()) && app.ui.docking.is_none() {
|
||||
return false;
|
||||
}
|
||||
let Some(layout) = app.ui.docking.as_ref() else { return false };
|
||||
if !valid(layout) {
|
||||
app.ui.docking = None;
|
||||
app.status("Invalid panel layout; restored the default dock");
|
||||
return false;
|
||||
}
|
||||
let mut layout = layout.clone();
|
||||
if !app.ui.dock_expanded {
|
||||
layout.root = None;
|
||||
}
|
||||
let raise = app.ui.docking_raise.take();
|
||||
let mut blocked = Vec::new();
|
||||
if app.ui.dialog.is_some() || app.ui.about || app.ui.palette.is_some() || egui::Popup::is_any_open(ui.ctx()) {
|
||||
blocked.push(ui.ctx().content_rect());
|
||||
} else if app.story_editor.is_some() {
|
||||
// The editor is rendered after the dock; block its unmeasured first frame conservatively.
|
||||
let rect = ui.ctx().memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap_or_else(|| ui.ctx().content_rect());
|
||||
blocked.push(rect);
|
||||
}
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let language = app.ui.language.clone();
|
||||
let mut draw = |ui: &mut egui::Ui| {
|
||||
let mut style = craft_ui::docking::DockStyle::from_ui(ui);
|
||||
style.tab_height = 27.0;
|
||||
style.background = t.panel;
|
||||
style.tab_background = t.tab_strip;
|
||||
style.active_background = t.panel;
|
||||
style.text = t.text_strong;
|
||||
style.inactive_text = t.text_dim;
|
||||
style.border = egui::Stroke::new(1.0, t.border);
|
||||
style.accent = t.accent;
|
||||
style.float_label = crate::i18n::tr(&language, "Float Panel").to_owned();
|
||||
style.close_label = crate::i18n::tr(&language, "Close Panel").to_owned();
|
||||
style.move_label = crate::i18n::tr(&language, "Group with").to_owned();
|
||||
style.panels_label = crate::i18n::tr(&language, "Panels").to_owned();
|
||||
style.resize_label = crate::i18n::tr(&language, "Resize panels").to_owned();
|
||||
style.resize_window_label = crate::i18n::tr(&language, "Resize panel window").to_owned();
|
||||
let mut area = craft_ui::docking::DockArea::new(egui::Id::new("designcraft-panel-docking")).blocked_rects(&blocked);
|
||||
if let Some(panel) = &raise {
|
||||
area = area.raise_panel(panel.clone());
|
||||
}
|
||||
area.show_customized(
|
||||
ui,
|
||||
&layout,
|
||||
&style,
|
||||
|id| {
|
||||
crate::i18n::tr(
|
||||
&language,
|
||||
crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).find(|p| p.0 == id).map(|p| p.1).unwrap_or(id),
|
||||
)
|
||||
.to_owned()
|
||||
},
|
||||
|_| craft_ui::docking::Permissions::default(),
|
||||
|_| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 80.0), ..Default::default() },
|
||||
&mut PanelContent(app),
|
||||
)
|
||||
};
|
||||
let output = if layout.root.is_some() {
|
||||
egui::Panel::right("shared-panel-dock")
|
||||
.default_size(310.0)
|
||||
.size_range(220.0..=720.0)
|
||||
.resizable(true)
|
||||
.frame(egui::Frame::NONE.fill(t.panel))
|
||||
.show(ui, &mut draw)
|
||||
.inner
|
||||
} else {
|
||||
draw(ui)
|
||||
};
|
||||
if let Some(panel) = output.focused {
|
||||
app.ui.docking_opened = Some(panel);
|
||||
}
|
||||
if let Some(error) = output.error {
|
||||
app.ui.status = error.to_string();
|
||||
}
|
||||
if ui.input(|input| input.pointer.any_released()) {
|
||||
if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. })) && cancel_legacy_drag(app, ui.ctx()) {
|
||||
return app.ui.docking.is_some();
|
||||
}
|
||||
ui.ctx().data_mut(|data| data.remove::<LegacyDrag>(egui::Id::new("designcraft-panel-docking").with("legacy-origin")));
|
||||
}
|
||||
for action in output.actions {
|
||||
if let Err(error) = app.run("window.panel.layout", json!({"action": action})) {
|
||||
app.ui.status = error;
|
||||
}
|
||||
}
|
||||
icon_rail(app, ui);
|
||||
true
|
||||
}
|
||||
|
||||
fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let column = egui::Panel::right("shared-panel-icons")
|
||||
.exact_size(38.0)
|
||||
.resizable(false)
|
||||
.frame(egui::Frame::NONE.fill(t.panel))
|
||||
.show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("shared-panel-icons-scroll").show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
for &(id, label, icon) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) {
|
||||
if app.ui.docking.as_ref().is_some_and(|layout| {
|
||||
layout.contains(&id.to_string()) && (app.ui.dock_expanded || layout.floating.iter().any(|g| g.panels.iter().any(|p| p == id)))
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let response =
|
||||
crate::icons::button(ui, icon, 28.0, false, crate::i18n::tr(&app.ui.language, label)).interact(egui::Sense::click_and_drag());
|
||||
if response.clicked() {
|
||||
let _ = app.run("window.panel.activate", json!({"panel":id}));
|
||||
}
|
||||
legacy_tab(app, ui, id, &response);
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.rect;
|
||||
let _ = column;
|
||||
}
|
||||
|
||||
pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) {
|
||||
let Some(panel) = normalize(panel) else { return };
|
||||
let language = app.ui.language.clone();
|
||||
response.context_menu(|ui| {
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Float Panel"))).clicked() {
|
||||
if let Err(error) = app.run("window.panel.float", json!({"panel": panel})) {
|
||||
app.status(error);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Group with")), |ui| {
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||||
for (anchor, label, _) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) {
|
||||
if *anchor != panel && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, label))).clicked() {
|
||||
// A closed icon panel is made visible before becoming the destination.
|
||||
let result = app
|
||||
.run("window.panel.activate", json!({"panel": anchor}))
|
||||
.and_then(|_| app.run("window.panel.move", json!({"panel":panel,"anchor":anchor})));
|
||||
if let Err(error) = result {
|
||||
app.status(error);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if response.drag_started()
|
||||
&& let Some(id) = normalize(panel)
|
||||
{
|
||||
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
|
||||
let bounds = ui.max_rect();
|
||||
let source = egui::Rect::from_min_size(
|
||||
egui::pos2(bounds.left(), response.rect.top()),
|
||||
egui::vec2(bounds.width().clamp(240.0, 600.0), bounds.height().clamp(200.0, 600.0)),
|
||||
);
|
||||
if ensure(&mut layout, id).is_ok()
|
||||
&& craft_ui::docking::begin_drag(ui.ctx(), egui::Id::new("designcraft-panel-docking"), id.to_string(), source)
|
||||
{
|
||||
ui.ctx().data_mut(|data| {
|
||||
data.insert_temp(
|
||||
egui::Id::new("designcraft-panel-docking").with("legacy-origin"),
|
||||
LegacyDrag {
|
||||
layout: app.ui.docking.clone(),
|
||||
open_panel: app.ui.open_panel.clone(),
|
||||
floating: app.ui.floating.clone(),
|
||||
generation: app.ui.docking_generation,
|
||||
},
|
||||
)
|
||||
});
|
||||
app.ui.docking = Some(layout);
|
||||
app.ui.open_panel = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PanelContent<'a>(&'a mut DesignApp);
|
||||
impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
|
||||
fn body(&mut self, ui: &mut egui::Ui, id: &String) {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::same(8)).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt(("shared-panel-body", id))
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| crate::dock::panel_body(self.0, ui, id));
|
||||
});
|
||||
}
|
||||
fn label(&mut self, ui: &egui::Ui, _: &String, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText {
|
||||
egui::WidgetText::Galley(crate::rtl::plain(ui.ctx(), logical, font.clone(), color))
|
||||
}
|
||||
fn action_label(&mut self, ui: &egui::Ui, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText {
|
||||
egui::WidgetText::Galley(crate::rtl::plain(ui.ctx(), logical, font.clone(), color))
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the last explicitly opened panel through the same validated layout transaction.
|
||||
pub(crate) fn close_visible(app: &mut DesignApp) -> Result<(), String> {
|
||||
if app.ui.docking.is_some() {
|
||||
if let Some(panel) = app.ui.docking_opened.clone() {
|
||||
change(app, "close", &json!({"panel": panel}))?;
|
||||
}
|
||||
} else {
|
||||
app.ui.open_panel = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Legacy inspection fields are derived from the shared layout after customization.
|
||||
fn sync_legacy(app: &mut DesignApp) {
|
||||
if let Some(layout) = &app.ui.docking {
|
||||
app.ui.floating =
|
||||
layout.floating.iter().flat_map(|group| group.panels.iter().map(|id| (id.clone(), [group.rect[0], group.rect[1]]))).collect();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "panel_docking_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,599 @@
|
||||
use super::*;
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
fn app() -> DesignApp {
|
||||
DesignApp::new(designcraft_engine::Session::new(), Default::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_commands_group_split_reorder_float_and_restore_without_duplicate_ownership() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap();
|
||||
app.run("window.panel.move", json!({"panel":"properties", "anchor":"swatches", "before":"swatches"})).unwrap();
|
||||
let before_float = app.ui.docking.clone().unwrap();
|
||||
app.run("window.panel.float", json!({"panel":"properties", "x":50,"y":70,"width":330,"height":410})).unwrap();
|
||||
assert!(app.ui.docking.as_ref().unwrap().floating.iter().any(|g| g.panels == ["properties"]));
|
||||
app.run("window.panel.dock", json!({"panel":"properties"})).unwrap();
|
||||
assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float);
|
||||
app.run("window.panel.close", json!({"panel":"properties"})).unwrap();
|
||||
assert!(!app.ui.docking.as_ref().unwrap().contains(&"properties".into()));
|
||||
app.run("window.panel.activate", json!({"panel":"properties"})).unwrap();
|
||||
assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float);
|
||||
let layout = app.ui.docking.as_ref().unwrap();
|
||||
assert!(valid(layout));
|
||||
let unique: std::collections::HashSet<_> = layout.panels().into_iter().collect();
|
||||
assert_eq!(unique.len(), layout.panels().len());
|
||||
let saved = serde_json::to_value(&app.ui).unwrap();
|
||||
let restored: crate::UiState = serde_json::from_value(saved).unwrap();
|
||||
assert_eq!(restored.docking, app.ui.docking);
|
||||
assert_eq!(restored.docking_hidden, app.ui.docking_hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_panel_requests_are_atomic_and_do_not_enter_a_custom_workspace() {
|
||||
let mut app = app();
|
||||
for (id, params) in [
|
||||
("window.panel.move", json!({"panel":"layers","anchor":"unknown"})),
|
||||
("window.panel.move", json!({"panel":"layers","anchor":"properties","before":"unknown"})),
|
||||
("window.panel.move", json!({"panel":"layers","anchor":"properties","zone":42})),
|
||||
("window.panel.float", json!({"panel":"layers","width":-10})),
|
||||
("window.panel.float", json!({"panel":"unknown"})),
|
||||
("window.panel.float", json!({"panel":"layers","x":"invalid"})),
|
||||
] {
|
||||
let old = app.ui.docking.clone();
|
||||
let hidden = app.ui.docking_hidden.clone();
|
||||
assert!(app.run(id, params).is_err(), "{id}");
|
||||
assert_eq!(app.ui.docking, old);
|
||||
assert_eq!(app.ui.docking_hidden, hidden);
|
||||
}
|
||||
}
|
||||
|
||||
fn harness(app: DesignApp, size: egui::Vec2) -> Harness<'static, DesignApp> {
|
||||
let mut ready = false;
|
||||
let mut harness = Harness::builder().with_size(size).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
move |ui, app: &mut DesignApp| {
|
||||
if !ready {
|
||||
crate::theme::install_fonts(ui.ctx(), &app.ui.language);
|
||||
crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::MediumDark));
|
||||
ready = true;
|
||||
return;
|
||||
}
|
||||
crate::dock::show(app, ui);
|
||||
egui::CentralPanel::default().show(ui, |_| {});
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.run_steps(4);
|
||||
harness
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness<'static, DesignApp>, from: Pos2, to: Pos2) {
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
for step in 1..=8 {
|
||||
h.event(Event::PointerMoved(from + (to - from) * (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(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_panel_tab_drag_enters_shared_docking_and_can_redock_into_another_tab_group() {
|
||||
let mut h = harness(app(), vec2(1280.0, 800.0));
|
||||
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
|
||||
drag(&mut h, source, egui::pos2(160.0, 140.0));
|
||||
let layout = h.state().ui.docking.as_ref().expect("ordinary tab drag starts shared docking");
|
||||
assert!(layout.floating.iter().any(|g| g.panels.contains(&"layers".into())), "{layout:?}");
|
||||
let source = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center();
|
||||
let destination = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center();
|
||||
drag(&mut h, source, destination);
|
||||
let layout = h.state().ui.docking.as_ref().unwrap();
|
||||
assert!(!layout.floating.iter().any(|g| g.panels.contains(&"layers".into())));
|
||||
assert!(valid(layout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_workspace_restores_the_custom_tree_and_hidden_panel_placement() {
|
||||
let mut app = app();
|
||||
|
||||
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
|
||||
app.run("window.panel.float", json!({"panel":"properties","x":50,"y":80})).unwrap();
|
||||
app.run("window.panel.close", json!({"panel":"swatches"})).unwrap();
|
||||
let layout = app.ui.docking.clone();
|
||||
let hidden = app.ui.docking_hidden.clone();
|
||||
app.run("window.newWorkspace", json!({"name":"Custom docking"})).unwrap();
|
||||
app.run("window.workspace", json!({"name":"Essentials"})).unwrap();
|
||||
assert!(app.ui.docking.is_none());
|
||||
app.run("window.workspace", json!({"name":"Custom docking"})).unwrap();
|
||||
assert_eq!(app.ui.docking, layout);
|
||||
assert_eq!(app.ui.docking_hidden, hidden);
|
||||
}
|
||||
|
||||
/// Explicit offscreen evidence; this creates no native windows.
|
||||
#[test]
|
||||
fn capture_custom_panel_docking_visual_fixtures() {
|
||||
let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return };
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
for theme in crate::theme::Brightness::ALL {
|
||||
for width in [800.0, 1280.0] {
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("frame.create", json!({"rect":[40,40,280,180],"content":"text","text":"Synthetic layout — مرحبا"})).unwrap();
|
||||
let language = if width == 800.0 && scale == 1.0 { "ar" } else { "en" };
|
||||
app.run("app.language", json!({"lang":if language == "en" { "" } else { language }})).unwrap();
|
||||
app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap();
|
||||
app.run("window.panel.float", json!({"panel":"properties","x":30,"y":90,"width":300,"height":440})).unwrap();
|
||||
let mut ready = false;
|
||||
let mut harness = Harness::builder().with_size(vec2(width, 800.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
|
||||
move |ui, app: &mut DesignApp| {
|
||||
if !ready {
|
||||
crate::theme::install_fonts(ui.ctx(), &app.ui.language);
|
||||
crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(theme));
|
||||
ready = true;
|
||||
return;
|
||||
}
|
||||
crate::dock::show(app, ui);
|
||||
egui::CentralPanel::default().show(ui, |ui| {
|
||||
ui.label("Custom panel workspace");
|
||||
if app.ui.language == "ar" {
|
||||
ui.label("Shaped action references:");
|
||||
for action in ["Float Panel", "Group with", "Close Panel"] {
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, action));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.input_mut().max_texture_side = Some(8192);
|
||||
harness.run_steps(5);
|
||||
assert!(valid(harness.state().ui.docking.as_ref().unwrap()));
|
||||
harness
|
||||
.render()
|
||||
.unwrap()
|
||||
.save(directory.join(format!("designcraft-shared-docking-{}-{language}-{width}-{scale}x.png", theme.id())))
|
||||
.unwrap();
|
||||
if language == "ar" {
|
||||
let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"swatches".to_string()));
|
||||
let at = harness.ctx.read_response(tab).unwrap().rect.center();
|
||||
harness.input_mut().events.extend([
|
||||
Event::PointerMoved(at),
|
||||
Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
harness.run_steps(1);
|
||||
harness.input_mut().events.push(Event::PointerButton {
|
||||
pos: at,
|
||||
button: PointerButton::Secondary,
|
||||
pressed: false,
|
||||
modifiers: Modifiers::NONE,
|
||||
});
|
||||
harness.run_steps(2);
|
||||
assert!(egui::Popup::is_any_open(&harness.ctx), "actual tab context menu must be open");
|
||||
for action in ["Float Panel", "Group with", "Close Panel"] {
|
||||
assert!(
|
||||
harness.query_all_by_label(crate::i18n::tr("ar", action)).count() >= 2,
|
||||
"both logical reference and actual menu label must exist"
|
||||
);
|
||||
}
|
||||
harness.render().unwrap().save(directory.join(format!("designcraft-dock-context-{}-ar.png", theme.id()))).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels_the_first_drag_without_replacing_the_legacy_workspace() {
|
||||
let mut h = harness(app(), vec2(1280.0, 800.0));
|
||||
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
|
||||
h.event(Event::PointerMoved(source));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerMoved(source - vec2(90.0, 0.0)));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.docking.is_some());
|
||||
h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
assert!(h.state().ui.docking.is_none());
|
||||
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.docking.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_layout_actions_cover_geometry_reorder_accordion_and_atomic_rejection() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
|
||||
app.run("window.panel.layout", json!({"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}})).unwrap();
|
||||
assert!(
|
||||
matches!(app.ui.docking.as_ref().unwrap().root, Some(Node::Split { size: craft_ui::layout::SplitSize::Ratio(value), .. }) if (value - 0.35).abs() < 0.001)
|
||||
);
|
||||
app.run("window.panel.float", json!({"panel":"properties"})).unwrap();
|
||||
app.run("window.panel.layout", json!({"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}})).unwrap();
|
||||
assert_eq!(
|
||||
app.ui.docking.as_ref().unwrap().floating.iter().find(|group| group.panels.iter().any(|p| p == "properties")).unwrap().rect,
|
||||
[44.0, 66.0, 360.0, 450.0]
|
||||
);
|
||||
app.run("window.panel.layout", json!({"action":{"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"swatches"}}}}}))
|
||||
.unwrap();
|
||||
assert_eq!(group_members_for_test(app.ui.docking.as_ref().unwrap(), "swatches"), ["properties", "swatches"]);
|
||||
let before = app.ui.docking.clone();
|
||||
let hidden = app.ui.docking_hidden.clone();
|
||||
for action in [
|
||||
json!({"MoveFloating":{"panel":"properties","rect":[0,0,-5,30]}}),
|
||||
json!({"ResizeSplit":{"path":[true,true,true],"size":{"Ratio":0.2}}}),
|
||||
json!({"Open":{"panel":"unknown","anchor":null}}),
|
||||
json!({"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"unknown"}}}}),
|
||||
] {
|
||||
assert!(app.run("window.panel.layout", json!({"action":action})).is_err());
|
||||
assert_eq!(app.ui.docking, before);
|
||||
assert_eq!(app.ui.docking_hidden, hidden);
|
||||
}
|
||||
app.ui.docking = Some(Layout {
|
||||
root: Some(Node::Stack { entries: vec![craft_ui::docking::StackEntry { panel: "layers".into(), open: true, height: Some(100.0) }] }),
|
||||
floating: Vec::new(),
|
||||
});
|
||||
app.run("window.panel.layout", json!({"action":{"SetStackOpen":{"panel":"layers","open":false}}})).unwrap();
|
||||
app.run("window.panel.layout", json!({"action":{"ResizeStack":{"panel":"layers","height":150}}})).unwrap();
|
||||
let Some(Node::Stack { entries }) = &app.ui.docking.as_ref().unwrap().root else { panic!("expected stack") };
|
||||
assert!(!entries[0].open);
|
||||
assert_eq!(entries[0].height, Some(150.0));
|
||||
}
|
||||
|
||||
fn group_members_for_test(layout: &Layout<String>, panel: &str) -> Vec<String> {
|
||||
let mut pending: Vec<_> = layout.root.iter().collect();
|
||||
while let Some(node) = pending.pop() {
|
||||
match node {
|
||||
Node::Tabs { panels, .. } if panels.iter().any(|p| p == panel) => return panels.clone(),
|
||||
Node::Split { first, second, .. } => pending.extend([first.as_ref(), second.as_ref()]),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populated_canvas_keeps_a_real_stroke_edit_after_floating_and_redocking() {
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({"width":480,"height":360})).unwrap();
|
||||
let id = app.run("frame.create", json!({"rect":[80,70,330,230],"content":"none"})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("object.fill", json!({"swatch":"C=100 M=0 Y=0 K=0"})).unwrap();
|
||||
app.run("object.stroke", json!({"weight":8})).unwrap();
|
||||
app.run("window.panel.float", json!({"panel":"stroke","x":40,"y":140,"width":310,"height":490})).unwrap();
|
||||
let directory = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from);
|
||||
let builder = Harness::builder().with_size(vec2(1280.0, 800.0));
|
||||
let builder = if directory.is_some() { builder.wgpu() } else { builder };
|
||||
let mut h = builder.build_ui_state(
|
||||
|ui, app: &mut DesignApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.input_mut().max_texture_side = Some(8192);
|
||||
h.run_steps(5);
|
||||
let at = h.get_by_label("Bevel").rect().center();
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().stroke.join, designcraft_doc::Join::Bevel);
|
||||
if let Some(directory) = &directory {
|
||||
std::fs::create_dir_all(directory).unwrap();
|
||||
h.render().unwrap().save(directory.join("designcraft-populated-stroke-floating.png")).unwrap();
|
||||
}
|
||||
let from = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"stroke".to_string()))).unwrap().rect.center();
|
||||
let to = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center();
|
||||
drag(&mut h, from, to);
|
||||
assert!(h.state().ui.docking.as_ref().unwrap().floating.is_empty());
|
||||
assert_eq!(h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().stroke.join, designcraft_doc::Join::Bevel);
|
||||
if let Some(directory) = &directory {
|
||||
h.render().unwrap().save(directory.join("designcraft-populated-stroke-redocked.png")).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_workspace_during_a_drag_cancels_the_stale_gesture() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
|
||||
let destination = app.ui.docking.clone();
|
||||
app.run("window.newWorkspace", json!({"name":"Destination"})).unwrap();
|
||||
app.run("window.workspace", json!({"name":"Essentials"})).unwrap();
|
||||
let mut h = harness(app, vec2(1280.0, 800.0));
|
||||
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
|
||||
h.event(Event::PointerMoved(source));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerMoved(source - vec2(90.0, 0.0)));
|
||||
h.run_steps(2);
|
||||
h.state_mut().run("window.workspace", json!({"name":"Destination"})).unwrap();
|
||||
h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().ui.docking, destination);
|
||||
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.docking, destination);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_customization_preserves_the_visible_legacy_flyout() {
|
||||
let mut app = app();
|
||||
app.ui.open_panel = Some("paragraphStyles".into());
|
||||
app.ui.dock_expanded = false;
|
||||
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
|
||||
let layout = app.ui.docking.as_ref().unwrap();
|
||||
assert!(layout.contains(&"paragraphStyles".into()));
|
||||
assert!(valid(layout));
|
||||
assert_eq!(layout.panels().iter().filter(|p| p.as_str() == "paragraphStyles").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_before_first_destination_frame_does_not_restore_an_old_drag_snapshot() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
|
||||
app.run("window.newWorkspace", json!({"name":"Destination"})).unwrap();
|
||||
app.run("window.workspace", json!({"name":"Essentials"})).unwrap();
|
||||
let mut h = harness(app, vec2(1280.0, 800.0));
|
||||
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
|
||||
h.event(Event::PointerMoved(source));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerMoved(source - vec2(90.0, 0.0)));
|
||||
h.run_steps(2);
|
||||
h.state_mut().run("window.workspace", json!({"name":"Destination"})).unwrap();
|
||||
let destination = h.state().ui.docking.clone();
|
||||
let open_panel = h.state().ui.open_panel.clone();
|
||||
let floating = h.state().ui.floating.clone();
|
||||
// The app calls this before dock rendering when panels are hidden or Presentation starts.
|
||||
let ctx = h.ctx.clone();
|
||||
cancel_gesture(h.state_mut(), &ctx);
|
||||
assert_eq!(h.state().ui.docking, destination);
|
||||
assert_eq!(h.state().ui.open_panel, open_panel);
|
||||
assert_eq!(h.state().ui.floating, floating);
|
||||
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.docking, destination);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_table_and_control_visibility_work_after_customization_without_document_edits() {
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap();
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
|
||||
app.run("app.tablePanel", json!({})).unwrap();
|
||||
assert!(app.ui.docking.as_ref().unwrap().contains(&"table".into()));
|
||||
app.run("app.tablePanel", json!({})).unwrap();
|
||||
assert!(!app.ui.docking.as_ref().unwrap().contains(&"table".into()));
|
||||
app.run("app.tablePanel", json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let (req, _) = crate::control::ControlRequest::new("ui.set", json!({"closePanel":true,"dockExpanded":false}));
|
||||
let crate::control::Outcome::Done(result) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected synchronous control result") };
|
||||
assert_eq!(result["ok"], true);
|
||||
assert!(!app.ui.dock_expanded);
|
||||
assert!(!app.ui.docking.as_ref().unwrap().contains(&"table".into()));
|
||||
app.run("window.panel", json!({"panel":"properties"})).unwrap();
|
||||
assert!(app.ui.dock_expanded);
|
||||
assert_eq!(app.ui.docking_raise.as_deref(), Some("properties"));
|
||||
assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), doc);
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_activation_queues_one_transient_raise_and_workspace_switch_clears_it() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
|
||||
app.run("window.panel.activate", json!({"panel":"layers"})).unwrap();
|
||||
assert_eq!(app.ui.docking_raise.as_deref(), Some("layers"));
|
||||
assert!(serde_json::to_value(&app.ui).unwrap().get("docking_raise").is_none());
|
||||
app.run("window.workspace", json!({"name":"Typography"})).unwrap();
|
||||
assert!(app.ui.docking_raise.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaped_panel_label_keeps_the_logical_accessibility_text() {
|
||||
let ctx = egui::Context::default();
|
||||
let logical = "خصائص Pages";
|
||||
crate::theme::install_fonts(&ctx, "ar");
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let galley = crate::rtl::plain(ui.ctx(), logical, egui::FontId::proportional(12.0), egui::Color32::WHITE);
|
||||
assert_eq!(galley.job.text, logical);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modal_overlay_blocks_a_real_tab_drag_release() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
|
||||
let mut h = harness(app, vec2(1280.0, 800.0));
|
||||
let area = egui::Id::new("designcraft-panel-docking");
|
||||
let source = h.ctx.read_response(area.with(("tab", &"properties".to_string()))).unwrap().rect.center();
|
||||
h.event(Event::PointerMoved(source));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerMoved(source - vec2(90.0, 0.0)));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.ctx.dragged_id(), Some(area.with(("tab", &"properties".to_string()))), "test must start the owned tab gesture");
|
||||
let before = h.state().ui.docking.clone();
|
||||
h.state_mut().ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", json!({})));
|
||||
let destination = egui::pos2(150.0, 150.0);
|
||||
h.event(Event::PointerMoved(destination));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: destination, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.docking, before, "release under a modal must not float or dock the panel");
|
||||
h.state_mut().ui.dialog = None;
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.docking, before, "closing the overlay must not revive its blocked drag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_f9_toggles_table_after_customization_through_the_real_shortcut_path() {
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
|
||||
let before = serde_json::to_value(&app.session.active().unwrap().doc).unwrap();
|
||||
let mut h = crate::test_window::open(app, vec2(1440.0, 900.0));
|
||||
for visible in [true, false] {
|
||||
let modifiers = Modifiers { shift: true, ..Modifiers::NONE };
|
||||
h.event(Event::Key { key: egui::Key::F9, physical_key: None, pressed: true, repeat: false, modifiers });
|
||||
h.run_steps(1);
|
||||
h.event(Event::Key { key: egui::Key::F9, physical_key: None, pressed: false, repeat: false, modifiers });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().app.ui.docking.as_ref().unwrap().contains(&"table".into()), visible);
|
||||
}
|
||||
assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_story_editor_frame_conservatively_blocks_owned_tab_release() {
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let story = app.run("frame.create", json!({"rect":[72,72,300,200],"content":"text","text":"Story editor overlay fixture"})).unwrap()["story"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
|
||||
let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap();
|
||||
let mut h = crate::test_window::open(app, vec2(1440.0, 900.0));
|
||||
let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string()));
|
||||
let from = h.ctx.read_response(tab).unwrap().rect.center();
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerMoved(from - vec2(90.0, 0.0)));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.ctx.dragged_id(), Some(tab), "must own a real tab drag before opening editor");
|
||||
assert!(h.ctx.memory(|m| m.area_rect(egui::Id::new("story_editor"))).is_none());
|
||||
let layout = h.state().app.ui.docking.clone();
|
||||
let frame_before = h.ctx.cumulative_frame_nr();
|
||||
h.state_mut().app.run("app.storyEditor", json!({"story":story})).unwrap();
|
||||
// The unmeasured first frame blocks the full content, even outside the eventual editor.
|
||||
let release = egui::pos2(150.0, 150.0);
|
||||
h.input_mut().events.extend([
|
||||
Event::PointerMoved(release),
|
||||
Event::PointerButton { pos: release, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.ctx.cumulative_frame_nr() - frame_before, 1, "release must occur in the first editor frame");
|
||||
let measured = h.ctx.memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap();
|
||||
assert!(!measured.contains(release), "fixture exercises the conservative first-frame guard outside eventual editor bounds");
|
||||
assert_eq!(h.state().app.ui.docking, layout, "first editor frame must block release before it has a measured rectangle");
|
||||
assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), doc);
|
||||
h.run_steps(3);
|
||||
let editor = h.ctx.memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap();
|
||||
let screen = h.ctx.content_rect();
|
||||
let outside = [screen.left_bottom() + vec2(80.0, -80.0), screen.right_bottom() + vec2(-450.0, -80.0)]
|
||||
.into_iter()
|
||||
.find(|p| !editor.contains(*p))
|
||||
.expect("fixture has space outside editor");
|
||||
let before_outside = h.state().app.ui.docking.clone();
|
||||
let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"layers".to_string()));
|
||||
let from = h.ctx.read_response(tab).unwrap().rect.center();
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerMoved(from - vec2(90.0, 0.0)));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.ctx.dragged_id(), Some(tab));
|
||||
h.event(Event::PointerMoved(outside));
|
||||
h.event(Event::PointerButton { pos: outside, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
assert_ne!(h.state().app.ui.docking, before_outside, "measured editor must permit ordinary releases outside its bounds");
|
||||
let after = h.state().app.ui.docking.clone();
|
||||
h.state_mut().app.run("app.storyEditor", json!({"story":story})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().app.ui.docking, after, "closing editor must not revive a blocked drag");
|
||||
assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_legacy_panel_menu_visual_fixture() {
|
||||
let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return };
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("frame.create", json!({"rect":[40,40,280,180],"content":"text","text":"Legacy Arabic menu fixture"})).unwrap();
|
||||
app.run("app.language", json!({"lang":"ar"})).unwrap();
|
||||
let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap();
|
||||
let mut ready = false;
|
||||
let mut h = Harness::builder().with_size(vec2(800.0, 800.0)).wgpu().build_ui_state(
|
||||
move |ui, app: &mut DesignApp| {
|
||||
if !ready {
|
||||
crate::theme::install_fonts(ui.ctx(), &app.ui.language);
|
||||
crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::Dark));
|
||||
ready = true;
|
||||
return;
|
||||
}
|
||||
crate::dock::show(app, ui);
|
||||
egui::CentralPanel::default().show(ui, |ui| {
|
||||
ui.label("Shaped legacy menu references:");
|
||||
for label in ["Float Panel", "Layers"] {
|
||||
crate::rtl::label(ui, crate::i18n::tr("ar", label));
|
||||
}
|
||||
});
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.input_mut().max_texture_side = Some(8192);
|
||||
h.run_steps(5);
|
||||
assert!(h.state().ui.docking.is_none(), "fixture must use the actual legacy menu before customization");
|
||||
let at = h.get_by_label("Properties").rect().center();
|
||||
h.input_mut().events.extend([
|
||||
Event::PointerMoved(at),
|
||||
Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
assert!(egui::Popup::is_any_open(&h.ctx));
|
||||
assert_eq!(h.query_all_by_label(crate::i18n::tr("ar", "Float Panel")).count(), 2, "reference and actual logical action must be accessible");
|
||||
h.render().unwrap().save(directory.join("designcraft-legacy-context-dark-ar.png")).unwrap();
|
||||
let group = h.get_by_label_contains(crate::i18n::tr("ar", "Group with")).rect().center();
|
||||
h.hover_at(group);
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.query_all_by_label(crate::i18n::tr("ar", "Layers")).count(), 2, "reference and actual target must be accessible");
|
||||
h.render().unwrap().save(directory.join("designcraft-legacy-targets-dark-ar.png")).unwrap();
|
||||
assert!(h.state().ui.docking.is_none());
|
||||
let (last, last_label, _) = crate::dock::ICON_PANELS.last().unwrap();
|
||||
let logical = crate::i18n::tr("ar", last_label);
|
||||
let scroll_at = h.query_all_by_label(crate::i18n::tr("ar", "Layers")).find(|node| node.rect().left() > 500.0).unwrap().rect().center();
|
||||
let submenu_layer = h.ctx.layer_id_at(scroll_at).unwrap();
|
||||
let submenu = h.ctx.memory(|m| m.area_rect(submenu_layer.id)).unwrap();
|
||||
let before = h.query_all_by_label(logical).find(|node| submenu.x_range().contains(node.rect().center().x)).map(|node| node.rect());
|
||||
assert!(before.is_none_or(|rect| !submenu.contains_rect(rect)), "actual submenu must require scrolling to reach its last target");
|
||||
h.input_mut().events.extend([
|
||||
Event::PointerMoved(scroll_at),
|
||||
Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -1600.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
h.run_steps(12);
|
||||
let target = h.query_all_by_label(logical).find(|node| submenu.x_range().contains(node.rect().center().x)).unwrap().rect();
|
||||
assert!(submenu.contains_rect(target), "last target must be inside the actual submenu after scrolling");
|
||||
assert!(h.ctx.content_rect().contains_rect(target), "last target must be physically visible after wheel scrolling");
|
||||
h.render().unwrap().save(directory.join("designcraft-legacy-targets-scrolled-dark-ar.png")).unwrap();
|
||||
h.query_all_by_label(logical).find(|node| submenu.contains_rect(node.rect())).unwrap().click();
|
||||
h.run_steps(4);
|
||||
let layout = h.state().ui.docking.as_ref().unwrap();
|
||||
assert_eq!(layout.panels().iter().filter(|panel| panel.as_str() == "properties").count(), 1);
|
||||
assert_eq!(layout.panels().iter().filter(|panel| panel.as_str() == *last).count(), 1);
|
||||
let group = group_members_for_test(layout, last);
|
||||
assert!(group.iter().any(|panel| panel == "properties"));
|
||||
assert_eq!(group.last().map(String::as_str), Some("properties"), "clicked destination must receive the original panel after it");
|
||||
assert_eq!(serde_json::to_value(&h.state().session.active().unwrap().doc).unwrap(), doc);
|
||||
h.render().unwrap().save(directory.join("designcraft-legacy-last-target-selected-dark-ar.png")).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Compatibility recovery at the saved-preferences boundary. Command deserialization stays strict.
|
||||
|
||||
use craft_ui::docking::{Layout, Location};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::UiState;
|
||||
|
||||
impl UiState {
|
||||
/// Read saved preferences, discarding only unreadable optional docking metadata.
|
||||
/// A layout from a newer craft-ui version must not reset unrelated settings or named workspaces.
|
||||
pub fn from_preferences_json(bytes: &[u8]) -> serde_json::Result<Self> {
|
||||
let mut saved: Value = serde_json::from_slice(bytes)?;
|
||||
recover_docking(&mut saved);
|
||||
if let Some(workspaces) = saved.get_mut("customWorkspaces").and_then(Value::as_array_mut) {
|
||||
for workspace in workspaces {
|
||||
recover_docking(workspace);
|
||||
}
|
||||
}
|
||||
serde_json::from_value(saved)
|
||||
}
|
||||
}
|
||||
|
||||
fn recover_docking(saved: &mut Value) {
|
||||
let Some(fields) = saved.as_object_mut() else { return };
|
||||
if fields.get("docking").is_some_and(|value| serde_json::from_value::<Option<Layout<String>>>(value.clone()).is_err()) {
|
||||
fields.remove("docking");
|
||||
}
|
||||
if let Some(hidden) = fields.get_mut("dockingHidden") {
|
||||
if let Some(entries) = hidden.as_object_mut() {
|
||||
entries.retain(|_, value| serde_json::from_value::<Location<String>>(value.clone()).is_ok());
|
||||
} else {
|
||||
fields.remove("dockingHidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{DesignApp, SavedWorkspace};
|
||||
use serde_json::json;
|
||||
|
||||
fn populated() -> UiState {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
|
||||
app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap();
|
||||
app.run("window.panel.close", json!({"panel":"properties"})).unwrap();
|
||||
app.ui.ui_scale = 1.5;
|
||||
app.ui.language = "ja".into();
|
||||
app.ui.custom_workspaces = vec![
|
||||
SavedWorkspace {
|
||||
name: "Print".into(),
|
||||
docking: app.ui.docking.clone(),
|
||||
docking_hidden: app.ui.docking_hidden.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
SavedWorkspace { name: "Proof".into(), docking: app.ui.docking.clone(), ..Default::default() },
|
||||
];
|
||||
app.ui
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_preferences_preserve_valid_layouts_and_hidden_origins() {
|
||||
let original = serde_json::to_value(populated()).unwrap();
|
||||
let loaded = UiState::from_preferences_json(&serde_json::to_vec(&original).unwrap()).unwrap();
|
||||
assert_eq!(serde_json::to_value(loaded).unwrap(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_layouts_recover_independently_without_resetting_preferences_or_workspaces() {
|
||||
let original = populated();
|
||||
for field in ["docking", "dockingHidden"] {
|
||||
for invalid in [json!({"root":{"FutureLayout":{}}}), json!(false)] {
|
||||
let mut saved = serde_json::to_value(&original).unwrap();
|
||||
saved[field] = invalid.clone();
|
||||
saved["customWorkspaces"][0][field] = invalid;
|
||||
assert!(serde_json::from_value::<UiState>(saved.clone()).is_err(), "command parsing remains strict");
|
||||
let loaded = UiState::from_preferences_json(&serde_json::to_vec(&saved).unwrap()).unwrap();
|
||||
assert_eq!(loaded.ui_scale, 1.5);
|
||||
assert_eq!(loaded.language, "ja");
|
||||
assert_eq!(loaded.custom_workspaces.len(), 2);
|
||||
assert_eq!(loaded.custom_workspaces[0].name, "Print");
|
||||
assert_eq!(loaded.custom_workspaces[1].name, "Proof");
|
||||
assert_eq!(loaded.custom_workspaces[1].docking, original.custom_workspaces[1].docking);
|
||||
if field == "docking" {
|
||||
assert!(loaded.docking.is_none());
|
||||
assert!(loaded.custom_workspaces[0].docking.is_none());
|
||||
assert_eq!(loaded.docking_hidden, original.docking_hidden);
|
||||
assert_eq!(loaded.custom_workspaces[0].docking_hidden, original.custom_workspaces[0].docking_hidden);
|
||||
} else {
|
||||
assert!(loaded.docking_hidden.is_empty());
|
||||
assert!(loaded.custom_workspaces[0].docking_hidden.is_empty());
|
||||
assert_eq!(loaded.docking, original.docking);
|
||||
assert_eq!(loaded.custom_workspaces[0].docking, original.custom_workspaces[0].docking);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_hidden_origin_does_not_discard_other_origins() {
|
||||
let original = populated();
|
||||
let mut saved = serde_json::to_value(&original).unwrap();
|
||||
saved["dockingHidden"]["futurePanel"] = json!({"FutureLocation":{}});
|
||||
saved["customWorkspaces"][0]["dockingHidden"]["futurePanel"] = json!(false);
|
||||
let loaded = UiState::from_preferences_json(&serde_json::to_vec(&saved).unwrap()).unwrap();
|
||||
assert!(!original.docking_hidden.is_empty());
|
||||
assert_eq!(loaded.docking_hidden, original.docking_hidden);
|
||||
assert_eq!(loaded.custom_workspaces[0].docking_hidden, original.custom_workspaces[0].docking_hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_malformed_preferences_are_still_rejected() {
|
||||
assert!(UiState::from_preferences_json(br#"{"uiScale":"invalid","docking":false}"#).is_err());
|
||||
assert!(UiState::from_preferences_json(br#"{"customWorkspaces":false}"#).is_err());
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const GRIP: &str = "task_bar_grip";
|
||||
/// Show the bar under `sel` (screen rect of the selection), or where it is pinned, kept inside
|
||||
/// `canvas`.
|
||||
pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) {
|
||||
let grip_id = egui::Id::new(GRIP);
|
||||
let grip_id = egui::Id::new((GRIP, ctx.viewport_id(), app.pane));
|
||||
// (Two separate context calls: `is_pointer_over_egui` inside an `input` closure deadlocks.)
|
||||
let dragging = ctx.dragged_id() == Some(grip_id);
|
||||
if !app.ui.task_bar || (ctx.input(|i| i.pointer.any_down()) && !ctx.is_pointer_over_egui() && !dragging) {
|
||||
@@ -28,7 +28,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let id = egui::Id::new("task_bar");
|
||||
// The same selection can be shown in two canvases; each bar needs its own area and widgets.
|
||||
let id = egui::Id::new(("task_bar", ctx.viewport_id(), app.pane));
|
||||
// Width from the previous frame (content-sized).
|
||||
let w = ctx.memory(|m| m.area_rect(id).map(|r| r.width())).unwrap_or(420.0);
|
||||
let at = match app.ui.task_bar_pin {
|
||||
@@ -46,7 +47,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) {
|
||||
pos2(x, y)
|
||||
}
|
||||
};
|
||||
app.ui.task_bar_at = Some([at.x - canvas.min.x, at.y - canvas.min.y]);
|
||||
// A shared persisted pin is relative to each canvas. The transient pin target belongs
|
||||
// to the focused pane, so rendering the sibling cannot overwrite it.
|
||||
if app.pane == app.focus_pane {
|
||||
app.ui.task_bar_at = Some([at.x - canvas.min.x, at.y - canvas.min.y]);
|
||||
}
|
||||
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(at).show(ctx, |ui| {
|
||||
egui::Frame::NONE
|
||||
.fill(t.panel)
|
||||
@@ -75,7 +80,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) {
|
||||
} else {
|
||||
object_controls(app, ui);
|
||||
}
|
||||
more(app, ui);
|
||||
more(app, ui, [at.x - canvas.min.x, at.y - canvas.min.y]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -248,7 +253,7 @@ fn object_controls(app: &mut DesignApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
fn more(app: &mut DesignApp, ui: &mut Ui) {
|
||||
fn more(app: &mut DesignApp, ui: &mut Ui, at: [f32; 2]) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, FIELD_H), Sense::click());
|
||||
if resp.hovered() {
|
||||
@@ -268,7 +273,8 @@ fn more(app: &mut DesignApp, ui: &mut Ui) {
|
||||
let pinned = app.ui.task_bar_pin.is_some();
|
||||
let pin = crate::i18n::tr(&app.ui.language, "Pin Bar Position");
|
||||
if ui.button(crate::rtl::widget(ui, if pinned { format!("✓ {pin}") } else { format!(" {pin}") })).clicked() {
|
||||
let _ = app.run("window.taskBarPin", json!({"on": !pinned}));
|
||||
let params = if pinned { json!({"on": false}) } else { json!({"on": true, "at": at}) };
|
||||
let _ = app.run("window.taskBarPin", params);
|
||||
ui.close();
|
||||
}
|
||||
if ui.add_enabled(pinned, egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset Bar Position")))).clicked() {
|
||||
@@ -305,7 +311,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn bar(h: &Harness<'static, Window>) -> egui::Rect {
|
||||
h.ctx.memory(|m| m.area_rect(egui::Id::new("task_bar"))).unwrap()
|
||||
h.ctx.memory(|m| m.area_rect(egui::Id::new(("task_bar", h.ctx.viewport_id(), h.state().app.pane)))).unwrap()
|
||||
}
|
||||
|
||||
fn grip(h: &Harness<'static, Window>) -> egui::Pos2 {
|
||||
@@ -354,6 +360,59 @@ mod tests {
|
||||
assert_eq!(app.ui.task_bar_pin, Some([5.0, 6.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_pane_grips_handle_only_their_own_drag() {
|
||||
let (mut h, _) = window();
|
||||
h.set_size(vec2(2200.0, 900.0));
|
||||
// A compact object HUD fits in each pane, leaving room to exercise both axes
|
||||
// without the intentional canvas-edge clamp masking horizontal movement.
|
||||
let id = h.state_mut().app.run("frame.create", json!({"rect":[72,72,300,200],"content":"none"})).unwrap()["id"].as_u64().unwrap();
|
||||
h.state_mut().app.run("selection.set", json!({"ids":[id]})).unwrap();
|
||||
h.state_mut().app.run("window.split", json!({"on":true})).unwrap();
|
||||
h.run_steps(6);
|
||||
let area =
|
||||
|h: &Harness<'static, Window>, pane| h.ctx.memory(|m| m.area_rect(egui::Id::new(("task_bar", h.ctx.viewport_id(), pane)))).unwrap();
|
||||
for pane in [0_u8, 1] {
|
||||
h.state_mut().app.run("window.taskBarPin", json!({"at":[100,120]})).unwrap();
|
||||
h.run_steps(4);
|
||||
let before = area(&h, pane);
|
||||
let from = pos2(before.min.x + 10.0, before.center().y);
|
||||
let delta = vec2(24.0, 28.0);
|
||||
drag(&mut h, from, delta);
|
||||
let after = area(&h, pane);
|
||||
assert!((after.min - before.min - delta).length() < 2.0, "pane {pane} drag was handled by a sibling grip: {before:?} -> {after:?}");
|
||||
assert!(h.state().app.ui.task_bar_pin.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_pin_uses_the_invoking_hud_position_after_focus_moves_to_sibling() {
|
||||
let (mut h, _) = window();
|
||||
let id = h.state_mut().app.run("frame.create", json!({"rect":[72,72,300,200],"content":"none"})).unwrap()["id"].as_u64().unwrap();
|
||||
h.state_mut().app.run("selection.set", json!({"ids":[id]})).unwrap();
|
||||
h.state_mut().app.run("window.split", json!({"on":true})).unwrap();
|
||||
h.run_steps(6);
|
||||
h.state_mut().app.switch_pane(1);
|
||||
h.state_mut().app.run("view.zoom", json!({"zoom":0.5})).unwrap();
|
||||
h.state_mut().app.switch_pane(0);
|
||||
h.run_steps(4);
|
||||
let expected = h.state().app.ui.task_bar_at.unwrap();
|
||||
let left = h.ctx.memory(|m| m.area_rect(egui::Id::new(("task_bar", h.ctx.viewport_id(), 0_u8)))).unwrap();
|
||||
click_at(&mut h, pos2(left.max.x - 19.0, left.center().y));
|
||||
let pin = h.get_by_label_contains("Pin Bar Position").rect();
|
||||
let at = pos2(pin.max.x - 2.0, pin.center().y);
|
||||
let sibling = h.state().app.other_pane.as_ref().and_then(|(_, rect)| *rect).unwrap();
|
||||
assert!(sibling.contains(at), "fixture popup must extend into sibling canvas: {at:?} outside {sibling:?}");
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().app.focus_pane, 1);
|
||||
assert_ne!(h.state().app.ui.task_bar_at, Some(expected), "fixture HUD positions must differ");
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().app.ui.task_bar_pin, Some(expected), "Pin must use the invoking HUD position");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grip_moves_the_bar_and_it_stays_there() {
|
||||
let (mut h, id) = window();
|
||||
|
||||
@@ -480,18 +480,11 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) {
|
||||
v.selection.stroke = Stroke::new(1.0, t.text_strong);
|
||||
v.hyperlink_color = t.accent;
|
||||
v.override_text_color = Some(t.text);
|
||||
for (w, fill) in [
|
||||
(&mut v.widgets.noninteractive, t.panel),
|
||||
(&mut v.widgets.inactive, t.button),
|
||||
(&mut v.widgets.hovered, t.hover),
|
||||
(&mut v.widgets.active, t.tool_active),
|
||||
(&mut v.widgets.open, t.hover),
|
||||
] {
|
||||
w.bg_fill = fill;
|
||||
w.weak_bg_fill = fill;
|
||||
w.corner_radius = r;
|
||||
w.fg_stroke = Stroke::new(1.0, t.text);
|
||||
}
|
||||
craft_ui::theme::WidgetPalette { noninteractive: t.panel, inactive: t.button, hovered: t.hover, active: t.tool_active, open: t.hover }.apply_to(
|
||||
&mut v.widgets,
|
||||
r,
|
||||
Stroke::new(1.0, t.text),
|
||||
);
|
||||
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.divider);
|
||||
v.widgets.inactive.bg_stroke = Stroke::new(1.0, t.field_border);
|
||||
v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.field_border);
|
||||
|
||||
@@ -3,6 +3,17 @@
|
||||
Start the app with `--control <port>` (or `DESIGNCRAFT_CONTROL_PORT`). The server listens on
|
||||
`127.0.0.1` only and speaks JSON lines: one request object per line, one reply per line.
|
||||
|
||||
For an isolated native test profile, set `DESIGNCRAFT_CONFIG_DIR` to a dedicated directory before
|
||||
launch. UI/engine preferences and the `Recovery` directory then live under that root. An unset
|
||||
override preserves the usual platform paths; an explicitly empty override disables those paths.
|
||||
`DESIGNCRAFT_NO_PREFS` still disables preference reads/writes independently of recovery.
|
||||
|
||||
Control pointer and key input is queued across native frames. After a drag, wait for both the
|
||||
expected document state and `document.inspect.canUndo` before testing undo; an object can appear
|
||||
in the preview before its undo entry is committed. On macOS, default shortcuts owned by the native
|
||||
menu (including Command-Z) are skipped by the egui shortcut handler. `ui.key` does not send a Cocoa
|
||||
menu event; use `ui.menu.invoke` for a menu-command test and test physical accelerators separately.
|
||||
|
||||
**Only requests are read.** Every line must be a JSON object with a string `method` (`id` and `params`
|
||||
are optional; blank lines are skipped). Anything else gets one error reply
|
||||
(`{"ok": false, "error": "… closing the connection"}`) and the server **closes the connection**, so
|
||||
@@ -45,3 +56,40 @@ merged document; the template stays as it was ([agents.md](agents.md#data-merge)
|
||||
Headless window screenshots (locked screen, hidden window): `cargo run -p designcraft-ui-egui --example ui_shot -- script.jsonl`, where each line is one of the requests above, `{"shot": "/abs/out.png"}` or `{"steps": n}` (renders the whole UI offscreen with wgpu).
|
||||
|
||||
The MCP server (`designcraft-cli mcp`) wraps the same methods for Claude and other agents.
|
||||
|
||||
## Shared panel docking
|
||||
|
||||
Panel arrangements support tab grouping and reordering, horizontal and vertical splits,
|
||||
and movable, resizable floating groups inside the application window. Drag a panel tab,
|
||||
or use its context menu. Floating groups remain within the application viewport.
|
||||
|
||||
The shared layout begins with the existing workspace on the first docking operation.
|
||||
Workspace saves retain its groups, active tabs, split sizes, floating geometry, and the
|
||||
positions of hidden panels. Resetting a built-in workspace restores its default dock.
|
||||
|
||||
- `window.panel.move {panel, anchor, zone?, before?}` moves a panel into the target group.
|
||||
`zone` is `tab` (default), `center`, `left`, `right`, `top`, or `bottom`; `before` reorders
|
||||
tabs by panel ID. The destination must already be visible.
|
||||
- `window.panel.float {panel, x?, y?, width?, height?}` detaches a panel.
|
||||
- `window.panel.dock {panel, anchor?, zone?, before?}` returns a panel to its previous
|
||||
dock placement when possible, or to the supplied destination.
|
||||
- `window.panel.activate {panel}` reveals and selects a panel, restoring its saved
|
||||
position when possible. `window.panel.close {panel}` hides it.
|
||||
|
||||
Panel IDs are stable across themes. Invalid IDs, non-finite or invalid geometry, and
|
||||
invalid destinations return an error without changing the arrangement.
|
||||
|
||||
`window.panel.layout {action}` also accepts the shared, externally tagged action schema.
|
||||
For example, `{"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}}`
|
||||
updates a floating group's position and size; `{"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}}`
|
||||
resizes the root divider (`false`/`true` path entries select first/second children).
|
||||
`Move` with `placement: {"Tab":{"before":"swatches"}}` reorders tabs, and
|
||||
`SetStackOpen {panel,open}` / `ResizeStack {panel,height}` control accordion entries.
|
||||
The UI dispatches these same commands. Unknown panel IDs, invalid geometry, and stale
|
||||
split paths return an error without changing the workspace.
|
||||
|
||||
After panel customization, `app.tablePanel` (including Shift+F9) still toggles the
|
||||
Table panel. `ui.set` with `closePanel` closes the last explicitly opened panel;
|
||||
`dockExpanded:false` hides the root dock while floating groups remain visible.
|
||||
The icon rail can reopen a collapsed dock through any root panel. Activating a
|
||||
floating panel also raises its group. These operations change only UI layout.
|
||||
|
||||
+2
-2
@@ -73,7 +73,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 = 6;
|
||||
@@ -285,7 +285,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ui_crates_below_l6_flagged() {
|
||||
for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] {
|
||||
for dep in ["craft-ui", "egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] {
|
||||
let v = check(&[c("designcraft-engine", &[(dep, Normal, false)])]);
|
||||
assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user