UI.9: integrate shared docking and preserve panel workflows

This commit is contained in:
skylar taylor-barrick 2026-10-10 14:49:35 -06:00
parent 45516a6a96
commit ed14640756
33 changed files with 2327 additions and 149 deletions

115
Cargo.lock generated
View File

@ -200,6 +200,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arboard"
version = "3.6.1"
@ -713,6 +719,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colored"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.59.0",
]
[[package]]
name = "combine"
version = "4.6.8"
@ -790,6 +806,15 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
dependencies = [
"egui",
"serde",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@ -861,6 +886,19 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "dify"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc"
dependencies = [
"anyhow",
"colored",
"getopts",
"image",
"rayon",
]
[[package]]
name = "digest"
version = "0.10.7"
@ -1066,6 +1104,27 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_kittest"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
dependencies = [
"dify",
"eframe",
"egui",
"egui-wgpu",
"image",
"kittest",
"log",
"open",
"pollster 1.0.1",
"serde",
"tempfile",
"toml",
"wgpu",
]
[[package]]
name = "either"
version = "1.18.0"
@ -1438,6 +1497,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@ -1940,6 +2008,25 @@ dependencies = [
"generic-array",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.15.0"
@ -2067,6 +2154,16 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kittest"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
dependencies = [
"accesskit",
"accesskit_consumer 0.35.0",
]
[[package]]
name = "krilla"
version = "0.8.2"
@ -2119,6 +2216,12 @@ dependencies = [
"smallvec",
]
[[package]]
name = "lazy_static"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "leb128fmt"
version = "0.1.0"
@ -2740,6 +2843,16 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "open"
version = "5.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
dependencies = [
"is-wsl",
"libc",
]
[[package]]
name = "orbclient"
version = "0.3.55"
@ -4736,8 +4849,10 @@ dependencies = [
name = "vectorcraft-ui-egui"
version = "0.8.0"
dependencies = [
"craft-ui",
"egui",
"egui_extras",
"egui_kittest",
"image",
"log",
"serde",

View File

@ -11,6 +11,7 @@ rust-version = "1.95"
repository = "https://github.com/storytold/vectorcraft"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
vectorcraft-color = { path = "crates/color" }

View File

@ -1,5 +1,9 @@
# VectorCraft Roadmap
- UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available.
- UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned.
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux and the web (WASM), and agents can drive it fully over a JSON control channel and MCP.
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
@ -115,7 +119,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
- The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's tool-specific variants (only the selection tools show it).
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels.
- Panels undock (#495): ordinary tabs and panel icons can be dragged into floating groups, reordered, merged into another dock group, or placed beside it as a split. Floating groups stay inside the application window and can be resized; Dock Panel restores a panel’s saved placement. The default dock keeps its icon rail, group grip and Tools behavior, and custom layouts keep access to panels through the rail and Window menu. Grouping, split sizes, floating geometry and return locations persist with workspaces and preferences; UI gestures and control/MCP commands use the same docking actions. The Tools panel still floats by its title bar and docks on the left edge. Not done: panels in separate OS windows and collapsing a floating group to icons.
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, German, Spanish, French, Italian, Russian and Ukrainian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8).

View File

@ -4,21 +4,15 @@
use std::path::PathBuf;
#[path = "profile.rs"]
mod profile;
/// The settings file `ui.json` of the app `name`: in a folder named `name` in ~/Library/Application
/// Support (macOS) or %APPDATA% (Windows), or `lower` in $XDG_CONFIG_HOME or ~/.config (Linux and
/// BSD).
pub fn prefs_path_for(name: &str, lower: &str) -> Option<PathBuf> {
let base = if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support").join(name))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join(name))
} 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(lower))
};
base.map(|b| b.join("ui.json"))
let legacy = name == "DrawCraft" || lower == "drawcraft";
profile::prefs_path(legacy)
}
/// Have the font scans read VectorCraft's own Fonts folder, next to the settings, which

View File

@ -0,0 +1,66 @@
//! Native profile paths. Isolated profiles never migrate preferences from a user's old profile.
use std::ffi::OsString;
use std::path::PathBuf;
pub fn prefs_path(legacy: bool) -> Option<PathBuf> {
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key))
}
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
if let Some(root) = env("VECTORCRAFT_CONFIG_DIR") {
// Never fall back to DrawCraft's real profile when the isolated file does not exist.
return (!legacy && !root.is_empty()).then(|| PathBuf::from(root).join("ui.json"));
}
let (name, lower) = if legacy { ("DrawCraft", "drawcraft") } else { ("VectorCraft", "vectorcraft") };
let base = match os {
"macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support").join(name)),
"windows" => env("APPDATA").map(|p| PathBuf::from(p).join(name)),
_ => env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join(lower)),
};
base.map(|p| p.join("ui.json"))
}
#[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_has_no_legacy_fallback_on_any_platform() {
for os in ["macos", "windows", "linux"] {
let env = |key: &str| {
if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }
};
assert_eq!(prefs_path_with(os, false, env), Some("/isolated/ui.json".into()));
assert_eq!(prefs_path_with(os, true, env), None);
}
}
#[test]
fn empty_override_does_not_fall_back_to_user_files() {
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) };
assert_eq!(prefs_path_with("macos", false, env), None);
assert_eq!(prefs_path_with("macos", true, env), None);
}
#[test]
fn unset_override_keeps_platform_paths_and_legacy_migration() {
assert_eq!(prefs_path_with("macos", false, environment), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("macos", true, environment), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
assert_eq!(prefs_path_with("windows", false, environment), Some("/roaming/VectorCraft/ui.json".into()));
assert_eq!(prefs_path_with("linux", false, environment), Some("/xdg-config/vectorcraft/ui.json".into()));
assert_eq!(
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }),
Some("/user/.config/drawcraft/ui.json".into())
);
}
}

View File

@ -924,7 +924,7 @@ impl Renderer {
fn isolation_path(&mut self, f: &Frame) -> Option<(NodeId, Vec<NodeId>)> {
let isolated = f.opts.isolated.filter(|_| !self.isolation_settled)?;
let doc = std::ptr::from_ref(f.doc) as usize;
if !self.isolation.as_ref().is_some_and(|(stamp, d, id, _)| (*stamp, *d, *id) == (self.stamp, doc, isolated)) {
if self.isolation.as_ref().is_none_or(|(stamp, d, id, _)| (*stamp, *d, *id) != (self.stamp, doc, isolated)) {
self.isolation = Some((self.stamp, doc, isolated, f.doc.ancestry(isolated).unwrap_or_default()));
}
let path = self.isolation.as_ref().map(|(.., path)| path.clone()).filter(|p| !p.is_empty())?;

View File

@ -9,6 +9,7 @@ description = "VectorCraft egui frontend (menus, panels, tools and canvas); swap
workspace = true
[dependencies]
craft-ui = { workspace = true }
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
@ -32,6 +33,7 @@ serde_json = { workspace = true }
log = { workspace = true }
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
vectorcraft-testkit = { workspace = true }
wat = { workspace = true }

View File

@ -47,13 +47,7 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
}
ui.add_space(2.0);
// With the macOS menu bar the menus are at the top of the screen instead.
let menus_end = if app.services.native_menu.is_some() {
let full = ui.max_rect();
ui.painter().text(full.center(), egui::Align2::CENTER_CENTER, "VectorCraft", egui::FontId::proportional(13.5), t.text);
ui.cursor().min.x
} else {
menus::menu_bar(app, ui)
};
let menus_end = if app.services.native_menu.is_some() { ui.cursor().min.x } else { menus::menu_bar(app, ui) };
// The right-side group fills the space after the menus from the right; when it runs
// short, Discord goes first (it is also under Help), then the search icon (the palette
// stays under its shortcut), then the workspace switcher narrows.
@ -97,6 +91,14 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.add_space(10.0);
crate::community::discord_button(app, ui, false);
}
if app.services.native_menu.is_some() {
let title = ui.painter().layout_no_wrap("VectorCraft".into(), egui::FontId::proportional(13.5), t.text);
let title_rect = egui::Rect::from_center_size(full.center(), title.size());
// Keep the title centered only while the interactive groups leave room for it.
if title_rect.left() >= menus_end + 8.0 && title_rect.right() <= ui.min_rect().left() - 8.0 {
ui.painter().galley(title_rect.min, title, t.text);
}
}
});
});
if custom {
@ -153,7 +155,9 @@ pub enum AnchorControls {
/// Which anchor controls show for the selection and the active tool.
pub fn anchor_controls(app: &VectorcraftApp) -> AnchorControls {
let Some(st) = app.session.active() else { return AnchorControls::None };
let Some(st) = app.session.active() else {
return AnchorControls::None;
};
if !st.selection.anchors.is_empty() {
return AnchorControls::All;
}
@ -229,7 +233,9 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let first = sel.first().and_then(|id| st.doc.node(*id)).cloned();
let anchors = anchor_controls(app);
let label = match &first {
Some(_) if sel.len() == 1 && anchors != AnchorControls::None => tl!("Anchor Point"),
Some(_) if sel.len() == 1 && anchors != AnchorControls::None => {
tl!("Anchor Point")
}
Some(vectorcraft_doc::Node { kind: NodeKind::Image(im), .. }) if sel.len() == 1 => {
if im.link.is_some() {
tl!("Linked File")
@ -237,7 +243,9 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
tl!("Embedded")
}
}
Some(n) if sel.len() == 1 && crate::panels::image_trace::is_trace(n) => tl!("Image Tracing"),
Some(n) if sel.len() == 1 && crate::panels::image_trace::is_trace(n) => {
tl!("Image Tracing")
}
_ => tl!(crate::panels::appearance::object_label(app)),
};
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
@ -571,11 +579,12 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let more = app.background.jobs.len() - 1;
let label = crate::i18n::msg(&job.label);
let text = if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") };
ui.label(egui::RichText::new(text).size(11.0).color(t.text));
ui.add(egui::Spinner::new().size(12.0).color(t.accent));
ui.add(egui::Label::new(egui::RichText::new(text).size(11.0).color(t.text)).truncate());
} else if !app.ui.status.is_empty() {
// The message stays English in `ui.status` (agents and tests read it).
ui.label(egui::RichText::new(crate::i18n::msg(&app.ui.status)).size(11.0).color(t.text));
let message = crate::i18n::msg(&app.ui.status);
ui.add(egui::Label::new(egui::RichText::new(message).size(11.0).color(t.text)).truncate());
}
});
});
@ -1070,3 +1079,161 @@ mod tests {
assert_eq!(app.last_window_title, "foo.svg \u{2014} VectorCraft");
}
}
#[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
}
struct NativeMenus;
impl crate::native_menu::Backend for NativeMenus {
fn sync(&mut self, _: &crate::native_menu::MenuBar) {}
fn drain(&mut self) -> Vec<crate::native_menu::Event> {
Vec::new()
}
}
#[test]
fn capture_chrome_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 theme::Brightness::ALL {
for width in [800.0, 1280.0] {
for scale in [1.0, 1.5, 2.0] {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.services.native_menu = Some(crate::native_menu::NativeMenu::new(Box::new(NativeMenus)));
app.ui.status = format!("Saved /Users/example/Documents/{}/illustration.svg", "long-folder-name/".repeat(10));
let mut ready = false;
let mut harness =
egui_kittest::Harness::builder().with_size(vec2(width, 200.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !ready {
egui_extras::install_image_loaders(ui.ctx());
theme::install_fonts(ui.ctx());
theme::apply(ui.ctx(), brightness);
ready = true;
return;
}
app_bar(app, ui);
status_bar(app, ui);
},
app,
);
harness.input_mut().max_texture_side = Some(8192);
harness.run_steps(4);
let stem = format!("vectorcraft-chrome-{brightness:?}-{width}-{scale}x");
harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap();
let mut regions = serde_json::Map::new();
for id in ["app_bar", "status_bar"] {
let rect = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new(id)).unwrap().outer_rect;
regions.insert(id.into(), json!([rect.left(), rect.top(), rect.width(), rect.height()]));
}
let dynamic: Vec<_> = harness
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
egui::Shape::Text(text) if text.galley.job.text.contains(" fps") => {
let r = text.visual_bounding_rect();
Some(json!([r.left(), r.top(), r.width(), r.height()]))
}
_ => None,
})
.collect();
std::fs::write(
directory.join(format!("{stem}.json")),
serde_json::to_vec_pretty(&json!({
"screen": [width, 200.0], "scale": scale, "regions": regions, "native_dynamic_text_regions": dynamic
}))
.unwrap(),
)
.unwrap();
}
}
}
}
#[test]
fn native_title_does_not_overlap_header_controls() {
for brightness in theme::Brightness::ALL {
for width in [800.0, 1280.0] {
for scale in [1.0, 1.5, 2.0] {
let ctx = egui::Context::default();
egui_extras::install_image_loaders(&ctx);
theme::install_fonts(&ctx);
theme::apply(&ctx, brightness);
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.services.native_menu = Some(crate::native_menu::NativeMenu::new(Box::new(NativeMenus)));
let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| app_bar(&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 texts: Vec<_> = output
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
egui::Shape::Text(text) => Some(text),
_ => None,
})
.collect();
let title = texts.iter().find(|text| text.galley.job.text == "VectorCraft");
if width >= 1280.0 {
assert!(title.is_some(), "the title remains visible when the header has room");
}
if let Some(title) = title {
for text in texts.iter().filter(|text| text.galley.job.text != "VectorCraft") {
assert!(
!title.visual_bounding_rect().intersects(text.visual_bounding_rect()),
"{width}: title overlaps {:?}",
text.galley.job.text
);
}
}
}
}
}
}
#[test]
fn long_status_stays_between_navigation_and_performance_readouts() {
for brightness in theme::Brightness::ALL {
for width in [800.0, 1280.0] {
for scale in [1.0, 1.5, 2.0] {
let ctx = egui::Context::default();
egui_extras::install_image_loaders(&ctx);
theme::install_fonts(&ctx);
theme::apply(&ctx, brightness);
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.ui.status = format!("Saved /Users/example/Documents/{}/illustration.svg", "long-folder-name/".repeat(10));
let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| status_bar(&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 texts: Vec<_> = output
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
egui::Shape::Text(text) => Some(text),
_ => None,
})
.collect();
let status = texts.iter().find(|text| text.galley.job.text.starts_with("Saved ")).expect("status stays visible");
assert_eq!(status.galley.rows.len(), 1, "status is one line");
assert!(status.galley.elided, "long status retains the full text for the label tooltip");
let bounds = status.visual_bounding_rect();
assert!(bounds.left() >= 0.0 && bounds.right() <= width, "{width}: {bounds:?}");
for text in texts.iter().filter(|text| !text.galley.job.text.starts_with("Saved ")) {
assert!(!bounds.intersects(text.visual_bounding_rect()), "{width}: status overlaps {:?}", text.galley.job.text);
}
}
}
}
}
}

View File

@ -36,7 +36,7 @@ pub fn set_collapsed(app: &mut VectorcraftApp, collapsed: bool) {
/// The strip along the top of a dock column with its double arrow: » collapses the dock to icons,
/// « expands it. The whole strip is the click target (as on the toolbar). True when clicked.
fn collapse_header(ui: &mut Ui, collapsed: bool) -> bool {
pub(crate) fn collapse_header(ui: &mut Ui, collapsed: bool) -> bool {
let t = Tokens::get(ui.ctx());
let (hdr, resp) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER), Sense::click());
ui.painter().rect_filled(hdr, 0.0, t.tab_strip);
@ -59,7 +59,7 @@ fn toggle(app: &mut VectorcraftApp, collapsed: bool) {
/// One icon of the column: a click pops its panel out (or puts it away); dragged out of the
/// column (`column`), the panel floats.
fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &str, icon: &str, column: Rect) {
fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &str, icon: &str, _column: Rect) {
let open = app.ui.open_panel.as_deref() == Some(id);
// Its own id: the icons below it move up when it floats, and mustn't take over its drag.
let (_, rect) = ui.allocate_space(vec2(30.0, 30.0));
@ -67,12 +67,13 @@ fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &s
if resp.clicked() {
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
}
if resp.dragged() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !column.contains(p)) {
floating::tear(app, ui.ctx(), &[id], id, floating::strip_grab(ui.ctx(), resp.rect.left()));
}
crate::panel_docking::legacy_tab(app, ui, id, &resp);
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if crate::panel_docking::show(app, ui) {
return;
}
let t = Tokens::get(ui.ctx());
let shown = floating::shown_tab(&app.ui);
// Main tabbed group, unless it is collapsed to icons or all its panels float.
@ -163,36 +164,36 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool {
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
let out = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !strip.contains(p));
let outside = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !strip.contains(p));
// What a drag out of the strip floats: the panels, the one shown, where its tab began.
let mut tear: Option<(Vec<&'static str>, &'static str, f32)> = None;
let mut x = strip.left();
for tab in DockTab::ALL {
let (id, label, _) = tab.info();
if floating::group_of(&app.ui, id).is_some() {
continue;
}
let galley =
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.5), if tab == active { t.text_strong } else { t.text_dim });
let r = Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0));
let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click_and_drag());
if tab == active {
ui.painter().rect_filled(r, 0.0, t.panel);
}
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
let tabs: Vec<_> = floating::docked_tabs(&app.ui).collect();
let names: Vec<_> = tabs
.iter()
.map(|tab| {
let (id, label, _) = tab.info();
(id, label)
})
.collect();
let selected = tabs.iter().position(|&tab| tab == active).unwrap_or(0);
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
let area = Rect::from_min_max(strip.min, egui::pos2((menu.left() - 4.0).max(strip.left()), strip.bottom() - 1.0));
let out = panel_tabs(ui, "docktab", area, &names, selected, theme::semibold(12.5), true);
for (i, resp) in out.responses {
let Some(&tab) = tabs.get(i) else { continue };
let id = tab.info().0;
if resp.clicked() {
app.ui.dock_tab = tab;
} else if resp.dragged() && out {
tear = Some((vec![id], id, r.left()));
}
x = r.right();
crate::panel_docking::legacy_tab(app, ui, id, &resp);
}
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
let rest = Rect::from_min_max(egui::pos2(x, strip.top()), egui::pos2(menu.left() - 4.0, strip.bottom()));
if let Some(tab) = out.picked.and_then(|i| tabs.get(i)).copied() {
app.ui.dock_tab = tab;
}
let rest = Rect::from_min_max(egui::pos2(out.right, strip.top()), egui::pos2(menu.left() - 4.0, strip.bottom()));
if rest.width() > 0.0 {
let resp = ui.interact(rest, ui.id().with("dock-group-bar"), Sense::drag());
if resp.dragged() && out {
if resp.dragged() && outside {
tear = Some((floating::docked_tabs(&app.ui).map(|t| t.info().0).collect(), active.info().0, strip.left()));
}
resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group"));
@ -205,6 +206,92 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool {
floating::group_of(&app.ui, active.info().0).is_some()
}
/// App styling and overflow menus around the shared tab fitting/interaction components. Docking
/// and floating groups consume the responses themselves, preserving their different drag rules.
pub(crate) struct PanelTabs {
pub responses: Vec<(usize, egui::Response)>,
pub picked: Option<usize>,
pub right: f32,
}
pub(crate) fn panel_tabs(
ui: &mut Ui,
id: &str,
area: Rect,
tabs: &[(&str, &str)],
selected: usize,
font: egui::FontId,
separators: bool,
) -> PanelTabs {
let t = Tokens::get(ui.ctx());
let labels: Vec<_> = tabs.iter().map(|&(_, label)| tl!(label)).collect();
let galleys: Vec<_> = labels
.iter()
.enumerate()
.map(|(i, label)| ui.painter().layout_no_wrap((*label).to_string(), font.clone(), if i == selected { t.text_strong } else { t.text_dim }))
.collect();
let natural: Vec<_> = galleys.iter().map(|g| g.size().x + 24.0).collect();
let fit = craft_ui::tabs::fit(&natural, selected, area.width(), 40.0, 18.0);
let mut out = PanelTabs { responses: Vec::with_capacity(fit.shown.len()), picked: None, right: area.left() };
for (i, width) in fit.shown {
let Some((key, _)) = tabs.get(i) else { continue };
let Some(label) = labels.get(i) else { continue };
let Some(galley) = galleys.get(i) else { continue };
let rect = Rect::from_min_size(egui::pos2(out.right, area.top()), vec2(width, area.height()));
let shrunk = natural.get(i).is_some_and(|natural| *natural > width + 0.5);
let galley = if shrunk {
let mut job =
egui::text::LayoutJob::simple_singleline((*label).to_string(), font.clone(), if i == selected { t.text_strong } else { t.text_dim });
job.wrap =
egui::text::TextWrapping { max_width: (width - 8.0).max(1.0), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
ui.painter().layout_job(job)
} else {
galley.clone()
};
let resp = craft_ui::tabs::Tab::new(ui.id().with((id, key)), label, i == selected)
.sense(Sense::click_and_drag())
.focus_stroke(Stroke::new(1.0, t.accent))
.show_at(ui, rect, |ui, _| {
if i == selected {
ui.painter().rect_filled(rect, 0.0, t.panel);
}
ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())).galley(rect.center() - galley.size() / 2.0, galley, t.text);
if separators {
ui.painter().line_segment([rect.right_top(), rect.right_bottom()], Stroke::new(1.0, t.border));
}
});
out.responses.push((i, if shrunk { resp.on_hover_text(*label) } else { resp }));
out.right = rect.right();
}
if !fit.overflow.is_empty() && fit.overflow_width > 0.0 {
let rect = Rect::from_min_size(egui::pos2(out.right, area.top()), vec2(fit.overflow_width, area.height()));
let resp = ui.interact(rect, ui.id().with((id, "overflow")), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(rect, 0.0, t.hover);
}
if resp.has_focus() && rect.width() > 4.0 {
ui.painter().rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
}
icons::paint(ui, "chevrons-right", Rect::from_center_size(rect.center(), vec2(12.0, 12.0)).intersect(rect), t.text);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Panels")));
let resp = resp.on_hover_text(tl!("Panels"));
egui::Popup::menu(&resp).show(|ui| {
widgets::menu_scroll(ui, |ui| {
for i in fit.overflow {
if let Some(label) = labels.get(i)
&& ui.button(*label).clicked()
{
out.picked = Some(i);
ui.close();
}
}
});
});
out.right = rect.right();
}
out
}
/// A panel popped out next to the icon column: an icon panel, or one of the tabbed group's panels
/// while the dock is collapsed. Dragging its title floats it.
pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
@ -325,6 +412,98 @@ mod tests {
use super::*;
use crate::toolbar::tests::{wheel, widget_rects};
#[test]
fn tab_headers_fit_narrow_widths_keep_the_selected_tab_and_clip_labels() {
let ctx = egui::Context::default();
egui_extras::install_image_loaders(&ctx);
theme::install_fonts(&ctx);
theme::apply(&ctx, theme::Brightness::MediumDark);
let names = [("one", "Long panel name"), ("two", "Another long panel name"), ("three", "Layers")];
for width in [0.0, 1.0, 5.0, 18.0, 19.0, 80.0, 180.0, 300.0] {
for selected in 0..names.len() {
let area = Rect::from_min_size(egui::pos2(20.0, 20.0), vec2(width, 32.0));
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
let out = panel_tabs(ui, "fit-test", area, &names, selected, theme::semibold(12.5), true);
assert!(out.responses.iter().any(|(i, _)| *i == selected), "selected index {selected} stays on the strip");
assert!(out.right <= area.right() + 0.001, "{width}: strip ends at {}", out.right);
for (_, response) in &out.responses {
if response.rect.is_positive() {
assert!(area.contains_rect(response.rect), "{width}: {:?}", response.rect);
}
}
});
output.textures_delta.clear();
for shape in output.shapes.iter().filter(|s| matches!(s.shape, egui::Shape::Text(_))) {
assert!(shape.clip_rect.right() <= area.right() + 0.001, "{width}: a label is clipped to its tab");
}
if width > 0.0 && width < 18.0 {
assert!(
output.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(rect) if rect.brush.is_some())),
"the actual overflow SVG was painted"
);
}
}
}
}
#[test]
fn overflow_menu_can_activate_a_hidden_panel() {
crate::i18n::set_current(crate::i18n::Lang::EN);
let ctx = egui::Context::default();
ctx.enable_accesskit();
egui_extras::install_image_loaders(&ctx);
theme::install_fonts(&ctx);
theme::apply(&ctx, theme::Brightness::MediumDark);
let names = [("properties", "Properties"), ("layers", "Layers"), ("libraries", "Libraries")];
let mut selected = 0;
let mut time = 0.0;
let mut frame = |events| {
time += 0.1;
let input = egui::RawInput {
events,
time: Some(time),
screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 300.0))),
..Default::default()
};
let mut output = ctx.run_ui(input, |ui| {
let area = Rect::from_min_size(egui::pos2(20.0, 20.0), vec2(90.0, 32.0));
let out = panel_tabs(ui, "overflow-test", area, &names, selected, theme::semibold(12.5), true);
if let Some(picked) = out.picked {
selected = picked;
}
});
output.textures_delta.clear();
output
};
let button = |output: &egui::FullOutput, label: &str| {
let bounds = output
.platform_output
.accesskit_update
.as_ref()
.unwrap()
.nodes
.iter()
.find(|(_, n)| n.label() == Some(label) && n.role() == egui::accesskit::Role::Button)
.unwrap()
.1
.bounds()
.unwrap();
egui::pos2((bounds.x0 + bounds.x1) as f32 * 0.5, (bounds.y0 + bounds.y1) as f32 * 0.5)
};
let press =
|pos, pressed| egui::Event::PointerButton { pos, pressed, button: egui::PointerButton::Primary, modifiers: egui::Modifiers::NONE };
frame(vec![]);
let output = frame(vec![]);
let overflow = button(&output, "Panels");
frame(vec![egui::Event::PointerMoved(overflow), press(overflow, true)]);
frame(vec![press(overflow, false)]);
let output = frame(vec![]);
let libraries = button(&output, "Libraries");
frame(vec![egui::Event::PointerMoved(libraries), press(libraries, true)]);
frame(vec![press(libraries, false)]);
assert_eq!(selected, 2);
}
#[test]
fn every_flyout_stays_left_of_the_icon_column() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());

View File

@ -35,7 +35,7 @@ const TRAVEL: f32 = 4.0;
/// What a move carries.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Moving {
pub(crate) enum Moving {
/// The floating group holding this panel.
Panel(&'static str),
/// The floating Tools panel.
@ -44,16 +44,17 @@ enum Moving {
/// A move in progress: what moves, where the pointer holds it from its top-left corner, and where
/// the press began.
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Debug)]
struct Move {
what: Moving,
grab: Vec2,
from: Pos2,
original: UiState,
}
/// Where a moved group or Tools panel lands.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Drop {
pub(crate) enum Drop {
/// Back in the dock (the Tools panel: at the window's left edge).
Dock,
/// Stacked with the floating group holding this panel.
@ -172,9 +173,9 @@ pub fn clamp(pos: [f32; 2], size: Vec2, screen: Rect) -> Pos2 {
}
/// Start moving `what` with the pointer, held `grab` from its top-left corner.
fn start(ctx: &egui::Context, what: Moving, grab: Vec2) {
fn start(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, grab: Vec2) {
let Some(from) = ctx.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())) else { return };
ctx.data_mut(|d| d.insert_temp(move_id(), Move { what, grab, from }));
ctx.data_mut(|d| d.insert_temp(move_id(), Move { what, grab, from, original: app.ui.clone() }));
}
/// Where the pointer holds a group torn off by a press `left` points right of where the group's
@ -187,9 +188,25 @@ pub(crate) fn strip_grab(ctx: &egui::Context, left: f32) -> Vec2 {
/// Float `ids` under the pointer, held `grab` from the new group's top-left corner, and carry on
/// moving them with the pointer (a tab or group dragged out of the dock or out of its group).
pub(crate) fn tear(app: &mut VectorcraftApp, ctx: &egui::Context, ids: &[&'static str], active: &'static str, grab: Vec2) {
// A restored popout may retain its drag response in the cancellation frame. Do not
// immediately tear it out again after track restored the gesture's original layout.
if ctx.input(|input| !input.focused || input.key_pressed(egui::Key::Escape)) {
return;
}
let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return };
float(&mut app.ui, ids, active, at - grab);
start(ctx, Moving::Panel(active), grab);
let original = app.ui.clone();
let pos = at - grab;
if let Err(error) = app.run("window.panel.float", json!({"panel":active,"group":ids.len() > 1,"x":pos.x,"y":pos.y})) {
app.ui.status = error;
return;
}
start(app, ctx, Moving::Panel(active), grab);
ctx.data_mut(|data| {
if let Some(mut drag) = data.get_temp::<Move>(move_id()) {
drag.original = original;
data.insert_temp(move_id(), drag);
}
});
}
/// The Tools panel's title bar was pressed (`title`, the panel's bounds `bounds`): docked, dragging
@ -197,27 +214,29 @@ pub(crate) fn tear(app: &mut VectorcraftApp, ctx: &egui::Context, ids: &[&'stati
pub(crate) fn drag_tools(app: &mut VectorcraftApp, ctx: &egui::Context, title: &egui::Response, bounds: Rect, floating: Option<Pos2>) {
let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return };
match floating {
Some(pos) if title.drag_started() => start(ctx, Moving::Tools, at - pos),
Some(pos) if title.drag_started() => start(app, ctx, Moving::Tools, at - pos),
None if title.dragged() && !bounds.contains(at) => {
// Held where the press grabbed the title bar.
let grab = ctx.input(|i| i.pointer.press_origin()).map_or(vec2(10.0, 7.0), |p| p - bounds.min);
let p = at - grab;
app.ui.toolbar_pos = Some([p.x, p.y]);
start(ctx, Moving::Tools, grab);
start(app, ctx, Moving::Tools, grab);
}
_ => {}
}
}
/// Where the pointer at `at` would drop `what`, and the zone that lights up for it.
fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2) -> Option<(Drop, Rect)> {
pub(crate) fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2) -> Option<(Drop, Rect)> {
let zone = |id: Id| ctx.data(|d| d.get_temp::<Rect>(id)).filter(|r| r.contains(at));
let Moving::Panel(id) = what else { return zone(tools_zone_id()).map(|r| (Drop::Dock, r)) };
let own = group_of(&app.ui, id);
// Another group's title bar and tabs stack with it.
for (i, g) in app.ui.floating_panels.iter().enumerate() {
let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| Some(i) != own) else { continue };
let head = ctx.memory(|m| m.area_rect(area_id(first))).map(|r| Rect::from_min_size(r.min, vec2(r.width(), 1.0 + TITLE + STRIP)));
let area =
if app.ui.docking.is_some() { Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string())) } else { area_id(first) };
let head = ctx.memory(|m| m.area_rect(area)).map(|r| Rect::from_min_size(r.min, vec2(r.width(), 1.0 + TITLE + STRIP)));
if let Some(head) = head.filter(|h| h.contains(at)) {
return Some((Drop::Stack(first), head));
}
@ -232,7 +251,7 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2
}
/// Light up a drop zone: over the docked panels, under the floating ones (the moved one too).
fn highlight(ctx: &egui::Context, zone: Rect) {
pub(crate) fn highlight(ctx: &egui::Context, zone: Rect) {
let t = Tokens::get(ctx);
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Background, Id::new("floating-drop-zone")));
painter.rect(zone.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.2), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
@ -242,6 +261,21 @@ fn highlight(ctx: &egui::Context, zone: Rect) {
/// follows the pointer and the zone it would drop on lights up; the release drops it there.
pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(m) = ctx.data(|d| d.get_temp::<Move>(move_id())) else { return };
if m.original.docking_generation != app.ui.docking_generation {
ctx.data_mut(|data| data.remove::<Move>(move_id()));
return;
}
if ctx.input(|input| !input.focused || input.key_pressed(egui::Key::Escape)) {
app.ui.docking = m.original.docking;
app.ui.docking_hidden = m.original.docking_hidden;
app.ui.docking_icons = m.original.docking_icons;
app.ui.floating_panels = m.original.floating_panels;
app.ui.open_panel = m.original.open_panel;
app.ui.dock_tab = m.original.dock_tab;
app.ui.toolbar_pos = m.original.toolbar_pos;
ctx.data_mut(|data| data.remove::<Move>(move_id()));
return;
}
let at = ctx.input(|i| i.pointer.interact_pos());
let target = at.filter(|at| at.distance(m.from) > TRAVEL).and_then(|at| drop_target(app, ctx, m.what, at));
if ctx.input(|i| i.pointer.primary_down()) {
@ -250,6 +284,11 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
&& p.y.is_finite()
{
match m.what {
Moving::Panel(id) if app.ui.docking.is_some() => {
if let Err(error) = app.run("window.panel.float", json!({"panel":id,"group":true,"x":p.x,"y":p.y})) {
app.ui.status = error;
}
}
Moving::Panel(id) => {
if let Some(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get_mut(i)) {
g.pos = [p.x, p.y];
@ -270,6 +309,16 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
app.ui.toolbar_pos = None;
return;
};
if app.ui.docking.is_some() {
let (command, params) = match drop {
Drop::Dock => ("window.panel.dock", json!({"panel":id,"group":true})),
Drop::Stack(onto) => ("window.panel.float", json!({"panel":id,"group":true,"onto":onto})),
};
if let Err(error) = app.run(command, params) {
app.ui.status = error;
}
return;
}
let Some(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get(i)) else { return };
let ids = ids_of(g);
let active = ids.get(g.active).copied().unwrap_or(id);
@ -300,6 +349,9 @@ enum Action {
/// The floating groups, each a stack of tabs under a title bar. Hidden with the dock (Tab,
/// Presentation Mode).
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
if app.ui.docking.is_some() {
return;
}
if app.ui.floating_panels.is_empty() || !app.ui.dock || app.ui.screen_mode >= 3 {
return;
}
@ -357,25 +409,25 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
// Tab strip: a click shows a tab, a drag out of the strip floats it on its own.
let (strip, _) = ui.allocate_exact_size(vec2(width, STRIP), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.panel_darker);
let mut left = strip.left();
for (k, (&id, galley)) in ids.iter().zip(tabs).enumerate() {
let r = Rect::from_min_size(pos2(left, strip.top()), vec2(galley.size().x + 24.0, STRIP));
left = r.right();
let resp = ui.interact(r, ui.id().with(("tab", id)), Sense::click_and_drag());
if id == active {
ui.painter().rect_filled(r, 0.0, t.panel);
}
ui.painter().galley(pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
let tab_area = Rect::from_min_max(strip.min, pos2((menu.left() - 4.0).max(strip.left()), strip.bottom()));
let names: Vec<_> = ids.iter().map(|&id| (id, panel(id).map_or(id, |(_, label)| label))).collect();
let selected = ids.iter().position(|&id| id == active).unwrap_or(0);
let out = dock::panel_tabs(ui, "tab", tab_area, &names, selected, theme::semibold(12.0), false);
for (k, resp) in out.responses {
let Some(&id) = ids.get(k) else { continue };
if resp.clicked() {
action = Some(Action::Activate(gi, k));
} else if lone && resp.drag_started() {
action = Some(Action::Move(id, pos));
} else if !lone && resp.dragged() && pointer.is_some_and(|p| !strip.expand(4.0).contains(p)) {
action = Some(Action::Tear(id, r.left()));
action = Some(Action::Tear(id, resp.rect.left()));
}
}
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
let rest = Rect::from_min_max(pos2(left, strip.top()), pos2(menu.left() - 4.0, strip.bottom()));
if let Some(k) = out.picked {
action = Some(Action::Activate(gi, k));
}
let rest = Rect::from_min_max(pos2(out.right, strip.top()), pos2(menu.left() - 4.0, strip.bottom()));
if rest.width() > 0.0 {
let resp = ui.interact(rest, ui.id().with("strip"), Sense::drag());
if resp.drag_started() {
@ -407,7 +459,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
g.pos = [pos.x, pos.y];
}
if let Some(at) = pointer {
start(ctx, Moving::Panel(id), at - pos);
start(app, ctx, Moving::Panel(id), at - pos);
}
}
Some(Action::Tear(id, left)) => tear(app, ctx, &[id], id, strip_grab(ctx, left)),
@ -528,7 +580,9 @@ mod tests {
fn frame(&mut self, events: Vec<Event>) {
self.time += 0.05;
let screen = Rect::from_min_size(Pos2::ZERO, self.screen);
let raw = egui::RawInput { time: Some(self.time), screen_rect: Some(screen), events, ..Default::default() };
let focused =
events.iter().rev().find_map(|event| if let Event::WindowFocused(focused) = event { Some(*focused) } else { None }).unwrap_or(true);
let raw = egui::RawInput { time: Some(self.time), screen_rect: Some(screen), focused, events, ..Default::default() };
let app = &mut self.app;
self.ctx
.run_ui(raw, |ui| {
@ -575,11 +629,20 @@ mod tests {
/// The floating group whose first panel is `first`, as drawn.
fn group(&self, first: &str) -> Rect {
self.ctx.memory(|m| m.area_rect(area_id(first))).unwrap()
let id = if self.app.ui.docking.is_some() {
Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string()))
} else {
area_id(first)
};
self.ctx.memory(|m| m.area_rect(id)).unwrap()
}
/// A point on a floating group's title bar (left of its ×).
fn title(&self, first: &str) -> Pos2 {
if self.app.ui.docking.is_some() {
let rect = self.group(first);
return rect.left_top() + vec2(6.0, 16.0);
}
let r = self.group(first);
pos2(r.left() + 30.0, r.top() + 1.0 + TITLE / 2.0)
}
@ -593,15 +656,29 @@ mod tests {
r
}
fn shared_tab(&self, panel: &str) -> Rect {
self.ctx.read_response(Id::new("vectorcraft-panel-docking").with(("tab", &panel.to_string()))).unwrap().rect
}
/// The dock's tabs, left to right.
fn dock_tabs(&self) -> Vec<Rect> {
if let Some(layout) = &self.app.ui.docking {
let root = craft_ui::docking::Layout { root: layout.root.clone(), floating: Vec::new() };
return root
.panels()
.into_iter()
.filter_map(|panel| {
self.ctx.read_response(Id::new("vectorcraft-panel-docking").with(("tab", panel))).map(|response| response.rect)
})
.collect();
}
let dock = self.temp_rect(dock_rect_id());
self.widgets(|r| r.height() == 32.0 && dock.contains_rect(r))
}
/// The icon column's panel icons, top to bottom.
fn icons(&self) -> Vec<Rect> {
let dock = self.temp_rect(dock_rect_id());
let dock = self.temp_rect(if self.app.ui.docking.is_some() { icons_rect_id() } else { dock_rect_id() });
self.widgets(|r| r.size() == vec2(30.0, 30.0) && dock.contains_rect(r))
}
@ -614,30 +691,19 @@ mod tests {
fn a_dock_tab_dragged_out_floats_and_dropped_on_the_dock_docks_again() {
let mut h = Harness::new();
let tabs = h.dock_tabs();
assert_eq!(tabs.len(), 3, "Properties, Layers, Libraries: {tabs:?}");
assert_eq!(tabs.len(), 3);
let to = pos2(600.0, 400.0);
h.drag(tabs[1].center(), to);
assert_eq!(h.groups(), [["layers"]], "Layers floats");
assert!(h.group("layers").contains(to), "under the pointer: {:?}", h.group("layers"));
assert_eq!(h.dock_tabs().len(), 2, "its tab left the dock");
assert_eq!(h.groups(), [["layers"]]);
assert!(h.group("layers").contains(to));
assert_eq!(h.dock_tabs().len(), 2);
assert_eq!(h.app.ui.dock_tab, DockTab::Properties);
// A click on its title bar moves nothing, even over the dock.
let dock = h.temp_rect(dock_rect_id());
h.app.run("window.panel.float", json!({"panel": "layers", "x": dock.left() + 10.0, "y": 300})).unwrap();
h.settle();
h.click(h.title("layers"));
assert_eq!(h.groups(), [["layers"]], "a click doesn't dock it");
// Dragged by its title bar onto the dock, the dock lights up, and the release docks it.
h.app.run("window.panel.float", json!({"panel": "layers", "x": 500, "y": 300})).unwrap();
h.settle();
let over = dock.center();
h.hold(h.title("layers"), over);
assert!(h.group("layers").contains(over), "the group follows the pointer");
let target = drop_target(&h.app, &h.ctx, Moving::Panel("layers"), over);
assert_eq!(target, Some((Drop::Dock, dock)), "the dock is the drop zone, lit whole: its tab comes back");
h.release(over);
assert!(h.groups().is_empty(), "docked: {:?}", h.groups());
assert_eq!((h.dock_tabs().len(), h.app.ui.dock_tab), (3, DockTab::Layers), "back in the dock, shown");
assert_eq!(h.groups(), [["layers"]], "a click only activates the floating tab");
let over = h.shared_tab("properties").center();
h.drag(h.title("layers"), over);
assert!(h.groups().is_empty());
assert_eq!((h.dock_tabs().len(), h.app.ui.dock_tab), (3, DockTab::Layers));
}
#[test]
@ -652,7 +718,9 @@ mod tests {
assert!(column.width() < 40.0, "only the icon column is left: {column:?}");
// Its × puts them back.
let g = h.group("properties");
h.click(pos2(g.right() - 11.0, g.top() + 1.0 + TITLE / 2.0));
let close =
if h.app.ui.docking.is_some() { pos2(g.right() - 36.0, g.top() + 16.0) } else { pos2(g.right() - 11.0, g.top() + 1.0 + TITLE / 2.0) };
h.click(close);
assert!(h.groups().is_empty());
assert_eq!(h.dock_tabs().len(), 3);
}
@ -668,32 +736,40 @@ mod tests {
assert_eq!(count, crate::state::ICON_PANEL_GROUPS.iter().map(|g| g.len()).sum::<usize>(), "every group's icons are drawn");
h.drag(icons[2].center(), pos2(500.0, 250.0));
assert_eq!(h.groups(), [["swatches"]]);
assert_eq!(h.icons().len(), count - 1, "its icon left the column");
h.drag(h.icons()[0].center(), pos2(500.0, 650.0));
assert_eq!(h.icons().len(), count - 1);
let separate = pos2(900.0, 650.0);
assert!(!h.group("swatches").contains(separate));
h.drag(h.icons()[0].center(), separate);
assert_eq!(h.groups(), [["swatches"], ["color"]]);
// Color's title bar dropped on the Swatches group's tabs stacks them.
let onto = h.group("swatches").left_top() + vec2(150.0, 1.0 + TITLE + STRIP / 2.0);
h.hold(h.title("color"), onto);
assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("color"), onto).map(|t| t.0), Some(Drop::Stack("swatches")));
h.release(onto);
let onto = h.shared_tab("swatches").right_center() - vec2(3.0, 0.0);
h.drag(h.title("color"), onto);
assert_eq!(h.groups(), [["swatches", "color"]]);
assert_eq!(h.app.ui.floating_panels[0].active, 1, "showing the panel dropped on it");
// A click shows a tab; a tab dragged out of the strip floats on its own again.
let g = h.group("swatches");
let tabs = h.widgets(|r| r.height() == STRIP && g.contains_rect(r));
assert_eq!(tabs.len(), 3, "two tabs and the strip right of them: {tabs:?}");
h.click(tabs[0].center());
assert_eq!(h.app.ui.floating_panels[0].active, 1);
h.click(h.shared_tab("swatches").center());
assert_eq!(h.app.ui.floating_panels[0].active, 0);
let to = pos2(900.0, 500.0);
h.drag(tabs[1].center(), to);
h.drag(h.shared_tab("color").center(), to);
assert_eq!(h.groups(), [["swatches"], ["color"]]);
assert!(h.group("color").contains(to));
// Window › Color shows it where it floats; Dock Panel puts it back.
assert_eq!(h.app.run("window.panel", json!({"panel": "Color"})).unwrap(), json!({"floating": ["color"]}));
assert_eq!(crate::menus::checked(&h.app, "window.panel", &json!({"panel": "color"})), Some(true));
h.app.run("window.panel.dock", json!({"panel": "color"})).unwrap();
h.settle();
assert!(h.groups().iter().all(|group| !group.contains(&"color")));
assert_eq!(h.icons().len(), count - 1, "Color is back in the column");
assert_eq!(h.app.ui.open_panel.as_deref(), Some("color"));
}
#[test]
fn an_icon_panel_can_be_dropped_into_another_docked_tab_group() {
let mut h = Harness::new();
h.drag(h.icons()[0].center(), pos2(500.0, 400.0));
assert_eq!(h.groups(), [["color"]]);
h.drag(h.shared_tab("color").center(), h.shared_tab("layers").center());
assert!(h.groups().is_empty());
assert!(h.app.ui.docking.as_ref().unwrap().contains(&"color".into()));
assert!(h.app.ui.open_panel.is_none(), "the actual shared group owns Color");
assert_eq!(h.dock_tabs().len(), 4);
}
#[test]
@ -729,6 +805,40 @@ mod tests {
assert!(h.groups().is_empty());
}
#[test]
fn popped_out_title_drag_cancels_on_escape_focus_loss_and_workspace_change() {
for reason in ["escape", "focus", "workspace"] {
let mut h = Harness::new();
h.app.run("window.panel", json!({"panel":"swatches"})).unwrap();
h.settle();
let original = h.app.ui.clone();
let flyout = h.ctx.memory(|m| m.area_rect(Id::new("icon-panel"))).unwrap();
let to = pos2(500.0, 400.0);
h.hold(flyout.left_top() + vec2(20.0, 13.0), to);
assert!(h.app.ui.floating_panels.iter().any(|g| g.panels.contains(&"swatches".into())));
match reason {
"escape" => h.frame(vec![Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed: true,
repeat: false,
modifiers: Default::default(),
}]),
"focus" => h.frame(vec![Event::WindowFocused(false)]),
_ => {
crate::workspaces::apply(&mut h.app.ui, &crate::workspaces::Workspace::default());
h.frame(vec![Event::PointerMoved(pos2(650.0, 450.0))]);
}
}
assert!(h.ctx.data(|d| d.get_temp::<Move>(move_id())).is_none(), "{reason}: stale gesture removed");
assert!(h.app.ui.docking.is_none(), "{reason}: original/selected workspace retained");
assert!(h.app.ui.floating_panels.is_empty(), "{reason}: no stale floating group");
if reason != "workspace" {
assert_eq!(h.app.ui.open_panel, original.open_panel);
}
}
}
#[test]
fn the_tools_panel_floats_by_its_title_bar_and_docks_on_the_left_edge() {
let mut h = Harness::new();

View File

@ -694,3 +694,11 @@
Ocean Ripple… Vlnky na hladině…
Rename Data Set Přejmenovat sadu dat
Rename Variable Přejmenovat proměnnou
# Shared panel docking
Activate Panel Aktivovat panel
Change Panel Layout Změnit rozložení panelů
Close Panel Zavřít panel
Move Panel Přesunout panel
Group with Seskupit s
Resize panels Změnit velikost panelů
Resize panel window Změnit velikost okna panelu

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3994,3 +3994,28 @@ theme Appearance Erscheinungsbild
Define a variable first Definieren Sie zuerst eine Variable
Text Text
Visibility Sichtbarkeit
Activate Panel Bedienfeld aktivieren
Change Panel Layout Bedienfeldanordnung ändern
Close Panel Bedienfeld schließen
Move Panel Bedienfeld verschieben
Group with Gruppieren mit
Resize panel window Bedienfeldfenster skalieren
Resize panels Bedienfelder skalieren
@msg Invalid panel layout; restored the default dock Ungültige Bedienfeldanordnung; Standarddock wiederhergestellt
@msg action is required Eine Aktion ist erforderlich
@msg anchor must name a registered panel Der Anker muss ein registriertes Bedienfeld benennen
@msg before must name a panel before muss ein Bedienfeld benennen
@msg docking action contains an unknown panel or an excessive split path Die Docking-Aktion enthält ein unbekanntes Bedienfeld oder einen zu langen Teilungspfad
@msg group must be true or false group muss true oder false sein
@msg move requires a destination panel Das Verschieben erfordert ein Zielbedienfeld
@msg move requires an existing destination panel Das Verschieben erfordert ein vorhandenes Zielbedienfeld
@msg onto must name a floating panel onto muss ein schwebendes Bedienfeld benennen
@msg panel group is empty Die Bedienfeldgruppe ist leer
@msg panel must name a registered panel panel muss ein registriertes Bedienfeld benennen
@msg saved panel layout is invalid; reset the workspace first Die gespeicherte Bedienfeldanordnung ist ungültig; zuerst den Arbeitsbereich zurücksetzen
@msg x and y must be provided together x und y müssen zusammen angegeben werden
@msg zone must be a string zone muss eine Zeichenfolge sein
@msg zone must be tab, center, left, right, top or bottom zone muss tab, center, left, right, top oder bottom sein
@msg {key} must be a finite coordinate {key} muss eine endliche Koordinate sein
@msg invalid restored panel group Ungültige wiederhergestellte Bedienfeldgruppe

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -4037,3 +4037,28 @@ axis Both Ambos
Define a variable first Defina primero una variable
Text Texto
Visibility Visibilidad
# Shared panel docking
Activate Panel Activar panel
Change Panel Layout Cambiar distribución de paneles
Close Panel Cerrar panel
Move Panel Mover panel
Group with Agrupar con
Resize panels Cambiar tamaño de los paneles
Resize panel window Cambiar tamaño de la ventana del panel
@msg Invalid panel layout; restored the default dock La distribución de paneles no es válida; se restauró el anclaje predeterminado
@msg action is required Se requiere action
@msg anchor must name a registered panel anchor debe indicar un panel registrado
@msg before must name a panel before debe indicar un panel
@msg docking action contains an unknown panel or an excessive split path La acción de anclaje contiene un panel desconocido o una ruta de divisiones demasiado larga
@msg group must be true or false group debe ser true o false
@msg move requires a destination panel Para mover se requiere un panel de destino
@msg move requires an existing destination panel Para mover se requiere un panel de destino existente
@msg onto must name a floating panel onto debe indicar un panel flotante
@msg panel group is empty El grupo de paneles está vacío
@msg panel must name a registered panel panel debe indicar un panel registrado
@msg saved panel layout is invalid; reset the workspace first La distribución guardada de paneles no es válida; primero restablece el espacio de trabajo
@msg zone must be a string zone debe ser una cadena de texto
@msg zone must be tab, center, left, right, top or bottom zone debe ser tab, center, left, right, top o bottom
@msg {key} must be a finite coordinate {key} debe ser una coordenada finita
@msg x and y must be provided together x e y deben proporcionarse juntos
@msg invalid restored panel group El grupo de paneles restaurado no es válido

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -4049,3 +4049,28 @@ effect Soft Doux
Define a variable first Définissez d’abord une variable
Text Texte
Visibility Visibilité
# Shared panel docking
Activate Panel Activer le panneau
Change Panel Layout Modifier la disposition des panneaux
Close Panel Fermer le panneau
Move Panel Déplacer le panneau
Group with Regrouper avec
Resize panels Redimensionner les panneaux
Resize panel window Redimensionner la fenêtre du panneau
@msg Invalid panel layout; restored the default dock La disposition des panneaux est invalide ; la disposition ancrée par défaut a été restaurée
@msg action is required action est requis
@msg anchor must name a registered panel anchor doit désigner un panneau enregistré
@msg before must name a panel before doit désigner un panneau
@msg docking action contains an unknown panel or an excessive split path L’action d’ancrage contient un panneau inconnu ou un chemin de divisions trop long
@msg group must be true or false group doit être true ou false
@msg move requires a destination panel Le déplacement nécessite un panneau de destination
@msg move requires an existing destination panel Le déplacement nécessite un panneau de destination existant
@msg onto must name a floating panel onto doit désigner un panneau flottant
@msg panel group is empty Le groupe de panneaux est vide
@msg panel must name a registered panel panel doit désigner un panneau enregistré
@msg saved panel layout is invalid; reset the workspace first La disposition enregistrée des panneaux est invalide ; réinitialisez d’abord l’espace de travail
@msg zone must be a string zone doit être une chaîne de caractères
@msg zone must be tab, center, left, right, top or bottom zone doit être tab, center, left, right, top ou bottom
@msg {key} must be a finite coordinate {key} doit être une coordonnée finie
@msg x and y must be provided together x et y doivent être fournis ensemble
@msg invalid restored panel group Le groupe de panneaux restauré est invalide

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -4039,3 +4039,28 @@ effect Regular Normale
Define a variable first Definisci prima una variabile
Text Testo
Visibility Visibilità
# Shared panel docking
Activate Panel Attiva pannello
Change Panel Layout Modifica disposizione dei pannelli
Close Panel Chiudi pannello
Move Panel Sposta pannello
Group with Raggruppa con
Resize panels Ridimensiona pannelli
Resize panel window Ridimensiona finestra del pannello
@msg Invalid panel layout; restored the default dock La disposizione dei pannelli non è valida; è stato ripristinato l’ancoraggio predefinito
@msg action is required action è obbligatorio
@msg anchor must name a registered panel anchor deve indicare un pannello registrato
@msg before must name a panel before deve indicare un pannello
@msg docking action contains an unknown panel or an excessive split path L’azione di ancoraggio contiene un pannello sconosciuto o un percorso di divisioni troppo lungo
@msg group must be true or false group deve essere true o false
@msg move requires a destination panel Lo spostamento richiede un pannello di destinazione
@msg move requires an existing destination panel Lo spostamento richiede un pannello di destinazione esistente
@msg onto must name a floating panel onto deve indicare un pannello mobile
@msg panel group is empty Il gruppo di pannelli è vuoto
@msg panel must name a registered panel panel deve indicare un pannello registrato
@msg saved panel layout is invalid; reset the workspace first La disposizione salvata dei pannelli non è valida; reimposta prima l’area di lavoro
@msg zone must be a string zone deve essere una stringa
@msg zone must be tab, center, left, right, top or bottom zone deve essere tab, center, left, right, top o bottom
@msg {key} must be a finite coordinate {key} deve essere una coordinata finita
@msg x and y must be provided together x e y devono essere forniti insieme
@msg invalid restored panel group Il gruppo di pannelli ripristinato non è valido

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -4032,3 +4032,29 @@ effect Vertical 垂直
Define a variable first 先に変数を定義してください
Text テキスト
Visibility 表示状態
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
# Shared panel docking
Activate Panel パネルをアクティブにする
Change Panel Layout パネルの配置を変更
Close Panel パネルを閉じる
Move Panel パネルを移動
Group with グループ化先
Resize panels パネルのサイズを変更
Resize panel window パネルウィンドウのサイズを変更
@msg invalid restored panel group 復元されたパネルグループが無効です
@msg Invalid panel layout; restored the default dock パネル配置が無効です。既定のドックを復元しました
@msg action is required action を指定してください
@msg anchor must name a registered panel anchor に登録済みパネルを指定してください
@msg before must name a panel before にパネルを指定してください
@msg docking action contains an unknown panel or an excessive split path ドッキング操作に不明なパネルまたは長すぎる分割パスが含まれています
@msg group must be true or false group は true または false にしてください
@msg move requires a destination panel 移動先のパネルを指定してください
@msg move requires an existing destination panel 移動先には既存のパネルを指定してください
@msg onto must name a floating panel onto にフローティングパネルを指定してください
@msg panel group is empty パネルグループが空です
@msg panel must name a registered panel panel に登録済みパネルを指定してください
@msg saved panel layout is invalid; reset the workspace first 保存されたパネル配置が無効です。先にワークスペースをリセットしてください
@msg x and y must be provided together x と y は両方指定してください
@msg zone must be a string zone は文字列にしてください
@msg zone must be tab, center, left, right, top or bottom zone は tab、center、left、right、top、bottom のいずれかにしてください
@msg {key} must be a finite coordinate {key} は有限の座標にしてください

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2754,3 +2754,11 @@
Ocean Ripple… Ondulação oceânica…
Rename Data Set Renomear conjunto de dados
Rename Variable Renomear variável
# Shared panel docking
Activate Panel Ativar Painel
Change Panel Layout Alterar Layout dos Painéis
Close Panel Fechar Painel
Move Panel Mover Painel
Group with Agrupar com
Resize panels Redimensionar painéis
Resize panel window Redimensionar janela do painel

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -4035,3 +4035,28 @@ axis Both Оба
Define a variable first Сначала определите переменную
Text Текст
Visibility Видимость
# Shared panel docking
Activate Panel Активировать панель
Change Panel Layout Изменить расположение панелей
Close Panel Закрыть панель
Move Panel Переместить панель
Group with Сгруппировать с
Resize panels Изменить размер панелей
Resize panel window Изменить размер окна панели
@msg Invalid panel layout; restored the default dock Недопустимое расположение панелей; восстановлено расположение по умолчанию
@msg action is required Необходимо указать action
@msg anchor must name a registered panel anchor должен указывать зарегистрированную панель
@msg before must name a panel before должен указывать панель
@msg docking action contains an unknown panel or an excessive split path Действие прикрепления содержит неизвестную панель или слишком длинный путь разделений
@msg group must be true or false group должен иметь значение true или false
@msg move requires a destination panel Для перемещения требуется целевая панель
@msg move requires an existing destination panel Для перемещения требуется существующая целевая панель
@msg onto must name a floating panel onto должен указывать плавающую панель
@msg panel group is empty Группа панелей пуста
@msg panel must name a registered panel panel должен указывать зарегистрированную панель
@msg saved panel layout is invalid; reset the workspace first Сохранённое расположение панелей недопустимо; сначала сбросьте рабочую среду
@msg zone must be a string zone должен быть строкой
@msg zone must be tab, center, left, right, top or bottom zone должен иметь значение tab, center, left, right, top или bottom
@msg {key} must be a finite coordinate {key} должен быть конечной координатой
@msg x and y must be provided together x и y необходимо указывать вместе
@msg invalid restored panel group Восстановленная группа панелей недопустима

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -4020,3 +4020,28 @@ axis Both Обидві
Define a variable first Спочатку визначте змінну
Text Текст
Visibility Видимість
# Shared panel docking
Activate Panel Активувати панель
Change Panel Layout Змінити розташування панелей
Close Panel Закрити панель
Move Panel Перемістити панель
Group with Згрупувати з
Resize panels Змінити розмір панелей
Resize panel window Змінити розмір вікна панелі
@msg Invalid panel layout; restored the default dock Неприпустиме розташування панелей; відновлено типове розташування
@msg action is required Потрібно вказати action
@msg anchor must name a registered panel anchor має вказувати зареєстровану панель
@msg before must name a panel before має вказувати панель
@msg docking action contains an unknown panel or an excessive split path Дія прикріплення містить невідому панель або надто довгий шлях поділів
@msg group must be true or false group має мати значення true або false
@msg move requires a destination panel Для переміщення потрібна цільова панель
@msg move requires an existing destination panel Для переміщення потрібна наявна цільова панель
@msg onto must name a floating panel onto має вказувати плаваючу панель
@msg panel group is empty Група панелей порожня
@msg panel must name a registered panel panel має вказувати зареєстровану панель
@msg saved panel layout is invalid; reset the workspace first Збережене розташування панелей неприпустиме; спочатку скиньте робоче середовище
@msg zone must be a string zone має бути рядком
@msg zone must be tab, center, left, right, top or bottom zone має мати значення tab, center, left, right, top або bottom
@msg {key} must be a finite coordinate {key} має бути скінченною координатою
@msg x and y must be provided together x та y потрібно вказувати разом
@msg invalid restored panel group Відновлена група панелей неприпустима

Can't render this file because it has a wrong number of fields in line 13.

View File

@ -3668,3 +3668,11 @@ effect Vertical 垂直
Define a variable first 请先定义一个变量
Text 文本
Visibility 可见性
# Shared panel docking
Activate Panel 激活面板
Change Panel Layout 更改面板布局
Close Panel 关闭面板
Move Panel 移动面板
Group with 组合到
Resize panels 调整面板大小
Resize panel window 调整面板窗口大小

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3449,3 +3449,11 @@ effect Soft 柔和
Define a variable first 請先定義一個變數
Text 文字
Visibility 可見性
# Shared panel docking
Activate Panel 啟用面板
Change Panel Layout 變更面板配置
Close Panel 關閉面板
Move Panel 移動面板
Group with 群組至
Resize panels 調整面板大小
Resize panel window 調整面板視窗大小

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1,28 +1,19 @@
//! Icon lookup and drawing (SVGs rasterized by egui_extras, tinted with theme colours).
use std::collections::HashMap;
use std::sync::OnceLock;
use craft_ui::icons::{SvgIconSet, SvgStyle};
use egui::{Color32, ImageSource, Rect, Ui, Vec2};
fn table() -> &'static HashMap<&'static str, &'static [u8]> {
static T: OnceLock<HashMap<&'static str, &'static [u8]>> = OnceLock::new();
fn table() -> &'static SvgIconSet {
static T: OnceLock<SvgIconSet> = OnceLock::new();
T.get_or_init(|| {
crate::icon_data::ICONS
.iter()
.map(|(n, b)| {
// Icons use currentColor; render white and tint at draw time. Slightly thinner strokes
// than Lucide's default read closer to Illustrator's glyph weight.
let s = String::from_utf8_lossy(b).replace("currentColor", "white").replace("stroke-width=\"2\"", "stroke-width=\"1.6\"");
let leaked: &'static [u8] = Box::leak(s.into_bytes().into_boxed_slice());
(*n, leaked)
})
.collect()
SvgIconSet::new(crate::icon_data::ICONS, SvgStyle { current_color: "white", stroke_width: Some("1.6") }, "bytes://icon/", "square-dashed")
})
}
pub fn exists(name: &str) -> bool {
table().contains_key(name)
table().contains(name)
}
/// Map tool-catalogue icon names to SVG files.
@ -102,7 +93,7 @@ pub fn tool_icon(name: &str) -> &'static str {
"tool-zoom" => "zoom-in",
other => {
if exists(other) {
table().get_key_value(other).map(|(k, _)| *k).unwrap_or("square-dashed")
table().canonical_name(other).unwrap_or("square-dashed")
} else {
"square-dashed"
}
@ -111,8 +102,7 @@ pub fn tool_icon(name: &str) -> &'static str {
}
pub fn source(name: &str) -> ImageSource<'static> {
let bytes = table().get(name).or_else(|| table().get("square-dashed")).copied().unwrap_or(&[]);
ImageSource::Bytes { uri: format!("bytes://icon/{name}.svg").into(), bytes: egui::load::Bytes::Static(bytes) }
table().source(name)
}
/// Paint icon `name` into `rect` tinted with `tint`.
@ -126,3 +116,33 @@ pub fn icon(ui: &mut Ui, name: &str, size: f32, tint: Color32) -> egui::Response
paint(ui, name, rect, tint);
resp
}
#[cfg(test)]
mod tests {
use super::*;
/// Characterize the pre-extraction loader against every embedded asset, including custom
/// icons. A shared-crate update must not silently change our stroke weight or cache keys.
#[test]
fn shared_catalog_preserves_existing_icons() {
for (name, original) in crate::icon_data::ICONS {
let expected = String::from_utf8_lossy(original).replace("currentColor", "white").replace("stroke-width=\"2\"", "stroke-width=\"1.6\"");
let ImageSource::Bytes { uri, bytes } = source(name) else { panic!("expected SVG bytes") };
assert_eq!(uri.as_ref(), format!("bytes://icon/{name}.svg"));
assert_eq!(bytes.as_ref(), expected.as_bytes(), "{name}");
assert!(exists(name));
assert_eq!(tool_icon(name), *name);
}
}
#[test]
fn shared_catalog_preserves_fallback_and_tool_aliases() {
let ImageSource::Bytes { uri, bytes } = source("missing-icon") else { panic!("expected SVG bytes") };
let ImageSource::Bytes { bytes: fallback, .. } = source("square-dashed") else { panic!("expected SVG bytes") };
assert_eq!(uri.as_ref(), "bytes://icon/missing-icon.svg");
assert_eq!(bytes.as_ref(), fallback.as_ref());
assert!(!exists("missing-icon"));
assert_eq!(tool_icon("missing-icon"), "square-dashed");
assert_eq!(tool_icon("tool-selection"), "dc-selection");
}
}

View File

@ -36,6 +36,7 @@ pub mod io;
pub mod menus;
pub mod native_menu;
pub mod palette;
mod panel_docking;
pub mod panels;
pub mod picks;
pub mod place;

View File

@ -256,6 +256,20 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("window.taskBar.reset", "Reset Bar Position", "", "{} unpin the Contextual Task Bar and put it back under the selection"),
("window.dock", "Panels", "Tab", "{} show/hide all panels"),
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too)"),
(
"window.panel.layout",
"Change Panel Layout",
"",
"{action} serialized docking action, including floating geometry, divider sizes and accordion state",
),
(
"window.panel.move",
"Move Panel",
"",
"{panel, anchor, zone?: tab|center|left|right|top|bottom, before?: panel} group, split or reorder a panel",
),
("window.panel.activate", "Activate Panel", "", "{panel} show and select a panel in its current group"),
("window.panel.close", "Close Panel", "", "{panel} hide a panel"),
(
"window.panel.float",
"Float Panel",
@ -843,6 +857,9 @@ pub(crate) fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>, String> {
/// Handle a UI command. `None` = not a UI command (the engine handles it).
pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
if let Some(result) = crate::panel_docking::command(app, id, p) {
return Some(result);
}
if id == "app.language" {
let lang = p.get("lang").and_then(Value::as_str).unwrap_or("");
let value = if lang.eq_ignore_ascii_case("auto") {
@ -1448,6 +1465,9 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
let canonical = normalize_panel(panel);
match canonical.and_then(DockTab::from_id) {
_ if app.ui.docking.is_some() => canonical.is_some_and(|id| {
app.ui.open_panel.as_deref() == Some(id) || app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&id.to_string()))
}),
_ if canonical.is_some_and(|id| crate::floating::group_of(&app.ui, id).is_some()) => true,
Some(tab) if !app.ui.dock_collapsed => crate::floating::shown_tab(&app.ui) == Some(tab),
_ => canonical.is_some_and(|id| app.ui.open_panel.as_deref() == Some(id)),

View File

@ -0,0 +1,809 @@
//! 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::VectorcraftApp;
#[derive(Clone, Default)]
struct LegacyDrag {
layout: Option<Layout<String>>,
open_panel: Option<String>,
floating: Vec<crate::state::FloatingPanels>,
icons: std::collections::BTreeSet<String>,
}
#[derive(Clone)]
struct RootGroupDrag {
panel: String,
layout: Layout<String>,
hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
generation: u64,
}
fn cancel_legacy_drag(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
let key = egui::Id::new("vectorcraft-panel-docking").with("legacy-origin");
let previous = ctx.data_mut(|data| data.remove_temp::<LegacyDrag>(key));
if let Some(previous) = previous {
app.ui.open_panel = previous.open_panel;
app.ui.floating_panels = previous.floating;
app.ui.docking = previous.layout;
app.ui.docking_icons = previous.icons;
craft_ui::docking::cancel_drag::<String>(ctx, egui::Id::new("vectorcraft-panel-docking"));
true
} else {
false
}
}
fn normalize(raw: &str) -> Option<&'static str> {
crate::menus::normalize_panel(raw)
}
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: &VectorcraftApp) -> Layout<String> {
let panels: Vec<String> = crate::floating::docked_tabs(&app.ui).map(|tab| tab.info().0.to_string()).collect();
let active = panels.iter().position(|id| id == app.ui.dock_tab.info().0).unwrap_or(0);
let root = (!panels.is_empty()).then_some(Node::Tabs { panels, active });
let mut layout = Layout { root, floating: Vec::new() };
for group in &app.ui.floating_panels {
let panels: Vec<String> = group.panels.iter().filter(|id| normalize(id).is_some() && !layout.contains(id)).cloned().collect();
if !panels.is_empty() && group.pos.iter().all(|v| v.is_finite()) {
let active = group.active.min(panels.len().saturating_sub(1));
layout.floating.push(Floating {
panels,
active,
rect: [group.pos[0], group.pos[1], group.width.unwrap_or(300.0).clamp(200.0, 720.0), 400.0],
});
}
}
layout
}
fn icon_origins(app: &VectorcraftApp) -> std::collections::BTreeSet<String> {
let mut icons = app.ui.docking_icons.clone();
if app.ui.docking.is_none() {
// Legacy workspaces have no custom groups in the dock. Icon-panel floats therefore
// retain their native rail destination when first converted to a shared layout.
icons.extend(
app.ui
.floating_panels
.iter()
.flat_map(|group| group.panels.iter())
.filter(|id| crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id.as_str()))
.cloned(),
);
}
icons
}
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()
}
// Preserve the native dock's selected tab when another tab leaves. If the selected tab
// itself leaves, the remaining group's first tab is the native fallback.
fn float_fallback(layout: &Layout<String>, panel: &str) -> Option<String> {
let mut pending: Vec<_> = layout.root.iter().collect();
while let Some(node) = pending.pop() {
match node {
Node::Tabs { panels, active } if panels.iter().any(|id| id == panel) => {
return panels.get(*active).filter(|id| id.as_str() != panel).or_else(|| panels.iter().find(|id| id.as_str() != panel)).cloned();
}
Node::Split { first, second, .. } => pending.extend([second.as_ref(), first.as_ref()]),
_ => {}
}
}
None
}
fn restore_float_fallback(layout: &mut Layout<String>, panel: Option<String>) -> Result<(), String> {
if let Some(panel) = panel.filter(|panel| Layout { root: layout.root.clone(), floating: Vec::new() }.contains(panel)) {
layout.apply(Action::Activate { panel }).map_err(|error| error.to_string())?;
}
Ok(())
}
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 group_members(layout: &Layout<String>, panel: &str) -> Vec<String> {
if let Some(group) = layout.floating.iter().find(|g| g.panels.iter().any(|id| id == panel)) {
return group.panels.clone();
}
let mut visit: Vec<_> = layout.root.iter().collect();
while let Some(node) = visit.pop() {
match node {
Node::Tabs { panels, .. } if panels.iter().any(|id| id == panel) => return panels.clone(),
Node::Split { first, second, .. } => {
visit.push(second);
visit.push(first);
}
_ => {}
}
}
vec![panel.into()]
}
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 VectorcraftApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
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)),
);
}
if params.get("panel").and_then(Value::as_str).is_some_and(|s| s.eq_ignore_ascii_case("tools") || s.eq_ignore_ascii_case("toolbar")) {
return None;
}
let operation = match id {
"window.panel.move" => "move",
"window.panel.float" => "float",
"window.panel.dock" => "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).map(|value| {
if id == "window.panel"
&& let Some(panel) = params.get("panel").and_then(Value::as_str).and_then(normalize)
&& let Some(group) = app.ui.floating_panels.iter().find(|group| group.panels.iter().any(|id| id == panel))
{
return json!({"floating":group.panels});
}
value
}))
}
// 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 VectorcraftApp, 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 mut icons = icon_origins(app);
let explicit_dock = match &action {
Action::Move { panel, anchor, .. } | Action::OpenAt { panel, anchor, .. }
if (Layout { root: layout.root.clone(), floating: Vec::new() }).contains(anchor) =>
{
Some(panel.clone())
}
_ => None,
};
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 fallback = match &action {
Action::Float { panel, .. } => float_fallback(&layout, panel),
_ => None,
};
layout.apply(action).map_err(|error| error.to_string())?;
restore_float_fallback(&mut layout, fallback)?;
if let Some((panel, location)) = returning {
app.ui.docking_hidden.insert(panel, location);
}
if let Some(panel) = explicit_dock {
icons.remove(&panel);
}
app.ui.docking_icons = icons;
app.ui.docking = Some(layout);
sync_legacy(app);
app.ui.open_panel = None;
Ok(json!({"layout": app.ui.docking}))
}
fn change(app: &mut VectorcraftApp, 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));
if params.get("x").is_some() != params.get("y").is_some() {
return Err("x and y must be provided together".into());
}
let whole_group = match params.get("group") {
None => false,
Some(Value::Bool(value)) => *value,
Some(_) => return Err("group must be true or false".into()),
};
let members = if whole_group { group_members(&layout, panel) } else { vec![panel.to_string()] };
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 icons = icon_origins(app);
let icon_panel = crate::state::ICON_PANELS.iter().any(|entry| entry.0 == panel);
if icon_panel && !layout.contains(&panel.to_string()) {
icons.insert(panel.to_string());
}
if operation == "activate" && icon_panel && !layout.contains(&panel.to_string()) {
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(panel) { None } else { Some(panel.into()) };
return Ok(json!({"panel": panel, "open": app.ui.open_panel}));
}
if operation == "dock" && params.get("anchor").or_else(|| params.get("onto")).is_none() {
let mut candidate = layout.clone();
let mut open = None;
for member in &members {
if candidate.contains(member) {
candidate.apply(Action::Close { panel: member.clone() }).map_err(|error| error.to_string())?;
}
}
// Remove the complete group before restoring any saved neighbor. Otherwise a
// still-floating group member can accidentally receive the first restored tab.
for member in &members {
if icons.contains(member) {
open = Some(member.clone());
} else if let Some(location) = hidden.get(member).filter(|location| {
location.floating.is_none()
&& location.anchor.as_ref().is_none_or(|anchor| Layout { root: candidate.root.clone(), floating: Vec::new() }.contains(anchor))
}) {
if candidate.restore(member.clone(), location).is_err() {
// A removed neighboring group uses the normal dock as its documented fallback.
ensure(&mut candidate, member)?;
}
} else {
ensure(&mut candidate, member)?;
}
}
if candidate.contains(&panel.to_string()) {
candidate.apply(Action::Activate { panel: panel.to_string() }).map_err(|error| error.to_string())?;
} else if icons.contains(panel) {
open = Some(panel.to_string());
}
if !valid(&candidate) {
return Err("invalid restored panel group".into());
}
app.ui.docking = Some(candidate);
app.ui.docking_icons = icons;
for member in &members {
hidden.remove(member);
}
app.ui.docking_hidden = hidden;
sync_legacy(app);
app.ui.open_panel = open;
return Ok(json!({"panel": panel, "floating": false, "layout": app.ui.docking}));
}
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 old = layout
.floating
.iter()
.find(|group| group.panels.iter().any(|id| id == panel) && group.panels.len() == members.len())
.map(|group| group.rect)
.unwrap_or([400.0 + 24.0 * layout.floating.len().min(12) as f32, 140.0 + 24.0 * layout.floating.len().min(12) as f32, 300.0, 400.0]);
let rect =
[number(params, "x", old[0])?, number(params, "y", old[1])?, number(params, "width", old[2])?, number(params, "height", old[3])?];
let fallback = float_fallback(&layout, panel).or_else(|| first_root(&layout, panel));
for member in &members {
if let Ok(location) = layout.location(member)
&& location.floating.is_none()
{
hidden.insert(member.clone(), location);
}
}
ensure(&mut layout, panel)?;
if let Ok(location) = layout.location(&panel.to_string())
&& location.floating.is_none()
{
hidden.insert(panel.into(), location);
}
if let Some(onto) = params.get("onto") {
let onto = onto.as_str().and_then(normalize).ok_or("onto must name a floating panel")?;
if members.iter().any(|id| id == onto)
|| layout.floating.iter().any(|group| group.panels.iter().any(|id| id == panel) && group.panels.iter().any(|id| id == onto))
|| !layout.floating.iter().any(|group| group.panels.iter().any(|id| id == onto))
{
return Err("onto must name a panel floating in another group".into());
}
for member in &members {
layout
.apply(Action::Move { panel: member.clone(), anchor: onto.into(), placement: Placement::Tab { before: None } })
.map_err(|e| e.to_string())?;
}
} else {
let lead = members.first().ok_or("panel group is empty")?;
layout.apply(Action::Float { panel: lead.clone(), rect }).map_err(|e| e.to_string())?;
for member in members.iter().filter(|id| *id != lead) {
layout
.apply(Action::Move { panel: member.clone(), anchor: lead.clone(), placement: Placement::Tab { before: None } })
.map_err(|e| e.to_string())?;
}
}
layout.apply(Action::Activate { panel: panel.into() }).map_err(|e| e.to_string())?;
restore_float_fallback(&mut layout, fallback)?;
}
"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);
}
}
if operation == "dock" && whole_group {
for member in members.iter().filter(|id| id.as_str() != panel) {
layout
.apply(Action::Move { panel: member.clone(), anchor: panel.into(), placement: Placement::Tab { before: None } })
.map_err(|e| e.to_string())?;
}
layout.apply(Action::Activate { panel: panel.into() }).map_err(|e| e.to_string())?;
}
let group = layout.floating.iter().find(|group| group.panels.iter().any(|id| id == panel));
let result = json!({"panel": panel, "floating": group.is_some(), "group": group.map(|g| &g.panels), "pos": group.map(|g| [g.rect[0], g.rect[1]]), "layout": layout});
if matches!(operation, "move" | "dock") && (Layout { root: layout.root.clone(), floating: Vec::new() }).contains(&panel.to_string()) {
icons.remove(panel);
}
app.ui.docking = Some(layout);
app.ui.docking_hidden = hidden;
app.ui.docking_icons = icons;
sync_legacy(app);
app.ui.open_panel = None;
app.ui.dock = true;
if matches!(operation, "activate" | "move" | "dock") {
app.ui.dock_collapsed = false;
}
Ok(result)
}
struct PanelContent<'a> {
app: &'a mut VectorcraftApp,
commands: Vec<(&'static str, Value)>,
}
impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
fn body(&mut self, ui: &mut egui::Ui, panel: &String) {
let width = ui.available_width();
let height = (ui.available_height() - 20.0).max(0.0);
if crate::state::DockTab::from_id(panel).is_some() {
crate::dock::panel_body(self.app, ui, panel, width, height);
} else {
egui::ScrollArea::vertical()
.id_salt(("shared-panel-body", panel))
.auto_shrink([false, false])
.show(ui, |ui| crate::dock::panel_body(self.app, ui, panel, width, height));
}
}
fn panel_menu(&mut self, ui: &mut egui::Ui, panel: &String) {
crate::panels::panel_menu_items(self.app, ui, panel);
}
fn group_header_width(&self, _panels: &[String], _active: &String, floating: bool) -> f32 {
if floating { 48.0 } else { 80.0 }
}
fn group_header(&mut self, ui: &mut egui::Ui, _panels: &[String], active: &String, floating: bool) {
let rect = ui.max_rect();
let menu = egui::Rect::from_center_size(rect.right_center() - egui::vec2(12.0, 0.0), egui::vec2(16.0, 16.0));
crate::panels::panel_menu(self.app, ui, active, menu);
let grip = egui::Rect::from_min_max(rect.min, egui::pos2(menu.left() - 4.0, rect.bottom()));
if floating {
let response = ui.put(grip, egui::Button::new("×")).on_hover_text(tl!("Dock Panel"));
if response.clicked() {
self.commands.push(("window.panel.dock", json!({"panel": active, "group": true})));
}
} else {
let response = ui.interact(grip, ui.id().with("dock-group-bar"), egui::Sense::drag());
if response.drag_started()
&& let Some(pos) = response.interact_pointer_pos()
{
ui.ctx().data_mut(|data| {
data.insert_temp(
egui::Id::new("vectorcraft-root-group-drag"),
RootGroupDrag {
panel: active.clone(),
layout: self.app.ui.docking.clone().unwrap_or_else(|| legacy(self.app)),
hidden: self.app.ui.docking_hidden.clone(),
generation: self.app.ui.docking_generation,
},
)
});
self.commands.push(("window.panel.float", json!({"panel": active, "group": true, "x": pos.x, "y": pos.y})));
}
response.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group"));
}
}
}
pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
let root_key = egui::Id::new("vectorcraft-root-group-drag");
if let Some(drag) = ui.ctx().data(|data| data.get_temp::<RootGroupDrag>(root_key)) {
if drag.generation != app.ui.docking_generation {
ui.ctx().data_mut(|data| data.remove::<RootGroupDrag>(root_key));
} else if ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape)) {
app.ui.docking = Some(drag.layout);
app.ui.docking_hidden = drag.hidden;
sync_legacy(app);
ui.ctx().data_mut(|data| data.remove::<RootGroupDrag>(root_key));
} else if let Some(pos) = ui.input(|input| input.pointer.interact_pos()) {
if ui.input(|input| input.pointer.primary_down()) {
if let Err(error) = app.run("window.panel.float", json!({"panel":drag.panel, "group":true, "x":pos.x, "y":pos.y})) {
app.ui.status = error;
}
if let Some(panel) = normalize(&drag.panel)
&& let Some((_, rect)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos)
{
crate::floating::highlight(ui.ctx(), rect);
}
} else {
if let Some(panel) = normalize(&drag.panel)
&& let Some((target, _)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos)
{
let (id, params) = match target {
crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel":panel,"group":true})),
crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"onto":onto})),
};
if let Err(error) = app.run(id, params) {
app.ui.status = error;
}
}
ui.ctx().data_mut(|data| data.remove::<RootGroupDrag>(root_key));
}
}
}
let area = egui::Id::new("vectorcraft-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.ui.status = "Invalid panel layout; restored the default dock".into();
return false;
}
let layout = layout.clone();
let t = crate::theme::Tokens::get(ui.ctx());
let collapsed = app.ui.dock_collapsed;
let display = if collapsed { Layout { root: None, floating: layout.floating.clone() } } else { layout.clone() };
let mut draw = |ui: &mut egui::Ui| {
if layout.root.is_some() && crate::dock::collapse_header(ui, collapsed) {
let _ = app.run("window.collapseDock", json!({"collapsed":!collapsed}));
}
if collapsed {
let root = Layout { root: layout.root.clone(), floating: Vec::new() };
for panel in root.panels() {
let info = crate::state::DockTab::from_id(panel)
.map(|tab| tab.info())
.or_else(|| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == panel).copied());
if let Some((_, label, icon)) = info
&& crate::widgets::icon_button(ui, icon, tl!(label), app.ui.open_panel.as_ref() == Some(panel), 30.0).clicked()
{
app.ui.open_panel = if app.ui.open_panel.as_ref() == Some(panel) { None } else { Some(panel.clone()) };
}
}
}
let mut style = craft_ui::docking::DockStyle::from_ui(ui);
style.tab_height = 33.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 = tl!("Float Panel").to_string();
style.close_label = tl!("Close Panel").to_string();
style.move_label = tl!("Group with").to_string();
style.panels_label = tl!("Panels").to_string();
style.resize_label = tl!("Resize panels").to_string();
style.resize_window_label = tl!("Resize panel window").to_string();
let mut content = PanelContent { app, commands: Vec::new() };
let output = craft_ui::docking::DockArea::new(egui::Id::new("vectorcraft-panel-docking")).show_customized(
ui,
&display,
&style,
|id| crate::state::all_panels().find(|p| p.0 == id).map(|p| tl!(p.1).to_string()).unwrap_or_else(|| id.clone()),
|_| craft_ui::docking::Permissions::default(),
|_| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 80.0), ..Default::default() },
&mut content,
);
for (id, params) in content.commands {
if let Err(error) = app.run(id, params) {
app.ui.status = error;
}
}
output
};
let output = if layout.root.is_some() {
let panel = 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));
let panel = if collapsed { panel.exact_size(38.0).resizable(false) } else { panel };
let shown = panel.show(ui, &mut draw);
ui.ctx().data_mut(|data| data.insert_temp(crate::floating::dock_rect_id(), shown.response.rect));
shown.inner
} else {
draw(ui)
};
if let Some(panel) = output.group_drag.as_deref().and_then(normalize)
&& let Some(pos) = ui.input(|input| input.pointer.interact_pos())
&& let Some((_, rect)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos)
{
crate::floating::highlight(ui.ctx(), rect);
}
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("vectorcraft-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;
}
}
if let Some((panel, pos)) = output.group_drop
&& let Some(panel) = normalize(&panel)
&& let Some((target, _)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos)
{
let (command, params) = match target {
crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel": panel, "group": true})),
crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel": panel, "group": true, "onto": onto})),
};
if let Err(error) = app.run(command, params) {
app.ui.status = error;
}
}
icon_rail(app, ui);
true
}
fn icon_rail(app: &mut VectorcraftApp, 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::state::ICON_PANEL_GROUPS
.iter()
.flat_map(|group| group.iter())
.filter_map(|id| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == *id))
{
if app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&id.to_string())) {
continue;
}
let (_, rect) = ui.allocate_space(egui::vec2(30.0, 30.0));
let response = ui.interact(rect, ui.id().with(("panel-icon", id)), egui::Sense::click_and_drag());
let response = crate::widgets::paint_icon_button(ui, response, icon, tl!(label), app.ui.open_panel.as_deref() == Some(id));
if response.clicked() {
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(id) { None } else { Some(id.into()) };
}
legacy_tab(app, ui, id, &response);
}
});
})
.response
.rect;
ui.ctx().data_mut(|data| {
let dock = if app.ui.docking.as_ref().is_some_and(|layout| layout.root.is_some()) {
data.get_temp::<egui::Rect>(crate::floating::dock_rect_id()).unwrap_or(column)
} else {
column
};
data.insert_temp(crate::floating::dock_rect_id(), dock.union(column));
data.insert_temp(crate::floating::icons_rect_id(), column);
data.insert_temp(egui::Id::new("dock-icon-column-left"), column.left());
});
}
pub(crate) fn legacy_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) {
let Some(panel) = normalize(panel) else { return };
response.context_menu(|ui| {
if ui.button(tl!("Float Panel")).clicked() {
if let Err(error) = app.run("window.panel.float", json!({"panel": panel})) {
app.ui.status = error;
}
ui.close();
}
ui.menu_button(tl!("Group with"), |ui| {
for (anchor, label) in crate::state::all_panels() {
if anchor != panel && ui.button(tl!(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.ui.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("vectorcraft-panel-docking"), id.to_string(), source)
{
ui.ctx().data_mut(|data| {
data.insert_temp(
egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"),
LegacyDrag {
layout: app.ui.docking.clone(),
open_panel: app.ui.open_panel.clone(),
floating: app.ui.floating_panels.clone(),
icons: app.ui.docking_icons.clone(),
},
)
});
if !app.ui.docking.as_ref().unwrap_or(&legacy(app)).contains(&id.to_string())
&& crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id)
{
app.ui.docking_icons.insert(id.into());
}
app.ui.docking = Some(layout);
app.ui.open_panel = None;
}
}
}
// Keep the legacy inspection schema useful; shared layout remains the rendering authority.
fn sync_legacy(app: &mut VectorcraftApp) {
if let Some(layout) = &app.ui.docking {
let mut pending: Vec<_> = layout.root.iter().collect();
while let Some(node) = pending.pop() {
match node {
Node::Tabs { panels, active } => {
if let Some(tab) = panels.get(*active).and_then(|id| crate::state::DockTab::from_id(id)) {
app.ui.dock_tab = tab;
}
}
Node::Split { first, second, .. } => pending.extend([second.as_ref(), first.as_ref()]),
_ => {}
}
}
app.ui.floating_panels = layout
.floating
.iter()
.map(|group| crate::state::FloatingPanels {
panels: group.panels.clone(),
active: group.active,
pos: [group.rect[0], group.rect[1]],
width: Some(group.rect[2]),
})
.collect();
}
}
#[cfg(test)]
#[path = "panel_docking_tests.rs"]
mod tests;

View File

@ -0,0 +1,417 @@
use super::*;
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
fn app() -> VectorcraftApp {
VectorcraftApp::new(vectorcraft_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::state::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: VectorcraftApp, size: egui::Vec2) -> Harness<'static, VectorcraftApp> {
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 VectorcraftApp| {
if !ready {
crate::theme::install_fonts(ui.ctx());
crate::theme::apply(ui.ctx(), 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, VectorcraftApp>, 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("vectorcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center();
let destination = h.ctx.read_response(egui::Id::new("vectorcraft-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.workspace.new", 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);
}
#[test]
fn legacy_float_group_aliases_use_atomic_shared_groups_and_preserve_tools() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"layers","group":true,"x":30,"y":50})).unwrap();
assert_eq!(app.ui.docking.as_ref().unwrap().floating[0].panels.len(), 3);
app.run("window.panel.float", json!({"panel":"swatches","onto":"layers"})).unwrap();
assert_eq!(app.ui.docking.as_ref().unwrap().floating[0].panels.len(), 4);
let before = app.ui.docking.clone();
assert!(app.run("window.panel.float", json!({"panel":"properties","onto":"missing","group":true})).is_err());
assert_eq!(app.ui.docking, before);
app.run("window.panel.float", json!({"panel":"tools","x":70,"y":90})).unwrap();
assert_eq!(app.ui.toolbar_pos, Some([70.0, 90.0]));
assert_eq!(app.ui.docking, before);
app.run("window.panel.dock", json!({"panel":"layers","group":true})).unwrap();
assert!(app.ui.docking.as_ref().unwrap().floating.is_empty());
}
/// 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("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 VectorcraftApp| {
if !ready {
egui_extras::install_image_loaders(ui.ctx());
crate::theme::install_fonts(ui.ctx());
crate::theme::apply(ui.ctx(), theme);
ready = true;
return;
}
crate::dock::show(app, ui);
egui::CentralPanel::default().show(ui, |ui| {
ui.label("Custom panel workspace");
});
},
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!("vectorcraft-shared-docking-{}-{width}-{scale}x.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("shape.rectangle", json!({"x":80,"y":70,"width":250,"height":160})).unwrap()["id"].clone();
app.run("select.set", json!({"ids":[id]})).unwrap();
app.run("paint.setFill", json!({"color":"#5297bb"})).unwrap();
app.run("stroke.set", 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 VectorcraftApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.input_mut().max_texture_side = Some(8192);
h.run_steps(5);
let mut pending: Vec<_> = h.output().shapes.iter().map(|shape| &shape.shape).collect();
let mut checkbox = None;
while let Some(shape) = pending.pop() {
match shape {
egui::Shape::Text(text) if text.galley.text() == "Dashed Line" => {
checkbox = Some(text.visual_bounding_rect().center());
break;
}
egui::Shape::Vec(shapes) => pending.extend(shapes),
_ => {}
}
}
let at = checkbox.expect("painted Dashed Line checkbox");
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);
let dash = crate::panels::current_stroke(h.state()).unwrap().dash.unwrap();
assert_eq!(dash.pattern, [12.0, 12.0]);
if let Some(directory) = &directory {
std::fs::create_dir_all(directory).unwrap();
h.render().unwrap().save(directory.join("vectorcraft-populated-stroke-floating.png")).unwrap();
}
let from = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"stroke".to_string()))).unwrap().rect.center();
let to = h.ctx.read_response(egui::Id::new("vectorcraft-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!(crate::panels::current_stroke(h.state()).unwrap().dash.unwrap(), dash);
if let Some(directory) = &directory {
h.render().unwrap().save(directory.join("vectorcraft-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.workspace.new", 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 floating_icon_groups_redock_to_the_rail_and_keep_saved_origins() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"color"})).unwrap();
app.run("window.panel.float", json!({"panel":"swatches", "onto":"color"})).unwrap();
assert_eq!(app.ui.docking_icons.len(), 2);
let workspace = crate::workspaces::capture(&app.ui, "Icon origins");
let saved = serde_json::to_vec(&workspace).unwrap();
let restored: crate::workspaces::Workspace = serde_json::from_slice(&saved).unwrap();
crate::workspaces::apply(&mut app.ui, &restored);
app.run("window.panel.dock", json!({"panel":"color", "group":true})).unwrap();
let layout = app.ui.docking.as_ref().unwrap();
assert!(!layout.contains(&"color".into()));
assert!(!layout.contains(&"swatches".into()));
assert!(app.ui.floating_panels.is_empty());
assert_eq!(app.ui.open_panel.as_deref(), Some("color"), "the requested icon pops out after redocking");
app.run("window.panel", json!({"panel":"color"})).unwrap();
assert!(app.ui.open_panel.is_none(), "the same icon puts it away");
app.run("window.panel", json!({"panel":"color"})).unwrap();
assert_eq!(app.ui.open_panel.as_deref(), Some("color"));
}
#[test]
fn all_dock_group_origins_survive_float_and_return() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"layers", "group":true})).unwrap();
assert_eq!(app.ui.floating_panels[0].panels.len(), 3);
assert!(app.ui.docking_hidden.contains_key("properties"));
assert!(app.ui.docking_hidden.contains_key("layers"));
assert!(app.ui.docking_hidden.contains_key("libraries"));
app.run("window.panel.dock", json!({"panel":"layers", "group":true})).unwrap();
let layout = app.ui.docking.as_ref().unwrap();
assert!(layout.floating.is_empty());
assert_eq!(layout.panels().into_iter().map(String::as_str).collect::<Vec<_>>(), ["properties", "layers", "libraries"]);
assert_eq!(app.ui.dock_tab, crate::state::DockTab::Layers);
assert!(valid(layout));
}
#[test]
fn panel_specific_menu_executes_after_first_float_regroup_and_reload() {
let mut app = app();
app.run("file.new", json!({})).unwrap();
app.run("window.panel.float", json!({"panel":"swatches","x":40,"y":100})).unwrap();
let mut h = harness(app, vec2(1280.0, 900.0));
for stage in 0..3 {
if stage == 1 {
h.state_mut().run("window.panel.float", json!({"panel":"color","x":60,"y":120})).unwrap();
h.state_mut().run("window.panel.float", json!({"panel":"swatches","onto":"color"})).unwrap();
} else if stage == 2 {
let saved = serde_json::to_vec(&h.state().ui).unwrap();
h.state_mut().ui = serde_json::from_slice(&saved).unwrap();
}
h.run_steps(4);
let at = h
.query_all_by_label("Panel menu")
.find(|node| node.rect().center().x < 800.0)
.expect("floating panel menu has an accessible button")
.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);
let action = h.query_all_by_label(" New Swatch…").next().expect("app-specific menu action remains reachable").rect().center();
for pressed in [true, false] {
h.event(Event::PointerButton { pos: action, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
assert_eq!(h.state().ui.dialog.as_ref().map(|dialog| dialog.kind.as_str()), Some("newSwatch"), "stage {stage}: actual panel action executes");
h.state_mut().ui.dialog = None;
}
}
#[test]
fn iconic_origin_tracks_explicit_custom_dock_and_legacy_saved_floats() {
let mut custom = app();
custom.run("window.panel.move", json!({"panel":"color","anchor":"layers"})).unwrap();
let before = custom.ui.docking.clone().unwrap();
custom.run("window.panel.float", json!({"panel":"color"})).unwrap();
custom.run("window.panel.dock", json!({"panel":"color"})).unwrap();
assert_eq!(custom.ui.docking.as_ref(), Some(&before), "explicit custom tabs are the saved destination");
assert!(custom.ui.open_panel.is_none(), "a docked custom tab is not an icon popout");
let mut legacy = app();
crate::floating::float(&mut legacy.ui, &["color"], "color", egui::pos2(120.0, 160.0));
let saved = serde_json::to_vec(&legacy.ui).unwrap();
legacy.ui = serde_json::from_slice(&saved).unwrap();
assert!(legacy.ui.docking.is_none());
legacy.run("window.panel.dock", json!({"panel":"color"})).unwrap();
assert!(!legacy.ui.docking.as_ref().unwrap().contains(&"color".into()), "legacy icon floats return to rail");
assert_eq!(legacy.ui.open_panel.as_deref(), Some("color"));
}

View File

@ -200,6 +200,7 @@ pub fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, rect: Rect) {
let t = Tokens::get(ui.ctx());
let resp = ui.interact(rect, ui.id().with(("panel-menu", id)), Sense::click());
icons::paint(ui, "menu", rect.shrink(1.0), if resp.hovered() { t.text_strong } else { t.text_dim });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Panel menu")));
let resp = resp.on_hover_text(tl!("Panel menu"));
egui::Popup::menu(&resp).show(|ui| {
crate::widgets::menu_scroll(ui, |ui| {

View File

@ -560,8 +560,9 @@ mod tests {
out.textures_delta.clear();
let mut shown = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
assert!(shown.iter().any(|t| t == "System Title Bar"), "{shown:?}");
assert!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), "{shown:?}");
let supported = cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos")));
assert_eq!(shown.iter().any(|t| t == "System Title Bar"), supported, "{shown:?}");
assert_eq!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), supported, "{shown:?}");
a.ui.dialog.as_mut().unwrap().fields.insert("systemTitleBar".into(), json!(true));
confirm(&mut a).unwrap();
assert!(a.session.prefs.system_title_bar);

View File

@ -277,6 +277,17 @@ pub struct UiState {
/// Panels dragged out of the dock: each group floats as its own stack of tabs.
#[serde(default)]
pub floating_panels: Vec<FloatingPanels>,
/// Shared panel membership and placement; older preferences use 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>>,
/// Panels whose saved return destination is the iconic rail.
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
pub docking_icons: std::collections::BTreeSet<String>,
/// Invalidates in-flight gestures when a workspace is replaced.
#[serde(skip)]
pub docking_generation: u64,
/// The Tools panel floats with its top-left corner here (dragged out by its title bar); `None`:
/// docked at the window's left edge.
#[serde(default)]
@ -483,6 +494,8 @@ impl UiState {
f.tools.first().is_some_and(|k| seen.insert(k.clone()))
});
FloatingPanels::sanitize(&mut self.floating_panels, &mut self.toolbar_pos);
self.docking = self.docking.filter(crate::panel_docking::valid);
self.docking_icons.retain(|id| ICON_PANELS.iter().any(|entry| entry.0 == id));
// Overrides that can't fire (modifier-only chords recorded by older versions, #487) give
// the default back.
self.shortcut_overrides.retain(|_, c| c.is_empty() || crate::shortcut_editor::normalize(c).is_some());
@ -507,6 +520,10 @@ impl Default for UiState {
slot_tool: Default::default(),
floating_flyouts: vec![],
floating_panels: vec![],
docking: None,
docking_hidden: Default::default(),
docking_icons: Default::default(),
docking_generation: 0,
toolbar_pos: None,
status_bar: true,
dock: true,

View File

@ -295,18 +295,11 @@ pub fn apply(ctx: &egui::Context, b: Brightness) {
v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 14, spread: 0, color: Color32::from_black_alpha(if t.dark { 120 } else { 60 }) };
v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(if t.dark { 140 } else { 70 }) };
let w = &mut v.widgets;
for (wv, fill) in [
(&mut w.noninteractive, t.panel),
(&mut w.inactive, t.button),
(&mut w.hovered, t.hover),
(&mut w.active, t.tool_active),
(&mut w.open, t.hover),
] {
wv.bg_fill = fill;
wv.weak_bg_fill = fill;
wv.corner_radius = t.cr();
wv.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(
w,
t.cr(),
Stroke::new(1.0, t.text),
);
w.noninteractive.bg_stroke = Stroke::new(1.0, t.divider);
w.inactive.bg_stroke = Stroke::NONE;
w.hovered.bg_stroke = Stroke::new(1.0, t.input_border);

View File

@ -31,6 +31,14 @@ pub struct Workspace {
pub floating_flyouts: Vec<FloatingFlyout>,
/// Panels dragged out of the dock, where they float (built-in workspaces: none).
pub floating_panels: Vec<FloatingPanels>,
/// Custom docking tree, including floating tab groups.
#[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>>,
/// Panels whose saved return destination is the iconic rail.
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
pub docking_icons: std::collections::BTreeSet<String>,
/// Where the Tools panel floats (built-in workspaces: docked).
pub toolbar_pos: Option<[f32; 2]>,
}
@ -128,6 +136,9 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace {
status_bar: ui.status_bar,
floating_flyouts: ui.floating_flyouts.clone(),
floating_panels: ui.floating_panels.clone(),
docking: ui.docking.clone(),
docking_hidden: ui.docking_hidden.clone(),
docking_icons: ui.docking_icons.clone(),
toolbar_pos: ui.toolbar_pos,
}
}
@ -147,6 +158,11 @@ pub fn apply(ui: &mut UiState, w: &Workspace) {
ui.status_bar = w.status_bar;
ui.floating_flyouts = w.floating_flyouts.clone();
ui.floating_panels = w.floating_panels.clone();
ui.docking_generation = ui.docking_generation.wrapping_add(1);
ui.docking = w.docking.clone().filter(crate::panel_docking::valid);
ui.docking_hidden = w.docking_hidden.clone();
ui.docking_icons =
w.docking_icons.iter().filter(|id| crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id.as_str())).take(64).cloned().collect();
ui.toolbar_pos = w.toolbar_pos;
FloatingPanels::sanitize(&mut ui.floating_panels, &mut ui.toolbar_pos);
ui.flyout = None;

View File

@ -3,6 +3,11 @@
`vectorcraft --control <port>` listens on `127.0.0.1:<port>` (loopback only). One JSON request per line:
`{"id": 1, "method": "ui.inspect", "params": {}}` → `{"id": 1, "ok": true, "result": {...}}` or `{"id":1,"ok":false,"error":"..."}`.
Set `VECTORCRAFT_CONFIG_DIR` to a dedicated directory for an isolated native test profile. Preferences,
logs, recovery and user libraries then use that root, without importing the legacy DrawCraft profile.
An unset override preserves the usual platform paths and legacy migration; an explicitly empty override
disables those paths. `VECTORCRAFT_NO_PREFS` continues to disable preference reads and writes.
**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
@ -676,3 +681,34 @@ drags its points (a press on a point focuses it, and a press on one of the focus
handle); a Mesh tool click inside a mesh envelope adds a row and a column. While an envelope or its content is
selected the Control bar shows its controls (Edit Envelope / Edit Contents, warp style, orientation, bend and
distortions or mesh rows and columns, Reset, Envelope Options); each runs an `object.envelope.*` command.
## 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.

View File

@ -90,7 +90,7 @@ fn intra_layer_allowed(from: &str, to: &str) -> bool {
/// External crates that constitute a UI toolkit / windowing dependency.
/// Entries ending in `*` are prefixes.
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"];
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "craft-ui", "rfd", "bevy*"];
/// First layer allowed to use UI crates.
pub const UI_MIN_LAYER: u8 = 6;
@ -303,11 +303,12 @@ mod tests {
#[test]
fn ui_crates_below_l6_flagged() {
for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] {
for dep in ["egui", "eframe", "winit", "egui_kittest", "craft-ui", "rfd", "bevy_ecs", "bevy"] {
let v = check(&[c("vectorcraft-engine", &[(dep, Normal, false)])]);
assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}");
}
assert!(check(&[c("vectorcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty());
assert!(check(&[c("vectorcraft-ui-egui", &[("craft-ui", Normal, false)])]).is_empty());
assert!(check(&[c("vectorcraft-mcp", &[("winit", Normal, false)])]).is_empty());
}