diff --git a/Cargo.lock b/Cargo.lock
index f572aa69..0cdde478 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -687,8 +687,10 @@ dependencies = [
"cadcraft-engine",
"cadcraft-geom",
"cadcraft-render",
+ "craft-ui",
"egui",
"egui-wgpu",
+ "egui_kittest",
"image",
"log",
"naga",
@@ -825,6 +827,16 @@ dependencies = [
"bytemuck",
]
+[[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"
@@ -890,6 +902,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
+[[package]]
+name = "craft-ui"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=b1406e3e6a6f48a88509df6bbcdb0d07ab91cc2f#b1406e3e6a6f48a88509df6bbcdb0d07ab91cc2f"
+dependencies = [
+ "egui",
+ "serde",
+]
+
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -945,6 +966,19 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
+[[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 = "dispatch"
version = "0.2.0"
@@ -1131,6 +1165,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.19.0"
@@ -1443,6 +1498,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"
@@ -1799,6 +1863,25 @@ dependencies = [
"hashbrown 0.17.1",
]
+[[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"
@@ -1920,6 +2003,16 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
+[[package]]
+name = "kittest"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer 0.35.0",
+]
+
[[package]]
name = "kurbo"
version = "0.13.1"
@@ -1932,6 +2025,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 = "libc"
version = "0.2.190"
@@ -2529,6 +2628,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"
diff --git a/Cargo.toml b/Cargo.toml
index a9bc8a7a..c238654e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -38,7 +38,9 @@ skrifa = "0.47"
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
# ui
+craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b1406e3e6a6f48a88509df6bbcdb0d07ab91cc2f", features = ["serde"] }
egui = "0.36"
+egui_kittest = "0.36"
eframe = { version = "0.36", default-features = false }
egui-wgpu = "0.36"
rfd = "0.17"
diff --git a/README.md b/README.md
index 44cb0f93..6d642fbc 100644
--- a/README.md
+++ b/README.md
@@ -115,6 +115,9 @@ numbers):
## Quick start
+Desktop and web UI builds require Rust 1.95 or later. The core workspace's declared minimum
+remains Rust 1.90.
+
```sh
git clone https://github.com/storytold/cadcraft
cd cadcraft
@@ -179,6 +182,14 @@ apps: cadcraft · cadcraft-cli · cadcraft-web
Nothing below `ui-egui` knows about egui, so the front end can be replaced. `cargo xtask ci`
checks formatting, clippy, tests, asset attribution, the crate layering and the wasm build.
+Drafting and layer-palette icon buttons use the pinned `craft-ui` button component for interaction,
+accessible labels and keyboard focus. CADCraft keeps its multicolor icon painter, dark palette,
+disabled tint and tool commands. The native host saves workspace visibility and toolset choices
+atomically; `CADCRAFT_CONFIG_DIR` selects an isolated profile. Compact toolbar groups move into a
+More menu, and status readouts truncate with a full-value tooltip before they reach layout tabs.
+Painted toolbar and tab controls
+have accessible labels and visible keyboard focus. Property controls remain application-specific.
+
## Roadmap
See [ROADMAP.md](ROADMAP.md) for milestones, measured parity and the estimate of remaining work.
@@ -306,3 +317,5 @@ Forks and modified versions must remove them.

Made by the ArtCraft team and community.
+
+Tool Sets, Layers, and Properties can be regrouped, split, floated within the app window, closed, and reopened. The drawing canvas stays visible and cannot be closed or turned into a tab. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
diff --git a/ROADMAP.md b/ROADMAP.md
index f975cdd6..6fcd4f89 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -82,7 +82,17 @@ about 150–190 hours of elapsed time), CADCraft would reach broad AutoCAD parit
## Current focus
+The drafting and layer-palette icon-button helper uses shared `craft-ui` interaction and accessibility,
+with CADCraft's original multicolor painting and disabled appearance. This does not change drafting
+commands. The native host now restores workspace visibility, toolset choices and collapsed groups;
+malformed preference files are reported and preserved. Painted toolbar and document/toolset/layout
+tabs expose keyboard focus and labels, and the control protocol can observe Tab/Shift-Tab traversal.
+Compact windows keep toolbar command groups accessible through a More menu, while coordinates and
+transient messages share an ellipsized readout that does not cover layout names.
+
1. Finish the alpha checklist above (release builds, AutoCAD open check, smoke test of every command, autosave/recovery, plot preview).
2. Layer filter groups, constraint bar polish (close buttons, hover highlight).
3. Native MULTILEADER objects in DXF; stop `*D` block duplication on re-save.
4. Then 3D foundations (UCS, orbit, solids via truck/csgrs per plan/adr/0001).
+
+- **2026-10-10 (UI docking):** Tool Sets, Layers, and Properties can be regrouped, split, floated within the app window, closed, and reopened. The drawing canvas stays visible and cannot be closed or turned into a tab. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. No milestone percentage change.
diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs
index 661bba7c..88e2446f 100644
--- a/apps/cadcraft-cli/src/main.rs
+++ b/apps/cadcraft-cli/src/main.rs
@@ -705,7 +705,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
-
fn args(values: &[&str]) -> Vec {
values.iter().map(|value| (*value).into()).collect()
}
diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs
index bdee8ecf..edb3832f 100644
--- a/apps/cadcraft/src/main.rs
+++ b/apps/cadcraft/src/main.rs
@@ -16,37 +16,64 @@ mod control_server;
mod graphics;
#[cfg(target_os = "macos")]
mod native_menu;
+mod workspace;
use cadcraft_engine::Session;
use cadcraft_ui_egui::{CadApp, Services};
-struct App(CadApp, #[cfg(target_os = "macos")] Option);
+struct App {
+ cad: CadApp,
+ workspace: workspace::Workspace,
+ workspace_error: Option,
+ #[cfg(target_os = "macos")]
+ native_menu: Option,
+}
+
+impl App {
+ fn persist_workspace(&mut self, final_attempt: bool) {
+ let result = if final_attempt { self.workspace.save_on_exit(&self.cad.ui) } else { self.workspace.save_if_changed(&self.cad.ui) };
+ if let Err(error) = result {
+ if self.workspace_error.as_ref() != Some(&error) {
+ eprintln!("CADCraft workspace: {error}");
+ self.cad.set_status(format!("Workspace could not be saved: {error}"));
+ self.workspace_error = Some(error);
+ }
+ } else {
+ self.workspace_error = None;
+ }
+ }
+}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
{
- if self.1.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
- self.1 = Some(native_menu::NativeMenu::install(&mut self.0));
+ if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
+ self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad));
}
- if let Some(m) = &mut self.1 {
- m.poll(&mut self.0, ctx);
+ if let Some(m) = &mut self.native_menu {
+ m.poll(&mut self.cad, ctx);
}
}
- self.0.logic(ctx);
- if self.0.quit_requested {
+ self.cad.logic(ctx);
+ self.persist_workspace(false);
+ if self.cad.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
- self.0.raw_input_hook(raw);
+ self.cad.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
- self.0.ui(ui);
+ self.cad.ui(ui);
+ self.persist_workspace(false);
+ }
+ fn on_exit(&mut self) {
+ self.persist_workspace(true);
}
/// Preferences (the interface theme) survive restarts; window and egui state are not kept.
fn save(&mut self, storage: &mut dyn eframe::Storage) {
- storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.0.prefs_json());
+ storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.cad.prefs_json());
}
fn persist_egui_memory(&self) -> bool {
false
@@ -144,6 +171,14 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
let mut app = CadApp::new(Session::empty(), services());
+ let (workspace, restored, workspace_error) = workspace::Workspace::load();
+ if let Some(ui) = restored {
+ app.ui = ui;
+ }
+ if let Some(error) = &workspace_error {
+ eprintln!("CADCraft workspace: {error}");
+ app.set_status(error);
+ }
if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) {
app.load_prefs(&prefs);
}
@@ -167,11 +202,13 @@ fn main() -> eframe::Result {
if app.session.docs.is_empty() {
app.session.new_drawing(false);
}
- Ok(Box::new(App(
- app,
+ Ok(Box::new(App {
+ cad: app,
+ workspace,
+ workspace_error,
#[cfg(target_os = "macos")]
- None,
- )))
+ native_menu: None,
+ }))
}),
)
}
diff --git a/apps/cadcraft/src/workspace.rs b/apps/cadcraft/src/workspace.rs
new file mode 100644
index 00000000..9526eb9c
--- /dev/null
+++ b/apps/cadcraft/src/workspace.rs
@@ -0,0 +1,301 @@
+//! Native workspace preferences. Drawing data and transient dialogs never enter this file.
+
+use std::ffi::OsString;
+use std::fs::{self, File, OpenOptions};
+use std::io::{Read, Write};
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU64, Ordering};
+
+use cadcraft_ui_egui::UiState;
+use serde_json::{Value, json};
+
+const MAX_BYTES: u64 = 64 * 1024;
+static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
+
+pub struct Workspace {
+ path: Option,
+ last_attempt: Option,
+ read_failed: bool,
+ pending_write_error: Option,
+}
+
+fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option) -> Option {
+ if let Some(root) = get("CADCRAFT_CONFIG_DIR") {
+ return (!root.is_empty()).then(|| PathBuf::from(root));
+ }
+ let nonempty = |key| get(key).filter(|value| !value.is_empty()).map(PathBuf::from);
+ match platform {
+ "macos" => nonempty("HOME").map(|home| home.join("Library/Application Support/CADCraft")),
+ "windows" => nonempty("APPDATA").map(|root| root.join("CADCraft")),
+ _ => nonempty("XDG_CONFIG_HOME").map(|root| root.join("cadcraft")).or_else(|| nonempty("HOME").map(|home| home.join(".config/cadcraft"))),
+ }
+}
+
+fn snapshot(ui: &UiState) -> Result {
+ ui.docking.validate()?;
+ if !matches!(ui.toolset_tab.as_str(), "Drafting" | "Modeling") {
+ return Err("toolsetTab must be Drafting or Modeling".into());
+ }
+ if ui.collapsed_groups.len() > 64 || ui.collapsed_groups.iter().any(|name| name.len() > 256) {
+ return Err("collapsedGroups exceeds the workspace size limit".into());
+ }
+ if ui.history_lines > 12 {
+ return Err("historyLines must be between 0 and 12".into());
+ }
+ Ok(json!({
+ "docking": ui.docking,
+ "showToolsets": ui.show_toolsets,
+ "showPalettes": ui.show_palettes,
+ "showToolbar": ui.show_toolbar,
+ "showFileTabs": ui.show_file_tabs,
+ "showStatusBar": ui.show_status_bar,
+ "showCommandLine": ui.show_command_line,
+ "showViewcube": ui.show_viewcube,
+ "showUcsIcon": ui.show_ucs_icon,
+ "showLayerList": ui.show_layer_list,
+ "toolsetTab": ui.toolset_tab,
+ "collapsedGroups": ui.collapsed_groups,
+ "propertiesAll": ui.properties_all,
+ "inWindowMenu": ui.in_window_menu,
+ "historyLines": ui.history_lines,
+ }))
+}
+
+fn read(path: &Path) -> Result