Merge pull request #409 from skylartaylor/codex/ui-suite-integration-20261010
Adopt shared panel docking and preserve workspace commands
This commit is contained in:
commit
16a7365701
|
|
@ -796,6 +796,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"
|
||||
|
|
@ -1122,6 +1131,7 @@ dependencies = [
|
|||
name = "designcraft-ui-egui"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
"designcraft-doc",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
@ -67,6 +67,8 @@ quick-xml = "0.38"
|
|||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
|||
|
||||
Newest first.
|
||||
|
||||
- **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"
|
||||
|
|
|
|||
|
|
@ -367,7 +367,9 @@ mod tests {
|
|||
// out of the set once it failed.
|
||||
write(&path, &record(Some(Backend::Gl), &[])).expect("write");
|
||||
let s = Startup::begin_unforced(Some(path.clone()), V).expect("candidates");
|
||||
if candidates().len() > 1 {
|
||||
// This synthetic prior start records OpenGL even on Metal-only macOS.
|
||||
// Switching depends on the chosen backend, not the candidate count.
|
||||
if s.backend != Backend::Gl {
|
||||
assert_eq!(s.switched_from, Some(Backend::Gl));
|
||||
let line = s.status_line().expect("status");
|
||||
assert!(line.contains("OpenGL") && line.contains(s.backend.name()) && line.contains("WGPU_BACKEND"), "{line}");
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ mod gpu;
|
|||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod profile;
|
||||
|
||||
use designcraft_engine::Session;
|
||||
use designcraft_ui_egui::{DesignApp, Services};
|
||||
|
|
@ -67,27 +68,13 @@ impl eframe::App for App {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
|
|
@ -96,7 +83,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;
|
||||
}
|
||||
|
|
@ -249,7 +236,7 @@ fn main() -> eframe::Result {
|
|||
}
|
||||
// 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.
|
||||
|
|
@ -273,7 +260,7 @@ fn main() -> eframe::Result {
|
|||
}
|
||||
// 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`).
|
||||
let gpu = gpu::Startup::begin(if no_prefs() { None } else { config_dir().map(|d| d.join(gpu::FILE)) }, env!("CARGO_PKG_VERSION"));
|
||||
let gpu = gpu::Startup::begin(if no_prefs() { None } else { profile::config_dir().map(|d| d.join(gpu::FILE)) }, env!("CARGO_PKG_VERSION"));
|
||||
if let Some(s) = &gpu
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
|
|
@ -304,7 +291,7 @@ fn main() -> eframe::Result {
|
|||
}
|
||||
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) = gpu.as_ref().and_then(gpu::Startup::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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -834,7 +834,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")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -1443,8 +1443,8 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
|
|||
if resp.clicked() || resp.drag_started() {
|
||||
resp.request_focus();
|
||||
}
|
||||
// Keyboard for the active tool.
|
||||
if ui.ctx().text_edit_focused() && !resp.has_focus() {
|
||||
// Both split panes show the same document, but only the focused pane dispatches its keys.
|
||||
if app.pane != app.focus_pane || (ui.ctx().text_edit_focused() && !resp.has_focus()) {
|
||||
return;
|
||||
}
|
||||
let evs = ui.input(|i| i.events.clone());
|
||||
|
|
|
|||
|
|
@ -278,7 +278,7 @@ pub fn handle(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;
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ pub const ICON_PANELS: &[(&str, &str, &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 {
|
||||
|
|
@ -66,7 +69,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));
|
||||
|
|
@ -75,9 +78,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));
|
||||
|
|
@ -100,13 +109,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);
|
||||
}
|
||||
});
|
||||
|
|
@ -123,6 +135,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, ICON_PANELS.iter().find(|p| p.0 == id).map(|p| p.1).unwrap_or("Panel")).to_owned();
|
||||
|
|
@ -170,6 +185,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),
|
||||
|
|
@ -209,20 +227,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, ICON_PANELS.iter().find(|p| p.0 == id).map(|p| p.1).unwrap_or("Panel")).to_owned();
|
||||
|
|
@ -291,3 +316,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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ pub mod dock;
|
|||
pub mod i18n;
|
||||
pub mod icons;
|
||||
pub mod menus;
|
||||
mod panel_docking;
|
||||
pub mod panels;
|
||||
mod prefs;
|
||||
pub mod render_worker;
|
||||
mod rtl;
|
||||
pub mod story_editor;
|
||||
|
|
@ -97,6 +99,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.
|
||||
|
|
@ -217,6 +224,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,
|
||||
|
|
@ -310,6 +331,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(),
|
||||
|
|
@ -824,6 +850,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());
|
||||
|
|
@ -855,16 +884,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() {
|
||||
|
|
@ -877,9 +919,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 {
|
||||
|
|
|
|||
|
|
@ -130,6 +130,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"),
|
||||
|
|
@ -707,6 +723,9 @@ pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> {
|
|||
|
||||
/// 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;
|
||||
|
|
@ -1379,6 +1398,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);
|
||||
|
|
@ -1402,6 +1423,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;
|
||||
|
|
@ -1412,10 +1436,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"),
|
||||
|
|
@ -2440,6 +2471,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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -266,7 +271,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() {
|
||||
|
|
@ -286,7 +291,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() {
|
||||
|
|
@ -323,7 +329,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 {
|
||||
|
|
@ -372,6 +378,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();
|
||||
|
|
|
|||
|
|
@ -332,18 +332,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
|
||||
|
|
@ -44,3 +55,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.
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue