UI.9: integrate shared panel docking and preserve workspace behavior
This commit is contained in:
parent
92649ffdb0
commit
c62866443c
|
|
@ -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.5.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
"designcraft-doc",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
# DesignCraft roadmap
|
||||
|
||||
- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
|
||||
- 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.
|
||||
|
||||
- UI.4: Shared pane geometry and widget-state theme application through `craft-ui`, retaining all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane.
|
||||
|
||||
- AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it.
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -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")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,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,6 +16,7 @@ pub mod dock;
|
|||
pub mod i18n;
|
||||
pub mod icons;
|
||||
pub mod menus;
|
||||
mod panel_docking;
|
||||
pub mod panels;
|
||||
pub mod render_worker;
|
||||
mod rtl;
|
||||
|
|
@ -97,6 +98,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 +223,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 +330,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 +849,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 +883,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 +918,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();
|
||||
}
|
||||
|
|
@ -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