mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
Merge pull request #456 from skylartaylor/codex/ui-suite-integration-20261010
Add shared workspace docking to CADCraft
This commit is contained in:
26 files changed
+1926
-68
No files matched your search
Generated
+109
@@ -689,8 +689,10 @@ dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"log",
|
||||
"naga",
|
||||
@@ -827,6 +829,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"
|
||||
@@ -892,6 +904,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=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -947,6 +968,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"
|
||||
@@ -1133,6 +1167,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"
|
||||
@@ -1445,6 +1500,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"
|
||||
@@ -1801,6 +1865,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"
|
||||
@@ -1922,6 +2005,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"
|
||||
@@ -1934,6 +2027,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"
|
||||
@@ -2531,6 +2630,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"
|
||||
|
||||
@@ -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 = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
egui = "0.36"
|
||||
egui_kittest = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui-wgpu = "0.36"
|
||||
rfd = "0.17"
|
||||
|
||||
@@ -116,6 +116,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
|
||||
@@ -213,6 +216,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.
|
||||
|
||||
## Accessibility
|
||||
|
||||
Over the drawing area CADCraft hides the system cursor and draws its own crosshair. Tools that follow the system cursor (Windows Magnifier set to follow or centre the mouse pointer, other screen magnifiers, screen recorders, remote desktops) lose track of it there. Turn on **View ▸ Accessibility ▸ Show System Cursor** to keep a small system crosshair visible at the centre of the drawn one, or start CADCraft with the environment variable `CADCRAFT_SYSTEM_CURSOR=1` (on Windows, set it once with `setx CADCRAFT_SYSTEM_CURSOR 1` and start CADCraft again). Agents can switch it with the control channel: `ui.set {"systemCursor": true}`.
|
||||
@@ -346,3 +357,5 @@ Forks and modified versions must remove them.
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
||||
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.
|
||||
@@ -137,3 +137,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
|
||||
| 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate; banner, why, headline row |
|
||||
| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; milestone detail moved to docs/roadmap.md, parity assessment to docs/target-app-parity.md |
|
||||
| 2026-10-07 | major | Alpha checklist, milestone table, ≈ 29% weighted parity estimate |
|
||||
|
||||
## Shared UI workspace integration
|
||||
|
||||
Tool Sets, Layers and Properties use shared docking with persisted layouts, keyboard focus and compact toolbar overflow. No milestone percentage change.
|
||||
+51
-14
@@ -20,38 +20,65 @@ mod graphics;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod workspace;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::{CadApp, Services, i18n};
|
||||
|
||||
struct App(CadApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
struct App {
|
||||
cad: CadApp,
|
||||
workspace: workspace::Workspace,
|
||||
workspace_error: Option<String>,
|
||||
#[cfg(target_os = "macos")]
|
||||
native_menu: Option<native_menu::NativeMenu>,
|
||||
}
|
||||
|
||||
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 std::mem::take(&mut self.0.quit_requested) {
|
||||
self.cad.logic(ctx);
|
||||
self.persist_workspace(false);
|
||||
if std::mem::take(&mut 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);
|
||||
cadcraft_ui_egui::cmdline::capture_tab(ctx, 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
|
||||
@@ -219,6 +246,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);
|
||||
}
|
||||
app.system_languages = std::env::var("CADCRAFT_LOCALE").map(|s| vec![s]).unwrap_or_else(|_| sys_locale::get_locales().collect());
|
||||
if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) {
|
||||
app.load_prefs(&prefs);
|
||||
@@ -243,11 +278,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,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
//! 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<PathBuf>,
|
||||
last_attempt: Option<Value>,
|
||||
read_failed: bool,
|
||||
pending_write_error: Option<String>,
|
||||
}
|
||||
|
||||
fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
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<Value, String> {
|
||||
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<Option<UiState>, String> {
|
||||
let file = match File::open(path) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(format!("{}: {error}", path.display())),
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_BYTES + 1).read_to_end(&mut bytes).map_err(|error| format!("{}: {error}", path.display()))?;
|
||||
if bytes.len() as u64 > MAX_BYTES {
|
||||
return Err(format!("{}: workspace file exceeds 64 KiB", path.display()));
|
||||
}
|
||||
let value: Value = serde_json::from_slice(&bytes).map_err(|error| format!("{}: invalid workspace JSON: {error}", path.display()))?;
|
||||
if value.get("version").and_then(Value::as_u64) != Some(1) {
|
||||
return Err(format!("{}: unsupported workspace version", path.display()));
|
||||
}
|
||||
let workspace =
|
||||
value.get("workspace").filter(|value| value.is_object()).ok_or_else(|| format!("{}: missing workspace object", path.display()))?;
|
||||
// Dock schemas can evolve independently of the other saved workspace preferences.
|
||||
// Recover only this optional field; runtime UiState deserialization remains strict.
|
||||
let mut fields = workspace.clone();
|
||||
let docking = fields.as_object_mut().and_then(|fields| fields.remove("docking"));
|
||||
let mut ui: UiState = serde_json::from_value(fields).map_err(|error| format!("{}: invalid workspace: {error}", path.display()))?;
|
||||
if let Some(docking) = docking
|
||||
&& let Ok(docking) = serde_json::from_value::<cadcraft_ui_egui::docking::Workspace>(docking)
|
||||
&& docking.validate().is_ok()
|
||||
{
|
||||
ui.docking = docking;
|
||||
}
|
||||
// Old or hand-edited files cannot reopen a dialog or replace the active drawing view.
|
||||
ui.dialog = None;
|
||||
ui.start_tab = false;
|
||||
snapshot(&ui).map_err(|error| format!("{}: {error}", path.display()))?;
|
||||
Ok(Some(ui))
|
||||
}
|
||||
|
||||
fn write(path: &Path, workspace: &Value) -> Result<(), String> {
|
||||
let bytes = serde_json::to_vec_pretty(&json!({"version": 1, "workspace": workspace})).map_err(|error| error.to_string())?;
|
||||
if bytes.len() as u64 > MAX_BYTES {
|
||||
return Err("workspace exceeds 64 KiB".into());
|
||||
}
|
||||
let parent = path.parent().ok_or("workspace path has no parent")?;
|
||||
fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?;
|
||||
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let temporary = parent.join(format!(".workspace-{}-{sequence}.tmp", std::process::id()));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options.open(&temporary).map_err(|error| format!("{}: {error}", temporary.display()))?;
|
||||
let result = (|| {
|
||||
file.write_all(&bytes)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
fs::rename(&temporary, path)
|
||||
})();
|
||||
if let Err(error) = result {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(format!("{}: could not save workspace: {error}", path.display()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub fn load() -> (Self, Option<UiState>, Option<String>) {
|
||||
Self::at(config_dir_from(std::env::consts::OS, |key| std::env::var_os(key)).map(|root| root.join("workspace.json")))
|
||||
}
|
||||
|
||||
fn at(path: Option<PathBuf>) -> (Self, Option<UiState>, Option<String>) {
|
||||
let result = path.as_deref().map(read).transpose().map(Option::flatten);
|
||||
match result {
|
||||
Ok(ui) => {
|
||||
let initial = ui.as_ref().cloned().unwrap_or_default();
|
||||
(Self { path, last_attempt: snapshot(&initial).ok(), read_failed: false, pending_write_error: None }, ui, None)
|
||||
}
|
||||
Err(error) => (
|
||||
Self { path, last_attempt: None, read_failed: true, pending_write_error: None },
|
||||
None,
|
||||
Some(format!("Workspace could not be restored; its file was left unchanged. {error}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_if_changed(&mut self, ui: &UiState) -> Result<(), String> {
|
||||
let Some(path) = &self.path else { return Ok(()) };
|
||||
if self.read_failed {
|
||||
// Preserve a corrupt, oversized or newer-version file until the user repairs it.
|
||||
return Ok(());
|
||||
}
|
||||
let value = snapshot(ui)?;
|
||||
if self.last_attempt.as_ref() == Some(&value) {
|
||||
return self.pending_write_error.clone().map_or(Ok(()), Err);
|
||||
}
|
||||
// Keep a failed state's warning without retrying disk I/O every rendered frame.
|
||||
self.last_attempt = Some(value.clone());
|
||||
let result = write(path, &value);
|
||||
self.pending_write_error = result.as_ref().err().cloned();
|
||||
result
|
||||
}
|
||||
|
||||
pub fn save_on_exit(&mut self, ui: &UiState) -> Result<(), String> {
|
||||
if self.pending_write_error.is_some() {
|
||||
self.last_attempt = None;
|
||||
}
|
||||
self.save_if_changed(ui)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_dir() -> PathBuf {
|
||||
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("cadcraft-workspace-test-{}-{sequence}", std::process::id()));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_override_isolated_or_explicitly_disabled() {
|
||||
let get = |key: &str| match key {
|
||||
"CADCRAFT_CONFIG_DIR" => Some(OsString::from("/private/test-state")),
|
||||
"HOME" | "APPDATA" | "XDG_CONFIG_HOME" => Some(OsString::from("/unrelated-user-profile")),
|
||||
_ => None,
|
||||
};
|
||||
for platform in ["macos", "windows", "linux", "freebsd"] {
|
||||
assert_eq!(config_dir_from(platform, get), Some(PathBuf::from("/private/test-state")));
|
||||
assert_eq!(config_dir_from(platform, |key| if key == "CADCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { get(key) }), None);
|
||||
}
|
||||
assert_eq!(
|
||||
config_dir_from("linux", |key| (key == "XDG_CONFIG_HOME").then(|| OsString::from("/config"))),
|
||||
Some(PathBuf::from("/config/cadcraft"))
|
||||
);
|
||||
assert_eq!(config_dir_from("macos", |_| None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_survives_new_store_without_transient_or_document_state() {
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
let (mut first, restored, error) = Workspace::at(Some(path.clone()));
|
||||
assert!(restored.is_none() && error.is_none());
|
||||
let ui = UiState {
|
||||
show_palettes: false,
|
||||
show_command_line: false,
|
||||
toolset_tab: "Modeling".into(),
|
||||
collapsed_groups: vec!["Solid".into()],
|
||||
history_lines: 5,
|
||||
dialog: Some("layers".into()),
|
||||
start_tab: true,
|
||||
..Default::default()
|
||||
};
|
||||
first.save_if_changed(&ui).unwrap();
|
||||
let bytes = fs::read(&path).unwrap();
|
||||
let value: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert!(value["workspace"].get("dialog").is_none());
|
||||
assert!(value["workspace"].get("startTab").is_none());
|
||||
let (_, restored, error) = Workspace::at(Some(path.clone()));
|
||||
assert!(error.is_none());
|
||||
let restored = restored.unwrap();
|
||||
assert_eq!(snapshot(&restored).unwrap(), snapshot(&ui).unwrap());
|
||||
assert!(restored.dialog.is_none() && !restored.start_tab);
|
||||
let mut transient_only = ui.clone();
|
||||
transient_only.dialog = None;
|
||||
transient_only.start_tab = false;
|
||||
first.save_if_changed(&transient_only).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), bytes);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docked_floating_and_hidden_panels_survive_native_workspace_file_reload() {
|
||||
use cadcraft_ui_egui::docking::Panel;
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
let (mut store, _, _) = Workspace::at(Some(path.clone()));
|
||||
let mut ui = UiState::default();
|
||||
let mut app = cadcraft_ui_egui::CadApp::new(cadcraft_engine::Session::default(), Default::default());
|
||||
app.run("ui.dock", json!({"operation":"float", "panel":"layers", "rect":[120,90,340,410]})).unwrap();
|
||||
ui.docking = app.ui.docking;
|
||||
ui.docking.set_visible(Panel::ToolSets, false).unwrap();
|
||||
ui.show_toolsets = false;
|
||||
store.save_if_changed(&ui).unwrap();
|
||||
let (_, reloaded, error) = Workspace::at(Some(path));
|
||||
assert!(error.is_none());
|
||||
let mut reloaded = reloaded.unwrap();
|
||||
assert_eq!(serde_json::to_value(&reloaded.docking).unwrap(), serde_json::to_value(&ui.docking).unwrap());
|
||||
reloaded.docking.set_visible(Panel::ToolSets, true).unwrap();
|
||||
assert!(reloaded.docking.layout.contains(&Panel::ToolSets));
|
||||
assert_eq!(reloaded.docking.layout.floating[0].rect, [120.0, 90.0, 340.0, 410.0]);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_oversized_and_newer_state_are_reported_and_preserved() {
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
for bytes in [
|
||||
b"{invalid".to_vec(),
|
||||
vec![b' '; MAX_BYTES as usize + 1],
|
||||
br#"{"version":2,"workspace":{}}"#.to_vec(),
|
||||
br#"{"version":1,"workspace":{"toolsetTab":"unknown"}}"#.to_vec(),
|
||||
br#"{"version":1,"workspace":{"historyLines":18446744073709551615}}"#.to_vec(),
|
||||
] {
|
||||
fs::write(&path, &bytes).unwrap();
|
||||
let (mut store, ui, error) = Workspace::at(Some(path.clone()));
|
||||
assert!(ui.is_none());
|
||||
assert!(error.as_deref().is_some_and(|error| error.contains("left unchanged")));
|
||||
store.save_if_changed(&UiState { show_palettes: false, ..Default::default() }).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), bytes);
|
||||
}
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_docking_preserves_other_workspace_preferences() {
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
for docking in [
|
||||
json!({"layout":{"root":{"FuturePanelGroup":{}}},"hidden":[]}),
|
||||
json!({"layout":false,"hidden":[]}),
|
||||
json!({"layout":{"root":null,"floating":[]},"hidden":[["futurePanel",{}]]}),
|
||||
] {
|
||||
let workspace = json!({"showToolbar":false,"showPalettes":false,"toolsetTab":"Modeling", "collapsedGroups":["Draw"], "historyLines":7,"docking":docking});
|
||||
assert!(serde_json::from_value::<UiState>(workspace.clone()).is_err(), "runtime state must stay strict");
|
||||
fs::write(&path, serde_json::to_vec(&json!({"version":1,"workspace":workspace})).unwrap()).unwrap();
|
||||
let (_, restored, error) = Workspace::at(Some(path.clone()));
|
||||
assert!(error.is_none());
|
||||
let restored = restored.unwrap();
|
||||
assert!(!restored.show_toolbar);
|
||||
assert!(!restored.show_palettes);
|
||||
assert_eq!(restored.toolset_tab, "Modeling");
|
||||
assert_eq!(restored.collapsed_groups, ["Draw"]);
|
||||
assert_eq!(restored.history_lines, 7);
|
||||
assert_eq!(serde_json::to_value(restored.docking).unwrap(), serde_json::to_value(UiState::default().docking).unwrap());
|
||||
}
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_update_cannot_replace_last_good_workspace() {
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
let (mut store, _, _) = Workspace::at(Some(path.clone()));
|
||||
let mut ui = UiState { show_palettes: false, ..Default::default() };
|
||||
store.save_if_changed(&ui).unwrap();
|
||||
let good = fs::read(&path).unwrap();
|
||||
ui.collapsed_groups = vec!["x".repeat(257)];
|
||||
assert!(store.save_if_changed(&ui).is_err());
|
||||
assert_eq!(fs::read(&path).unwrap(), good);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_write_keeps_warning_and_retries_once_on_exit() {
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
let (mut store, _, _) = Workspace::at(Some(path.clone()));
|
||||
// Simulate a destination becoming unavailable after startup.
|
||||
fs::create_dir(&path).unwrap();
|
||||
let ui = UiState { show_palettes: false, ..Default::default() };
|
||||
let first_error = store.save_if_changed(&ui).unwrap_err();
|
||||
fs::remove_dir(&path).unwrap();
|
||||
assert_eq!(store.save_if_changed(&ui).unwrap_err(), first_error);
|
||||
assert!(!path.exists(), "ordinary frames must not retry disk writes");
|
||||
store.save_on_exit(&ui).unwrap();
|
||||
assert!(!read(&path).unwrap().unwrap().show_palettes);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -274,8 +274,7 @@ pub fn recompute(dm: &Dimension, prev: Option<&Dimension>, before: &Drawing, aft
|
||||
// An ordinate's `defpt` is its fixed datum (the drawing origin); it must not follow the feature.
|
||||
let ordinate = matches!(nd.kind, DimKind::Ordinate { .. });
|
||||
for name in POINTS {
|
||||
if !moved.iter().any(|m| m == name)
|
||||
&& !(ordinate && name == "defpt")
|
||||
if !(moved.iter().any(|m| m == name) || ordinate && name == "defpt")
|
||||
&& !(linear && (name == "p13" || name == "p14"))
|
||||
&& let Some(slot) = def_point_mut(&mut nd, name)
|
||||
{
|
||||
|
||||
@@ -30,7 +30,7 @@ impl Param<'_> {
|
||||
|
||||
fn spline(s: &Spline, f: impl FnOnce(&Param) -> Vec<Vec2>) -> Vec<Vec2> {
|
||||
let (lo, hi) = s.domain();
|
||||
if !s.is_valid() || !(lo.is_finite() && hi.is_finite() && hi > lo) {
|
||||
if !(s.is_valid() && lo.is_finite() && hi.is_finite() && hi > lo) {
|
||||
return Vec::new();
|
||||
}
|
||||
let h = (hi - lo) * 1e-7;
|
||||
|
||||
@@ -4,11 +4,13 @@ description = "CADCraft user interface (egui): the swappable front end"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.95"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-ui = { workspace = true }
|
||||
cadcraft-geom = { workspace = true }
|
||||
cadcraft-color = { workspace = true }
|
||||
cadcraft-doc = { workspace = true }
|
||||
@@ -23,6 +25,7 @@ log = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
# Validates the canvas WGSL and its GLSL ES 3.0 (WebGL2) translation in tests (same naga wgpu uses).
|
||||
naga = { version = "30", features = ["wgsl-in", "glsl-out"] }
|
||||
|
||||
|
||||
@@ -629,8 +629,14 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
{
|
||||
let _ = app.session.zoom_about(f64::from(zoom_pinch), xf.to_world(hp));
|
||||
}
|
||||
// Pan with the middle button (trackpads pan with a two-finger swipe, above).
|
||||
if middle_down && let Some(hp) = hover_pos {
|
||||
// Pan with the middle button (or Shift+right-drag / two-finger drag on trackpads is scroll).
|
||||
let middle_pressed = ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Middle));
|
||||
let focused = ui.input(|i| i.focused);
|
||||
if middle_down
|
||||
&& focused
|
||||
&& (app.canvas.pan_last.is_some() || (middle_pressed && inside))
|
||||
&& let Some(hp) = hover_pos
|
||||
{
|
||||
if let Some(last) = app.canvas.pan_last {
|
||||
let d = hp - last;
|
||||
if let Ok(st) = app.session.state_mut() {
|
||||
@@ -698,7 +704,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
app.session.cursor = eff;
|
||||
app.session.cursor_deferred = app.canvas.snap.and_then(|h| h.deferred);
|
||||
// Typed or menu snap overrides (END, NON…) and Tab cycling; a locked Dynamic Input value wins.
|
||||
if !dyn_frame.and_then(|f| crate::dyninput::parse(&app.cmd.buffer, &f)).is_some_and(|e| e.first_locked().is_some()) {
|
||||
if dyn_frame.and_then(|f| crate::dyninput::parse(&app.cmd.buffer, &f)).is_none_or(|e| e.first_locked().is_none()) {
|
||||
override_cursor(app, w, &xf);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -78,7 +78,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
ui.painter().galley(pos2(x, r.top() + (title_h - galley.size().y) / 2.0), galley, t.text_dim);
|
||||
// Allow dragging the window by the title row.
|
||||
let tr = Rect::from_min_size(r.min, vec2(r.width(), title_h));
|
||||
let resp = ui.interact(tr, ui.id().with("titledrag"), Sense::click_and_drag());
|
||||
let resp = ui.interact(tr, ui.id().with("titledrag"), Sense::CLICK | Sense::DRAG);
|
||||
if resp.drag_started() {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
}
|
||||
@@ -93,7 +93,31 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let size = 26.0;
|
||||
let mut clicked = None;
|
||||
let mac = ui.ctx().os().is_mac();
|
||||
for group in TOOLBAR {
|
||||
let widths: Vec<_> = TOOLBAR.iter().map(|group| group.len() as f32 * (size + 6.0) - 6.0).collect();
|
||||
let available = (row.right() - 12.0 - x).max(0.0);
|
||||
let Some(fit) = craft_ui::layout::fit_toolbar(&widths, available, 24.0, 30.0) else {
|
||||
// Very small windows still expose the complete command row by scrolling.
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(row.shrink2(vec2(4.0, 0.0))), |ui| {
|
||||
egui::ScrollArea::horizontal().id_salt("toolbar_small").show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for group in TOOLBAR {
|
||||
for (icon, cmd, tip, _) in *group {
|
||||
if icons::button(ui, *icon, size, tip, false).clicked() {
|
||||
clicked = Some(*cmd);
|
||||
}
|
||||
}
|
||||
ui.add_space(18.0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(command) = clicked {
|
||||
app.start(command);
|
||||
}
|
||||
return;
|
||||
};
|
||||
for (index, _) in &fit.shown {
|
||||
let Some(group) = TOOLBAR.get(*index) else { continue };
|
||||
for (icon, cmd, label, shortcut) in group.iter() {
|
||||
let br = Rect::from_min_size(pos2(x, row.center().y - size / 2.0), vec2(size, size));
|
||||
let resp = ui.interact(br, ui.id().with(("tb", *cmd)), Sense::click());
|
||||
@@ -105,6 +129,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
Some(sc) => format!("{} ({})", crate::i18n::t(label), crate::menus::shortcut_label(sc, mac)),
|
||||
None => crate::i18n::t(label).to_string(),
|
||||
};
|
||||
icons::describe_control(ui, &resp, &tip, None, false);
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
clicked = Some(*cmd);
|
||||
}
|
||||
@@ -112,6 +137,34 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
x += 18.0;
|
||||
}
|
||||
if !fit.overflow.is_empty() {
|
||||
let br = Rect::from_min_size(pos2(x, row.center().y - size / 2.0), vec2(fit.overflow_width, size));
|
||||
let response = ui.interact(br, ui.id().with("toolbar_more"), Sense::click());
|
||||
if response.hovered() {
|
||||
ui.painter().rect_filled(br, 3.0, t.control_hover.gamma_multiply(0.6));
|
||||
}
|
||||
for offset in [-5.0, 0.0, 5.0] {
|
||||
ui.painter().circle_filled(br.center() + vec2(offset, 0.0), 1.5, t.text);
|
||||
}
|
||||
icons::describe_control(ui, &response, "More toolbar commands", None, false);
|
||||
egui::Popup::menu(&response).show(|ui| {
|
||||
for (position, index) in fit.overflow.iter().enumerate() {
|
||||
let Some(group) = TOOLBAR.get(*index) else { continue };
|
||||
if position > 0 {
|
||||
ui.separator();
|
||||
}
|
||||
for (_, command, label, _) in *group {
|
||||
let response = ui.button(crate::i18n::t(label));
|
||||
crate::control::record_widget(ui, &response, label, "button", None);
|
||||
if response.clicked() {
|
||||
clicked = Some(*command);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
response.on_hover_text("More toolbar commands");
|
||||
}
|
||||
if let Some(c) = clicked {
|
||||
app.start(c);
|
||||
}
|
||||
@@ -131,12 +184,13 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let plus = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0));
|
||||
let presp = ui.interact(plus, ui.id().with("tab+"), Sense::click());
|
||||
icons::paint(ui.painter(), plus.shrink(2.0), Icon::Plus, false);
|
||||
icons::describe_control(ui, &presp, "New Drawing", None, false);
|
||||
if presp.on_hover_text(crate::tl!("New Drawing")).clicked() {
|
||||
app.start("new");
|
||||
app.ui.start_tab = false;
|
||||
}
|
||||
x += 24.0;
|
||||
let mut tab = |ui: &mut egui::Ui, label: &str, active: bool, w: f32, id: egui::Id| -> (bool, bool) {
|
||||
let mut tab = |ui: &mut egui::Ui, label: &str, active: bool, closable: bool, w: f32, id: egui::Id| -> (bool, bool) {
|
||||
let tr = Rect::from_min_size(pos2(x, r.top()), vec2(w, r.height()));
|
||||
let resp = ui.interact(tr, id, Sense::click());
|
||||
ui.painter().rect_filled(
|
||||
@@ -158,17 +212,19 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
if active { t.text } else { t.text_dim },
|
||||
);
|
||||
let mut close = false;
|
||||
if active || resp.hovered() {
|
||||
if closable && (active || resp.hovered()) {
|
||||
let cr = Rect::from_center_size(pos2(tr.right() - 11.0, tr.center().y), vec2(12.0, 12.0));
|
||||
let cresp = ui.interact(cr, id.with("x"), Sense::click());
|
||||
icons::paint(ui.painter(), cr, Icon::Close, false);
|
||||
icons::describe_control(ui, &cresp, &format!("Close {label}"), None, false);
|
||||
close = cresp.clicked();
|
||||
}
|
||||
icons::describe_control(ui, &resp, label, Some(active), true);
|
||||
ui.painter().vline(tr.right(), tr.y_range(), Stroke::new(1.0, t.border));
|
||||
x += w;
|
||||
(resp.clicked(), close)
|
||||
};
|
||||
let (c, _) = tab(ui, crate::tl!("Start"), app.ui.start_tab || app.session.docs.is_empty(), 110.0, ui.id().with("tab_start"));
|
||||
let (c, _) = tab(ui, crate::tl!("Start"), app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.id().with("tab_start"));
|
||||
if c {
|
||||
app.ui.start_tab = true;
|
||||
}
|
||||
@@ -182,7 +238,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
.map(|(i, d)| (i, format!("{}{}", d.title, if d.is_dirty() { "*" } else { "" }), i == app.session.active))
|
||||
.collect();
|
||||
for (i, title, active) in titles {
|
||||
let (c, x) = tab(ui, &title, active && !app.ui.start_tab, 170.0, ui.id().with(("tab", i)));
|
||||
let (c, x) = tab(ui, &title, active && !app.ui.start_tab, true, 170.0, ui.id().with(("tab", i)));
|
||||
if c {
|
||||
switch_to = Some(i);
|
||||
}
|
||||
@@ -218,6 +274,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let br = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0));
|
||||
let resp = ui.interact(br, ui.id().with(("sb", tip)), Sense::click());
|
||||
icons::paint(&p, br.shrink(2.0), icon, false);
|
||||
icons::describe_control(ui, &resp, tip, None, false);
|
||||
if resp.on_hover_text(crate::i18n::t(tip)).clicked() && icon == Icon::Plus {
|
||||
let _ = app.run("layout.new", json!({}));
|
||||
}
|
||||
@@ -248,6 +305,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
p.rect_filled(tr, 2.0, t.control.gamma_multiply(0.5));
|
||||
}
|
||||
p.galley(pos2(tr.left() + 13.0, tr.center().y - g.size().y / 2.0), g, if active { t.text } else { t.text_dim });
|
||||
icons::describe_control(ui, &resp, name, Some(active), true);
|
||||
if resp.clicked() {
|
||||
switch = Some(name.clone());
|
||||
}
|
||||
@@ -256,6 +314,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let pr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(16.0, 16.0));
|
||||
let presp = ui.interact(pr, ui.id().with("layout_plus"), Sense::click());
|
||||
icons::paint(&p, pr.shrink(2.0), Icon::Plus, false);
|
||||
icons::describe_control(ui, &presp, "New layout", None, false);
|
||||
if presp.on_hover_text(crate::tl!("New layout")).clicked()
|
||||
&& let Ok(v) = app.run("layout.new", json!({}))
|
||||
&& let Some(n) = v.get("name").and_then(serde_json::Value::as_str)
|
||||
@@ -289,7 +348,6 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
];
|
||||
let size = 20.0;
|
||||
let mut rx = r.right() - 8.0 - toggles.len() as f32 * (size + 4.0);
|
||||
let coords_w = 210.0;
|
||||
// Coordinates.
|
||||
let coord = app.canvas.cursor.map(|c| {
|
||||
let (lu, lp) = app.session.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4));
|
||||
@@ -309,16 +367,33 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let resp = ui.interact(br, ui.id().with("mspace_toggle"), Sense::click());
|
||||
p.rect_filled(br, 3.0, if resp.hovered() { t.control_hover } else { t.toggle_on.gamma_multiply(0.25) });
|
||||
p.text(br.center(), egui::Align2::CENTER_CENTER, crate::i18n::t(label), crate::theme::small(), t.text);
|
||||
icons::describe_control(ui, &resp, label, None, false);
|
||||
if resp.on_hover_text(crate::tl!("Switch between model space in a viewport and paper space")).clicked() {
|
||||
let _ = app.run(if model { "pspace" } else { "mspace" }, json!({}));
|
||||
app.canvas.list = None;
|
||||
}
|
||||
coord_right = br.left() - 10.0;
|
||||
}
|
||||
if let Some(c) = coord {
|
||||
p.text(pos2(coord_right, r.center().y), egui::Align2::RIGHT_CENTER, c, crate::theme::body(), t.text);
|
||||
// A transient message occupies the same bounded readout slot as coordinates.
|
||||
// Ellipsis and a tooltip keep either value readable without covering layout tabs.
|
||||
let message = app.status.as_ref().filter(|(_, at)| crate::now_ms() - at < 5000.0).map(|(message, _)| message);
|
||||
let readout = message.cloned().or(coord);
|
||||
if let Some(text) = readout {
|
||||
let left = x + 8.0;
|
||||
let width = (coord_right - left).max(0.0);
|
||||
if width > 4.0 {
|
||||
let color = if message.is_some() { t.warn } else { t.text };
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(text.clone(), crate::theme::body(), color);
|
||||
job.wrap.max_width = width;
|
||||
job.wrap.max_rows = 1;
|
||||
job.wrap.break_anywhere = true;
|
||||
let galley = p.layout_job(job);
|
||||
let rect = Rect::from_min_max(pos2(left, r.top()), pos2(coord_right, r.bottom()));
|
||||
let text_x = if message.is_some() { left } else { coord_right - galley.size().x };
|
||||
p.with_clip_rect(rect).galley(pos2(text_x, r.center().y - galley.size().y / 2.0), galley, color);
|
||||
ui.interact(rect, ui.id().with("status_readout"), Sense::hover()).on_hover_text(text);
|
||||
}
|
||||
}
|
||||
let _ = coords_w;
|
||||
let mut toggle_cmd = None;
|
||||
for (icon, on, cmd, tip) in toggles {
|
||||
let br = Rect::from_min_size(pos2(rx, r.center().y - size / 2.0), vec2(size, size));
|
||||
@@ -333,6 +408,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
// Re-tint the icon blue-ish by an underline.
|
||||
p.hline(br.x_range().shrink(4.0), br.bottom() - 1.0, Stroke::new(1.5, t.toggle_on));
|
||||
}
|
||||
icons::describe_control(ui, &resp, tip, Some(on), false);
|
||||
if resp.on_hover_text(crate::i18n::t(tip)).clicked() {
|
||||
toggle_cmd = Some(cmd);
|
||||
}
|
||||
@@ -341,12 +417,6 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
if let Some(c) = toggle_cmd {
|
||||
app.start(c);
|
||||
}
|
||||
// Transient status message.
|
||||
if let Some((msg, at)) = &app.status
|
||||
&& crate::now_ms() - at < 5000.0
|
||||
{
|
||||
p.text(pos2(x + 20.0, r.center().y), egui::Align2::LEFT_CENTER, msg, crate::theme::small(), t.warn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -264,16 +264,21 @@ pub fn submit(app: &mut CadApp) {
|
||||
app.cmdline(&text);
|
||||
}
|
||||
|
||||
fn visible_lines(app: &CadApp, canvas: Rect, bar: Rect) -> usize {
|
||||
let available = ((bar.top() - canvas.top() - 8.0).max(0.0) / LINE_H) as usize;
|
||||
app.ui.history_lines.min(MAX_LINES).min(available)
|
||||
}
|
||||
|
||||
pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {
|
||||
let t = Tokens::get();
|
||||
keyboard(app, ui.ctx());
|
||||
let w = (canvas.width() * 0.48).clamp(360.0, 760.0);
|
||||
let w = (canvas.width() * 0.48).clamp(360.0, 760.0).min((canvas.width() - 8.0).max(0.0));
|
||||
let h = 24.0;
|
||||
let bar = Rect::from_min_size(pos2(canvas.center().x - w / 2.0, canvas.bottom() - h - 10.0), vec2(w, h));
|
||||
let p = ui.painter_at(canvas);
|
||||
history(app, ui, canvas, bar);
|
||||
// History lines above the bar (the expanded window replaces them).
|
||||
let n = if app.cmd.expanded { 0 } else { app.ui.history_lines.min(MAX_LINES) };
|
||||
let n = if app.cmd.expanded { 0 } else { visible_lines(app, canvas, bar) };
|
||||
let lines: Vec<String> = app.session.log.iter().rev().take(n).rev().cloned().collect();
|
||||
if !lines.is_empty() {
|
||||
let lh = 16.0;
|
||||
@@ -387,7 +392,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {
|
||||
bar.left() + 24.0,
|
||||
bar.top() - 6.0 - rh * sug.len() as f32 - (if lines.is_empty() { 0.0 } else { 16.0 * lines.len() as f32 + 6.0 }),
|
||||
),
|
||||
vec2(300.0, rh * sug.len() as f32),
|
||||
vec2((bar.width() - 24.0).max(0.0), rh * sug.len() as f32),
|
||||
);
|
||||
p.rect_filled(lr, 3.0, t.list_bg);
|
||||
p.rect_stroke(lr, 3.0, Stroke::new(1.0, t.cmd_border), egui::StrokeKind::Inside);
|
||||
@@ -428,7 +433,7 @@ struct Zones {
|
||||
}
|
||||
|
||||
fn zones(app: &CadApp, canvas: Rect, bar: Rect) -> Zones {
|
||||
let n = if app.cmd.expanded { 0 } else { app.ui.history_lines.min(MAX_LINES).min(app.session.log.len()) };
|
||||
let n = if app.cmd.expanded { 0 } else { visible_lines(app, canvas, bar).min(app.session.log.len()) };
|
||||
let lines =
|
||||
(n > 0).then(|| Rect::from_min_size(pos2(bar.left(), bar.top() - LINE_H * n as f32 - 4.0), vec2(bar.width(), LINE_H * n as f32 + 2.0)));
|
||||
let window = app.cmd.expanded.then(|| {
|
||||
@@ -469,7 +474,7 @@ fn history(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect, bar: Rect) {
|
||||
&& let Some(o) = ui.input(|i| i.pointer.press_origin())
|
||||
&& on_grip(o)
|
||||
{
|
||||
app.cmd.resize_from = Some((o.y, app.ui.history_lines.min(MAX_LINES)));
|
||||
app.cmd.resize_from = Some((o.y, visible_lines(app, canvas, bar)));
|
||||
}
|
||||
if let Some((y0, n0)) = app.cmd.resize_from {
|
||||
if let (true, Some(p)) = (resp.dragged(), resp.interact_pointer_pos()) {
|
||||
@@ -518,6 +523,24 @@ mod tests {
|
||||
use cadcraft_engine::Session;
|
||||
use egui::{Event, PointerButton};
|
||||
|
||||
#[test]
|
||||
fn compact_canvas_contains_history_controls_without_changing_line_preference() {
|
||||
let mut app = CadApp::new(Session::empty(), crate::Services::default());
|
||||
app.ui.history_lines = 12;
|
||||
for i in 0..20 {
|
||||
app.session.echo(format!("line {i}"));
|
||||
}
|
||||
let canvas = Rect::from_min_size(Pos2::ZERO, vec2(300.0, 180.0));
|
||||
let w = (canvas.width() * 0.48).clamp(360.0, 760.0).min((canvas.width() - 8.0).max(0.0));
|
||||
let bar = Rect::from_min_size(pos2(canvas.center().x - w / 2.0, canvas.bottom() - 34.0), vec2(w, 24.0));
|
||||
let z = zones(&app, canvas, bar);
|
||||
assert!(canvas.contains_rect(bar));
|
||||
assert!(canvas.contains_rect(z.chevron));
|
||||
assert!(z.lines.is_some_and(|r| canvas.contains_rect(r)));
|
||||
assert!(canvas.contains_rect(z.grip));
|
||||
assert_eq!(app.ui.history_lines, 12);
|
||||
}
|
||||
|
||||
fn frame(app: &mut CadApp, ctx: &egui::Context, events: Vec<Event>) {
|
||||
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
@@ -542,7 +565,7 @@ mod tests {
|
||||
frame(&mut app, &ctx, Vec::new());
|
||||
frame(&mut app, &ctx, Vec::new());
|
||||
let canvas = app.canvas.rect.unwrap_or(Rect::NOTHING);
|
||||
let w = (canvas.width() * 0.48).clamp(360.0, 760.0);
|
||||
let w = (canvas.width() * 0.48).clamp(360.0, 760.0).min((canvas.width() - 8.0).max(0.0));
|
||||
let bar = Rect::from_min_size(pos2(canvas.center().x - w / 2.0, canvas.bottom() - 34.0), vec2(w, 24.0));
|
||||
let z = zones(&app, canvas, bar);
|
||||
let Some(lines) = z.lines else { panic!("three history lines show by default") };
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! - `engine.commands`: every command with menu path, aliases, params and enablement
|
||||
//! - `drawing.inspect {entities?, limit?}`: drawing summary and entities (verifies agent work)
|
||||
//! - `ui.inspect`: UI state, canvas rect, view, performance
|
||||
//! - `ui.elements {label?, role?, focused?}`: observed visible controls and actual egui focus
|
||||
//! - `ui.menu.list`: the menu tree; `ui.menu.invoke {command}`: like choosing a menu item
|
||||
//! - `ui.pointer {x, y, space?: "world"|"screen", button?: left|right, action?: click|move}`
|
||||
//! - `ui.click {x, y}`, `ui.move {x, y}`: real egui pointer input in screen points
|
||||
@@ -89,6 +90,88 @@ pub enum Outcome {
|
||||
Screenshot { path: Option<String> },
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Widget {
|
||||
id: egui::Id,
|
||||
label: String,
|
||||
role: &'static str,
|
||||
rect: [f32; 4],
|
||||
enabled: bool,
|
||||
visible: bool,
|
||||
selected: Option<bool>,
|
||||
}
|
||||
|
||||
fn widgets_id() -> egui::Id {
|
||||
egui::Id::new("cadcraft-observed-widgets")
|
||||
}
|
||||
|
||||
pub(crate) fn begin_frame(ctx: &egui::Context) {
|
||||
ctx.data_mut(|data| data.remove::<Vec<Widget>>(widgets_id()));
|
||||
}
|
||||
|
||||
/// Observe a real response. This never assigns focus or changes input routing.
|
||||
pub(crate) fn record_widget(ui: &egui::Ui, response: &egui::Response, label: &str, role: &'static str, selected: Option<bool>) {
|
||||
if !response.rect.is_finite() || !response.rect.is_positive() {
|
||||
return;
|
||||
}
|
||||
let widget = Widget {
|
||||
id: response.id,
|
||||
label: label.chars().take(512).collect(),
|
||||
role,
|
||||
rect: [response.rect.left(), response.rect.top(), response.rect.width(), response.rect.height()],
|
||||
enabled: response.enabled(),
|
||||
visible: ui.is_rect_visible(response.rect),
|
||||
selected,
|
||||
};
|
||||
ui.ctx().data_mut(|data| {
|
||||
let widgets = data.get_temp_mut_or_default::<Vec<Widget>>(widgets_id());
|
||||
if let Some(previous) = widgets.iter_mut().find(|previous| previous.id == widget.id) {
|
||||
*previous = widget;
|
||||
} else if widgets.len() < 2048 {
|
||||
widgets.push(widget);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn widgets(ctx: &egui::Context) -> Vec<Widget> {
|
||||
ctx.data_mut(|data| data.get_temp::<Vec<Widget>>(widgets_id()).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn widget_json(widget: &Widget, focused: Option<egui::Id>) -> Value {
|
||||
json!({
|
||||
"id": format!("{:016x}", widget.id.value()), "label": widget.label, "role": widget.role,
|
||||
"rect": widget.rect, "enabled": widget.enabled, "visible": widget.visible,
|
||||
"selected": widget.selected, "focused": focused == Some(widget.id),
|
||||
})
|
||||
}
|
||||
|
||||
fn focus_json(ctx: &egui::Context) -> Value {
|
||||
let focused = ctx.memory(|memory| memory.focused());
|
||||
let observed = widgets(ctx);
|
||||
let widget = observed.iter().find(|widget| focused == Some(widget.id));
|
||||
json!({
|
||||
"id": focused.map(|id| format!("{:016x}", id.value())),
|
||||
"label": widget.map(|widget| &widget.label),
|
||||
"role": widget.map(|widget| widget.role),
|
||||
"keyboardInput": ctx.egui_wants_keyboard_input(),
|
||||
})
|
||||
}
|
||||
|
||||
fn elements(ctx: &egui::Context, params: &Value) -> Value {
|
||||
let focused = ctx.memory(|memory| memory.focused());
|
||||
Value::Array(
|
||||
widgets(ctx)
|
||||
.iter()
|
||||
.filter(|widget| {
|
||||
params.get("label").and_then(Value::as_str).is_none_or(|label| widget.label == label)
|
||||
&& params.get("role").and_then(Value::as_str).is_none_or(|role| widget.role == role)
|
||||
&& params.get("focused").and_then(Value::as_bool).is_none_or(|value| value == (focused == Some(widget.id)))
|
||||
})
|
||||
.map(|widget| widget_json(widget, focused))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn ok(v: Value) -> Outcome {
|
||||
Outcome::Done(json!({"ok": true, "result": v}))
|
||||
}
|
||||
@@ -138,6 +221,7 @@ pub fn inspect(app: &CadApp, ctx: &egui::Context) -> Value {
|
||||
json!({
|
||||
"ui": serde_json::to_value(&app.ui).unwrap_or_default(),
|
||||
"theme": if app.shown_theme() == egui::Theme::Light { "light" } else { "dark" },
|
||||
"focus": focus_json(ctx),
|
||||
"canvasRect": app.canvas.rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
|
||||
"window": [r.width(), r.height()],
|
||||
"view": view.map(|v| json!({"center": [v.center.x, v.center.y], "height": v.height})),
|
||||
@@ -227,6 +311,11 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou
|
||||
"engine.commands" => ok(all_commands(app)),
|
||||
"drawing.inspect" | "document.inspect" => wrap(app.session.execute("drawing.inspect", p).map_err(|e| e.to_string())),
|
||||
"ui.inspect" => ok(inspect(app, ctx)),
|
||||
"ui.elements" => ok(elements(ctx, p)),
|
||||
"ui.focus" => {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.menu.list" => ok(Value::Array(
|
||||
crate::menus::tree(app).iter().map(|(m, es)| json!({"label": m, "children": es.iter().map(menu_json).collect::<Vec<_>>()})).collect(),
|
||||
)),
|
||||
@@ -321,6 +410,9 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou
|
||||
}
|
||||
match serde_json::from_value::<crate::UiState>(cur) {
|
||||
Ok(u) => {
|
||||
if let Err(error) = u.docking.validate() {
|
||||
return err(error);
|
||||
}
|
||||
app.ui = u;
|
||||
ok(serde_json::to_value(&app.ui).unwrap_or_default())
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ fn blocks(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
/// the insertion point.
|
||||
fn insert_block(app: &mut CadApp, name: &str) {
|
||||
app.start("insert");
|
||||
if !app.session.running.as_ref().is_some_and(|r| r.id == "insert") {
|
||||
if app.session.running.as_ref().is_none_or(|r| r.id != "insert") {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = app.session.input(cadcraft_engine::Input::Text(name.to_string())) {
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
//! Application panel identities and persistence around the shared docking renderer.
|
||||
//! Document contents remain owned by their existing view implementations.
|
||||
|
||||
use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone};
|
||||
use craft_ui::layout::{SplitAxis, SplitSize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Panel {
|
||||
Canvas,
|
||||
ToolSets,
|
||||
Layers,
|
||||
Properties,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
pub const ALL: &[Self] = &[Self::Canvas, Self::ToolSets, Self::Layers, Self::Properties];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Canvas => "canvas",
|
||||
Self::ToolSets => "toolSets",
|
||||
Self::Layers => "layers",
|
||||
Self::Properties => "properties",
|
||||
}
|
||||
}
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Canvas => "Drawing",
|
||||
Self::ToolSets => "Tool Sets",
|
||||
Self::Layers => "Layers",
|
||||
Self::Properties => "Properties",
|
||||
}
|
||||
}
|
||||
fn default_side(self) -> Zone {
|
||||
match self {
|
||||
Self::Canvas => Zone::Right,
|
||||
Self::ToolSets => Zone::Left,
|
||||
Self::Layers => Zone::Right,
|
||||
Self::Properties => Zone::Right,
|
||||
}
|
||||
}
|
||||
fn parse(id: &str) -> Result<Self, String> {
|
||||
Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn permissions(panel: &Panel) -> Permissions {
|
||||
if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() }
|
||||
}
|
||||
|
||||
fn tabs(panel: Panel) -> Node<Panel> {
|
||||
Node::Tabs { panels: vec![panel], active: 0 }
|
||||
}
|
||||
|
||||
fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Panel>) -> Node<Panel> {
|
||||
Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Workspace {
|
||||
pub layout: Layout<Panel>,
|
||||
/// Closed panels retain their most recent group, side, size or floating rectangle.
|
||||
pub hidden: Vec<(Panel, Location<Panel>)>,
|
||||
}
|
||||
|
||||
impl Default for Workspace {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
layout: Layout {
|
||||
root: Some(split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedFirst(220.0),
|
||||
tabs(Panel::ToolSets),
|
||||
split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedSecond(300.0),
|
||||
tabs(Panel::Canvas),
|
||||
Node::Stack {
|
||||
entries: vec![
|
||||
craft_ui::docking::StackEntry { panel: Panel::Layers, open: true, height: Some(280.0) },
|
||||
craft_ui::docking::StackEntry { panel: Panel::Properties, open: true, height: None },
|
||||
],
|
||||
},
|
||||
),
|
||||
)),
|
||||
floating: Vec::new(),
|
||||
},
|
||||
hidden: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
self.layout.validate().map_err(|error| error.to_string())?;
|
||||
if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() {
|
||||
return Err("The panel layout must contain its document view and bounded hidden panels".into());
|
||||
}
|
||||
// Saved settings cannot turn the protected document view into a floating or closable tab.
|
||||
if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) {
|
||||
return Err("The document view cannot float".into());
|
||||
}
|
||||
let mut nodes: Vec<_> = self.layout.root.iter().collect();
|
||||
while let Some(node) = nodes.pop() {
|
||||
match node {
|
||||
Node::Split { first, second, .. } => {
|
||||
nodes.push(first);
|
||||
nodes.push(second);
|
||||
}
|
||||
Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => {
|
||||
return Err("The document view cannot share a tab group".into());
|
||||
}
|
||||
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => {
|
||||
return Err("The document view cannot be collapsed".into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (panel, location) in &self.hidden {
|
||||
if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) {
|
||||
return Err("A hidden panel has an invalid or duplicate identity".into());
|
||||
}
|
||||
if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) {
|
||||
return Err("A hidden panel cannot restore over the document view".into());
|
||||
}
|
||||
let mut probe = self.layout.clone();
|
||||
if let Err(error) = probe.restore(*panel, location)
|
||||
&& error != craft_ui::docking::DockError::MissingTarget
|
||||
{
|
||||
return Err(error.to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply(&mut self, action: Action<Panel>) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
let mut next = self.clone();
|
||||
if let Action::Close { panel } = &action {
|
||||
let location = next.layout.location(panel).map_err(|error| error.to_string())?;
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
next.hidden.push((*panel, location));
|
||||
} else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action {
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
}
|
||||
next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?;
|
||||
next.validate()?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Explicit opens select the requested tab and expand a collapsed stack entry.
|
||||
pub fn reveal_panel(&mut self, panel: Panel) -> Result<(), String> {
|
||||
let mut next = self.clone();
|
||||
next.set_visible(panel, true)?;
|
||||
next.apply(Action::Activate { panel })?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> {
|
||||
if self.layout.contains(&panel) == visible {
|
||||
return Ok(());
|
||||
}
|
||||
if !visible {
|
||||
return self.apply(Action::Close { panel });
|
||||
}
|
||||
self.validate()?;
|
||||
let mut next = self.clone();
|
||||
let location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone());
|
||||
// Recreate the last surviving member first. Restoring a member also restores any
|
||||
// hidden anchor it needs, so either close order retains the original tab order.
|
||||
// Remove this member before recursion to bound malformed/cyclic saved anchors.
|
||||
next.hidden.retain(|(id, _)| *id != panel);
|
||||
if let Some(anchor) = location.as_ref().and_then(|saved| saved.anchor)
|
||||
&& !next.layout.contains(&anchor)
|
||||
&& next.hidden.iter().any(|(id, _)| *id == anchor)
|
||||
{
|
||||
next.set_visible(anchor, true)?;
|
||||
}
|
||||
let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok());
|
||||
if !restored {
|
||||
next.layout
|
||||
.apply_with_permissions(
|
||||
Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) },
|
||||
permissions,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
next.validate()?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn command(&mut self, params: &Value) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
if let Some(action) = params.get("action") {
|
||||
let action: Action<Panel> = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?;
|
||||
return self.apply(action);
|
||||
}
|
||||
let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
|
||||
if operation == "resizeSplit" {
|
||||
let path: Vec<bool> =
|
||||
serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?;
|
||||
let size: SplitSize =
|
||||
serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?;
|
||||
return self.apply(Action::ResizeSplit { path, size });
|
||||
}
|
||||
let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?;
|
||||
let action = match operation {
|
||||
"open" => return self.reveal_panel(panel),
|
||||
"close" => Action::Close { panel },
|
||||
"activate" => Action::Activate { panel },
|
||||
"setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? },
|
||||
"resizeStack" => {
|
||||
let value = params.get("height").ok_or("height is required")?;
|
||||
let height = if value.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
value
|
||||
.as_f64()
|
||||
.filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height))
|
||||
.ok_or("height must be finite and bounded")? as f32,
|
||||
)
|
||||
};
|
||||
Action::ResizeStack { panel, height }
|
||||
}
|
||||
"float" | "moveFloating" => {
|
||||
let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?;
|
||||
let mut values = [0.0f32; 4];
|
||||
for (output, value) in values.iter_mut().zip(rect) {
|
||||
*output = value
|
||||
.as_f64()
|
||||
.filter(|number| number.is_finite() && number.abs() <= 1_000_000.0)
|
||||
.ok_or("rect values must be finite and bounded")? as f32;
|
||||
}
|
||||
if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } }
|
||||
}
|
||||
"move" => {
|
||||
let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?;
|
||||
let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") {
|
||||
"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 center, left, right, top or bottom".into()),
|
||||
};
|
||||
let placement = if let Some(before) = params.get("before") {
|
||||
if !matches!(placement, Placement::Split(Zone::Center)) {
|
||||
return Err("before only applies to a center move".into());
|
||||
}
|
||||
Placement::Tab {
|
||||
before: if before.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?)
|
||||
},
|
||||
}
|
||||
} else {
|
||||
placement
|
||||
};
|
||||
Action::Move { panel, anchor, placement }
|
||||
}
|
||||
_ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()),
|
||||
};
|
||||
self.apply(action)
|
||||
}
|
||||
}
|
||||
|
||||
fn sync(workspace: &mut Workspace, app: &crate::CadApp) -> Result<(), String> {
|
||||
workspace.set_visible(Panel::ToolSets, app.ui.show_toolsets)?;
|
||||
if !app.ui.show_palettes {
|
||||
workspace.set_visible(Panel::Layers, false)?;
|
||||
workspace.set_visible(Panel::Properties, false)?;
|
||||
} else if !workspace.layout.contains(&Panel::Layers) && !workspace.layout.contains(&Panel::Properties) {
|
||||
// Reopen the last surviving accordion entry before restoring its earlier sibling.
|
||||
workspace.set_visible(Panel::Properties, true)?;
|
||||
workspace.set_visible(Panel::Layers, true)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn publish_visibility(app: &mut crate::CadApp, workspace: &Workspace) {
|
||||
app.ui.show_toolsets = workspace.layout.contains(&Panel::ToolSets);
|
||||
app.ui.show_palettes = workspace.layout.contains(&Panel::Layers) || workspace.layout.contains(&Panel::Properties);
|
||||
}
|
||||
|
||||
pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result<Value, String> {
|
||||
let mut workspace = app.ui.docking.clone();
|
||||
if reset {
|
||||
workspace = Workspace::default();
|
||||
} else {
|
||||
sync(&mut workspace, app)?;
|
||||
workspace.command(params)?;
|
||||
}
|
||||
publish_visibility(app, &workspace);
|
||||
app.ui.docking = workspace;
|
||||
Ok(json!({"docking": app.ui.docking}))
|
||||
}
|
||||
|
||||
pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
|
||||
let t = crate::theme::Tokens::get();
|
||||
let mut workspace = app.ui.docking.clone();
|
||||
if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) {
|
||||
app.set_status(format!("Panel layout could not be restored: {error}"));
|
||||
workspace = Workspace::default();
|
||||
let _ = sync(&mut workspace, app);
|
||||
}
|
||||
// Bodies can dispatch commands (including the drawing's typed command line).
|
||||
// Keep the canonical workspace accessible while rendering an immutable snapshot.
|
||||
app.ui.docking = workspace.clone();
|
||||
let mut style = DockStyle::from_ui(ui);
|
||||
style.min_pane = 96.0;
|
||||
style.background = t.panel;
|
||||
style.tab_background = t.chrome;
|
||||
style.active_background = t.tab_active;
|
||||
style.text = t.text;
|
||||
style.inactive_text = t.text_dim;
|
||||
style.border = egui::Stroke::new(1.0, t.border);
|
||||
style.accent = t.accent;
|
||||
style.font = crate::theme::body();
|
||||
let output = DockArea::new(egui::Id::new("cadcraft-docking")).show_with_limits(
|
||||
ui,
|
||||
&workspace.layout,
|
||||
&style,
|
||||
|panel| crate::i18n::t(panel.label()).into(),
|
||||
permissions,
|
||||
panel_limits,
|
||||
|ui, panel| match panel {
|
||||
Panel::Canvas => {
|
||||
ui.painter().rect_filled(ui.max_rect(), 0.0, t.canvas);
|
||||
crate::canvas::show(app, ui);
|
||||
}
|
||||
Panel::ToolSets => crate::palettes::toolsets(app, ui),
|
||||
Panel::Layers => {
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("dock-layers")
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| crate::palettes::layers_section(app, ui));
|
||||
}
|
||||
Panel::Properties => {
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("dock-properties")
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| crate::palettes::properties_section(app, ui));
|
||||
}
|
||||
},
|
||||
);
|
||||
// Existing panel close buttons and commands still own their visibility switches.
|
||||
let rendered = workspace;
|
||||
let mut workspace = app.ui.docking.clone();
|
||||
let _ = sync(&mut workspace, app);
|
||||
let stale = workspace.layout != rendered.layout || workspace.hidden != rendered.hidden;
|
||||
if stale {
|
||||
craft_ui::docking::cancel_drag::<Panel>(ui.ctx(), egui::Id::new("cadcraft-docking"));
|
||||
}
|
||||
publish_visibility(app, &workspace);
|
||||
app.ui.docking = workspace;
|
||||
if stale {
|
||||
return;
|
||||
}
|
||||
for action in output.actions {
|
||||
if let Err(error) =
|
||||
serde_json::to_value(action).map_err(|error| error.to_string()).and_then(|action| command(app, false, &json!({"action": action})))
|
||||
{
|
||||
app.set_status(format!("Panel move was not applied: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn panel_limits(panel: &Panel) -> PanelLimits {
|
||||
let (min, max_width) = match panel {
|
||||
Panel::Canvas => (egui::vec2(300.0, 180.0), 1_000_000.0),
|
||||
Panel::ToolSets => (egui::vec2(220.0, 150.0), 480.0),
|
||||
Panel::Layers | Panel::Properties => (egui::vec2(220.0, 100.0), 720.0),
|
||||
};
|
||||
PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) }
|
||||
}
|
||||
@@ -48,6 +48,7 @@
|
||||
Arc Дуга
|
||||
Arc Length Довжина дуги
|
||||
Area Площа
|
||||
Arrange Panel Розташувати панель
|
||||
Array Масив
|
||||
Attach Приєднати
|
||||
Attribute Атрибут
|
||||
@@ -374,6 +375,7 @@
|
||||
Layer contains objects (double-click to make current) Шар містить об'єкти (двічі клацніть, щоб зробити поточним)
|
||||
Layer is empty (double-click to make current) Шар порожній (двічі клацніть, щоб зробити поточним)
|
||||
Layers Шари
|
||||
Layers Palette Палітра шарів
|
||||
Layers displayed: {shown} of {total}. Current layer: {layer} Показано шарів: {shown} із {total}. Поточний шар: {layer}
|
||||
Layout Аркуш
|
||||
Layout list Список аркушів
|
||||
@@ -531,6 +533,7 @@
|
||||
New CTB Нова CTB
|
||||
New STB Нова STB
|
||||
New style Новий стиль
|
||||
Reset Panel Layout Скинути розташування панелей
|
||||
Use for this layout Використати для цього аркуша
|
||||
Display plot styles Показувати стилі друку
|
||||
No plot style tables. Немає таблиць стилів друку.
|
||||
|
||||
|
@@ -3,6 +3,7 @@
|
||||
//! Icons are line drawings on a 24×24 design grid: light strokes for existing geometry, blue for
|
||||
//! the geometry a command creates and orange dots for picked points.
|
||||
|
||||
use craft_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle};
|
||||
use egui::{Color32, Painter, Pos2, Rect, Stroke, pos2};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
@@ -907,15 +908,49 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Small helper: an icon button that returns `true` when clicked.
|
||||
/// A shared button interaction with CADCraft's original multicolor icon painting.
|
||||
pub fn button(ui: &mut egui::Ui, icon: Icon, size: f32, tooltip: &str, selected: bool) -> egui::Response {
|
||||
let t = Tokens::get();
|
||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::click());
|
||||
if selected {
|
||||
ui.painter().rect_filled(rect, 3.0, t.tab_active);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, 3.0, t.control_hover.gamma_multiply(0.6));
|
||||
}
|
||||
paint(ui.painter(), rect.shrink(size * 0.08), icon, !ui.is_enabled());
|
||||
let visuals = |fill| ButtonVisuals { fill, stroke: Stroke::NONE, icon: t.icon };
|
||||
let style = IconButtonStyle {
|
||||
normal: visuals(Color32::TRANSPARENT),
|
||||
hovered: visuals(t.control_hover.gamma_multiply(0.6)),
|
||||
pressed: None,
|
||||
selected: visuals(t.tab_active),
|
||||
corner_radius: egui::CornerRadius::same(3),
|
||||
focus_stroke: Stroke::new(1.0, t.accent),
|
||||
};
|
||||
let resp = IconButton::new(crate::i18n::t(tooltip), egui::vec2(size, size), &style).selected(selected).show(ui, |ui, rect, _| {
|
||||
// A single tint would erase the blue geometry and orange picked points. Keep the
|
||||
// original painter and its explicit dimming on the same parent-enabled UI.
|
||||
paint(ui.painter(), rect.shrink(size * 0.08), icon, !ui.is_enabled());
|
||||
});
|
||||
crate::control::record_widget(ui, &resp, tooltip, "button", Some(selected));
|
||||
resp.on_hover_text(crate::i18n::t(tooltip))
|
||||
}
|
||||
|
||||
/// Labels and focus outlines for controls whose existing geometry is painted by the app.
|
||||
/// Pointer focus is opt-in for navigation tabs; drafting buttons keep their command-line flow.
|
||||
pub fn describe_control(ui: &egui::Ui, response: &egui::Response, label: &str, selected: Option<bool>, tab: bool) {
|
||||
let label = crate::i18n::t(label);
|
||||
response.widget_info(|| match selected {
|
||||
Some(selected) => egui::WidgetInfo::selected(egui::WidgetType::Button, response.enabled(), selected, label),
|
||||
None => egui::WidgetInfo::labeled(egui::WidgetType::Button, response.enabled(), label),
|
||||
});
|
||||
if tab {
|
||||
ui.ctx().accesskit_node_builder(response.id, |node| {
|
||||
node.set_role(egui::accesskit::Role::Tab);
|
||||
node.clear_toggled();
|
||||
if let Some(selected) = selected {
|
||||
node.set_selected(selected);
|
||||
}
|
||||
});
|
||||
if response.clicked() {
|
||||
response.request_focus();
|
||||
}
|
||||
}
|
||||
if response.has_focus() && response.rect.width() > 4.0 && response.rect.height() > 4.0 {
|
||||
ui.painter().rect_stroke(response.rect.shrink(2.0), 2.0, Stroke::new(1.0, Tokens::get().accent), egui::StrokeKind::Inside);
|
||||
}
|
||||
crate::control::record_widget(ui, response, label, if tab { "tab" } else { "button" }, selected);
|
||||
}
|
||||
@@ -17,6 +17,8 @@ pub mod context_menu;
|
||||
pub mod control;
|
||||
pub mod credits;
|
||||
pub mod dialogs;
|
||||
pub mod docking;
|
||||
|
||||
pub mod dyninput;
|
||||
pub mod gpu;
|
||||
pub mod i18n;
|
||||
@@ -46,6 +48,8 @@ pub const PREFS_KEY: &str = "cadcraft.prefs";
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct UiState {
|
||||
pub docking: docking::Workspace,
|
||||
|
||||
pub interface_language: i18n::Preference,
|
||||
pub show_toolsets: bool,
|
||||
pub show_palettes: bool,
|
||||
@@ -116,6 +120,8 @@ impl SaveFormat {
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
UiState {
|
||||
docking: docking::Workspace::default(),
|
||||
|
||||
interface_language: i18n::Preference::default(),
|
||||
show_toolsets: true,
|
||||
show_palettes: true,
|
||||
@@ -412,6 +418,7 @@ impl CadApp {
|
||||
|
||||
fn draw(&mut self, ui: &mut egui::Ui) {
|
||||
let t0 = now_ms();
|
||||
control::begin_frame(ui.ctx());
|
||||
let t = theme::Tokens::get();
|
||||
chrome::title_and_toolbar(self, ui);
|
||||
if self.ui.in_window_menu {
|
||||
@@ -424,15 +431,9 @@ impl CadApp {
|
||||
chrome::file_tabs(self, ui);
|
||||
}
|
||||
let has_doc = !self.session.docs.is_empty() && !self.ui.start_tab;
|
||||
if has_doc && self.ui.show_toolsets {
|
||||
palettes::toolsets(self, ui);
|
||||
}
|
||||
if has_doc && self.ui.show_palettes {
|
||||
palettes::right_palettes(self, ui);
|
||||
}
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| {
|
||||
if has_doc {
|
||||
canvas::show(self, ui);
|
||||
docking::show(self, ui);
|
||||
} else {
|
||||
chrome::start_page(self, ui);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,14 @@ pub const MENUS: &[&str] = &["File", "Edit", "View", "Insert", "Format", "Tools"
|
||||
|
||||
/// UI-only commands: (id, label, menu path, shortcut).
|
||||
pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
|
||||
("ui.dock", "Arrange Panel", &[], None),
|
||||
("ui.dock.reset", "Reset Panel Layout", &["Window", "Reset Panel Layout"], None),
|
||||
("ui.open", "Open...", &[], Some("Cmd+O")),
|
||||
("ui.saveas", "Save As...", &[], None),
|
||||
("ui.sample", "Open Sample Drawing", &["Help", "Open Sample Drawing"], None),
|
||||
("ui.toggle.toolsets", "Tool Sets", &["Window", "Tool Sets"], Some("Cmd+3")),
|
||||
("ui.toggle.palettes", "Properties Inspector", &["Window", "Properties Inspector"], Some("Cmd+1")),
|
||||
("ui.panel.properties", "Properties Inspector", &["Window", "Properties Inspector"], Some("Cmd+1")),
|
||||
("ui.panel.layers", "Layers Palette", &["Window", "Layers Palette"], None),
|
||||
("ui.toggle.systemcursor", "Show System Cursor", &["View", "Accessibility", "Show System Cursor"], None),
|
||||
("ui.toggle.toolbar", "Tool Bar", &["Window", "Tool Bar"], None),
|
||||
("ui.toggle.filetabs", "File Tab", &["Window", "File Tab"], None),
|
||||
@@ -61,6 +64,13 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
||||
if let Some(r) = crate::managers::route(app, id, params) {
|
||||
return Some(r);
|
||||
}
|
||||
if matches!(id, "ui.dock" | "ui.dock.reset") {
|
||||
return Some(crate::docking::command(app, id == "ui.dock.reset", params));
|
||||
}
|
||||
if matches!(id, "ui.panel.properties" | "ui.panel.layers") {
|
||||
let panel = if id == "ui.panel.properties" { "properties" } else { "layers" };
|
||||
return Some(crate::docking::command(app, false, &json!({"operation":"open", "panel":panel})));
|
||||
}
|
||||
if let Some(r) = crate::plotstyles::route(app, id, params) {
|
||||
return Some(r);
|
||||
}
|
||||
@@ -128,6 +138,9 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"ui.toggle.toolsets" => {
|
||||
if params.get("on").and_then(Value::as_bool) == Some(true) {
|
||||
return Some(crate::docking::command(app, false, &json!({"operation":"open", "panel":"toolSets"})));
|
||||
}
|
||||
toggle(&mut app.ui.show_toolsets, params);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
@@ -276,8 +289,7 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"properties" | "pr" | "props" | "ch" if params.is_null() => {
|
||||
app.ui.show_palettes = true;
|
||||
Ok(Value::Null)
|
||||
crate::docking::command(app, false, &json!({"operation":"open", "panel":"properties"}))
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
@@ -438,7 +450,7 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) {
|
||||
(sc(cmd, Key::X), "cutclip"),
|
||||
(sc(cmd_shift, Key::V), "pasteblock"),
|
||||
(sc(cmd, Key::V), "pasteclip"),
|
||||
(sc(cmd, Key::Num1), "ui.toggle.palettes"),
|
||||
(sc(cmd, Key::Num1), "ui.panel.properties"),
|
||||
(sc(cmd, Key::Num3), "ui.toggle.toolsets"),
|
||||
(sc(cmd, Key::Num9), "ui.toggle.cmdline"),
|
||||
(sc(Modifiers::NONE, Key::F1), "ui.dialog.commands"),
|
||||
|
||||
@@ -121,7 +121,7 @@ fn command_known(id: &str) -> bool {
|
||||
|
||||
pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
egui::Panel::left("cc_toolsets").exact_size(220.0).resizable(false).frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| {
|
||||
egui::Frame::NONE.fill(t.panel).show(ui, |ui| {
|
||||
let r = ui.max_rect();
|
||||
let p = ui.painter().clone();
|
||||
// Tabs.
|
||||
@@ -140,6 +140,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
egui::FontId::proportional(13.5),
|
||||
if active { t.text } else { t.text_dim },
|
||||
);
|
||||
icons::describe_control(ui, &resp, crate::i18n::t(name), Some(active), true);
|
||||
if resp.clicked() {
|
||||
app.ui.toolset_tab = name.into();
|
||||
}
|
||||
@@ -149,6 +150,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let cr = Rect::from_center_size(pos2(r.right() - 14.0, r.top() + tab_h / 2.0), vec2(14.0, 14.0));
|
||||
let cresp = ui.interact(cr, ui.id().with("ts_collapse"), Sense::click());
|
||||
icons::paint(&p, cr, Icon::ChevronLeft, false);
|
||||
icons::describe_control(ui, &cresp, "Collapse Tool Sets", None, false);
|
||||
if cresp.on_hover_text(crate::tl!("Collapse Tool Sets")).clicked() {
|
||||
app.ui.show_toolsets = false;
|
||||
}
|
||||
@@ -178,6 +180,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
t.text,
|
||||
);
|
||||
icons::paint(pp, Rect::from_center_size(pos2(hr.right() - 12.0, hr.center().y), vec2(11.0, 11.0)), Icon::Gear, false);
|
||||
icons::describe_control(ui, &hresp, name, Some(!collapsed), false);
|
||||
if hresp.clicked() {
|
||||
toggle_group = Some(name.to_string());
|
||||
}
|
||||
@@ -291,7 +294,7 @@ pub fn right_palettes(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
fn layers_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
pub(crate) fn layers_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
section_header(ui, "Layers");
|
||||
// Layer tools row.
|
||||
@@ -586,7 +589,7 @@ fn properties_header(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
properties_header(app, ui);
|
||||
let sel = app.session.selection();
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! The real app's painted geometry at native and compact window sizes.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_geom::Vec2;
|
||||
use cadcraft_ui_egui::{CadApp, Services};
|
||||
use egui::{Rect, Shape, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
fn harness(width: f32, height: f32, scale: f32, message: bool) -> Harness<'static, CadApp> {
|
||||
let mut app = CadApp::new(Session::default(), Services::default());
|
||||
app.run("new", serde_json::json!({})).unwrap();
|
||||
app.ui.start_tab = false;
|
||||
app.ui.in_window_menu = false;
|
||||
app.integrated_titlebar = true;
|
||||
if message {
|
||||
app.set_status("Drawing saved successfully with a long status message");
|
||||
}
|
||||
let mut h = Harness::builder().with_size(vec2(width, height)).with_pixels_per_point(scale).build_ui_state(
|
||||
|ui, app: &mut CadApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.canvas.cursor = Some(Vec2::new(1234.5, -9876.25));
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(5);
|
||||
h
|
||||
}
|
||||
|
||||
fn visible_text(shape: &Shape, clip: Rect, result: &mut Vec<(String, Rect)>) {
|
||||
match shape {
|
||||
Shape::Text(text) => {
|
||||
let rect = text.visual_bounding_rect().intersect(clip);
|
||||
if rect.is_positive() {
|
||||
result.push((text.galley.text().to_string(), rect));
|
||||
}
|
||||
}
|
||||
Shape::Vec(shapes) => {
|
||||
for shape in shapes {
|
||||
visible_text(shape, clip, result);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_labels_never_overlap_at_standard_and_compact_sizes() {
|
||||
let mut overlaps = Vec::new();
|
||||
for (width, height) in [(1200.0, 760.0), (900.0, 560.0)] {
|
||||
for scale in [1.0, 2.0] {
|
||||
for message in [false, true] {
|
||||
let mut h = harness(width, height, scale, message);
|
||||
if let Ok(directory) = std::env::var("CRAFT_UI_VISUAL_DIR") {
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
h.render().unwrap().save(format!("{directory}/cad-{width}-{scale}-{message}.png")).unwrap();
|
||||
}
|
||||
let mut texts = Vec::new();
|
||||
for shape in &h.output().shapes {
|
||||
visible_text(&shape.shape, shape.clip_rect, &mut texts);
|
||||
}
|
||||
texts.retain(|(_, rect)| rect.center().y > height - 26.0);
|
||||
assert!(texts.iter().any(|(text, _)| text == "Layout2"));
|
||||
for (i, (first, first_rect)) in texts.iter().enumerate() {
|
||||
for (second, second_rect) in texts.iter().skip(i + 1) {
|
||||
if first_rect.intersects(*second_rect) {
|
||||
overlaps.push(format!("{width}px @{scale}: {first:?} {first_rect:?} overlaps {second:?} {second_rect:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(overlaps.is_empty(), "{}", overlaps.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_toolbar_overflow_keeps_help_reachable() {
|
||||
let mut h = harness(900.0, 560.0, 1.0, false);
|
||||
h.get_by_label("More toolbar commands").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Help").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().ui.dialog.as_deref(), Some("about"));
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Panel moves use the real app shell, retain commands, and survive a complete UI-state reload.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::{CadApp, Services, UiState, docking::Panel};
|
||||
use egui::{Event, Modifiers, PointerButton, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
fn harness(state: Option<UiState>, width: f32, scale: f32) -> Harness<'static, CadApp> {
|
||||
let mut app = CadApp::new(Session::default(), Services::default());
|
||||
app.run("rectang", json!({"p1":[30,30],"p2":[170,110]})).unwrap();
|
||||
app.run("circle", json!({"center":[100,70],"radius":26})).unwrap();
|
||||
app.run("line", json!({"points":[[30,30],[170,110]]})).unwrap();
|
||||
app.run("view.set", json!({"center":[100,70],"height":220})).unwrap();
|
||||
if let Some(state) = state {
|
||||
app.ui = state;
|
||||
}
|
||||
app.ui.in_window_menu = false;
|
||||
app.integrated_titlebar = true;
|
||||
let mut harness = Harness::builder().with_size(vec2(width, 760.0)).with_pixels_per_point(scale).build_ui_state(
|
||||
|ui, app: &mut CadApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.run_steps(5);
|
||||
harness
|
||||
}
|
||||
|
||||
fn drag(harness: &mut Harness<'_, CadApp>, from: egui::Pos2, to: egui::Pos2) {
|
||||
harness.event(Event::PointerMoved(from));
|
||||
harness.run_steps(2);
|
||||
harness.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
harness.run_steps(2);
|
||||
for step in 1..=8 {
|
||||
harness.event(Event::PointerMoved(from.lerp(to, step as f32 / 8.0)));
|
||||
harness.run_steps(1);
|
||||
}
|
||||
harness.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
harness.run_steps(4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_panel_drag_float_close_reopen_and_full_workspace_reload() {
|
||||
let mut harness = harness(None, 1280.0, 1.0);
|
||||
let source = harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Layers").rect().center();
|
||||
let target = harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Tool Sets").rect().center();
|
||||
drag(&mut harness, source, target);
|
||||
let location = harness.state().ui.docking.layout.location(&Panel::Layers).unwrap();
|
||||
assert_eq!(location.anchor, Some(Panel::ToolSets));
|
||||
harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Layers").click_button(PointerButton::Secondary);
|
||||
harness.run_steps(3);
|
||||
harness.get_by_label("Float panel").click();
|
||||
harness.run_steps(5);
|
||||
let rect = harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Layers)).unwrap().rect;
|
||||
harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Layers").click_button(PointerButton::Secondary);
|
||||
harness.run_steps(3);
|
||||
harness.get_by_label("Close panel").click();
|
||||
harness.run_steps(4);
|
||||
assert!(!harness.state().ui.docking.layout.contains(&Panel::Layers));
|
||||
harness.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap();
|
||||
harness.run_steps(3);
|
||||
harness.state_mut().run("ui.toggle.palettes", json!({"on": true})).unwrap();
|
||||
harness.run_steps(4);
|
||||
assert_eq!(harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Layers)).unwrap().rect, rect);
|
||||
let saved = serde_json::to_string(&harness.state().ui).unwrap();
|
||||
let expected = serde_json::to_value(&harness.state().ui.docking).unwrap();
|
||||
let mut reloaded = self::harness(Some(serde_json::from_str(&saved).unwrap()), 1280.0, 1.0);
|
||||
assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), expected);
|
||||
reloaded.state_mut().run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"toolSets", "zone":"center"})).unwrap();
|
||||
reloaded.run_steps(4);
|
||||
reloaded.get_by_role_and_label(egui::accesskit::Role::Tab, "Tool Sets").click();
|
||||
reloaded.run_steps(3);
|
||||
reloaded.get_by_label("Line").click();
|
||||
reloaded.run_steps(3);
|
||||
assert_eq!(reloaded.state().session.running.as_ref().map(|command| command.id.as_str()), Some("line"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_requests_leave_document_and_panel_layout_unchanged() {
|
||||
let mut harness = harness(None, 1000.0, 1.0);
|
||||
let before = serde_json::to_value(&harness.state().ui.docking).unwrap();
|
||||
let drawing = harness.state().session.doc().unwrap().clone();
|
||||
for params in [
|
||||
json!({"operation":"float", "panel":"canvas", "rect":[100,100,400,300]}),
|
||||
json!({"operation":"move", "panel":"toolSets", "target":"canvas", "zone":"center"}),
|
||||
json!({"operation":"float", "panel":"layers", "rect":[0,0,-1,200]}),
|
||||
json!({"operation":"move", "panel":"missing", "target":"canvas", "zone":"right"}),
|
||||
] {
|
||||
assert!(harness.state_mut().run("ui.dock", params).is_err());
|
||||
assert_eq!(serde_json::to_value(&harness.state().ui.docking).unwrap(), before);
|
||||
assert_eq!(harness.state().session.doc().unwrap(), &drawing);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docking_renders_default_float_and_redocked_at_normal_and_narrow_sizes() {
|
||||
for width in [900.0, 1280.0] {
|
||||
for scale in [1.0, 2.0] {
|
||||
let mut harness = harness(None, width, scale);
|
||||
for state in ["default", "floating", "redocked"] {
|
||||
if state == "floating" {
|
||||
harness.state_mut().run("ui.dock", json!({"operation":"float", "panel":"layers", "rect":[160,100,300,380]})).unwrap();
|
||||
} else if state == "redocked" {
|
||||
harness
|
||||
.state_mut()
|
||||
.run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"center"}))
|
||||
.unwrap();
|
||||
}
|
||||
harness.run_steps(5);
|
||||
harness.state().ui.docking.validate().unwrap();
|
||||
let content = harness.state().ui.docking.layout.panels();
|
||||
assert_eq!(content.len(), 4);
|
||||
assert!(content.contains(&&Panel::Canvas));
|
||||
if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") {
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
harness.render().unwrap().save(format!("{directory}/cad-{width}-{scale}-{state}.png")).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_reaches_split_stack_resize_and_tab_reorder_through_the_same_handler() {
|
||||
use craft_ui::{docking::Action, layout::SplitSize};
|
||||
let mut h = harness(None, 1280.0, 1.0);
|
||||
h.state_mut()
|
||||
.run(
|
||||
"ui.dock",
|
||||
json!({"action":serde_json::to_value(Action::<Panel>::ResizeSplit { path: vec![], size: SplitSize::FixedFirst(280.0) }).unwrap()}),
|
||||
)
|
||||
.unwrap();
|
||||
h.state_mut().run("ui.dock", json!({"operation":"setStackOpen","panel":"layers","open":false})).unwrap();
|
||||
h.state_mut().run("ui.dock", json!({"operation":"resizeStack","panel":"layers","height":160.0})).unwrap();
|
||||
assert_eq!(h.state().ui.docking.layout.location(&Panel::Layers).unwrap().stack, Some((false, Some(160.0))));
|
||||
h.state_mut().run("ui.dock", json!({"operation":"move","panel":"layers","target":"properties","zone":"center","before":null})).unwrap();
|
||||
h.run_steps(4);
|
||||
let panels = h.state().ui.docking.layout.panels();
|
||||
assert!(panels.iter().position(|p| **p == Panel::Properties).unwrap() < panels.iter().position(|p| **p == Panel::Layers).unwrap());
|
||||
assert_eq!(h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap().size, Some(SplitSize::FixedFirst(280.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_boundary_resizes_by_pointer_and_keyboard_without_changing_the_drawing() {
|
||||
let mut h = harness(None, 1280.0, 1.0);
|
||||
let drawing = h.state().session.doc().unwrap().clone();
|
||||
let center = h
|
||||
.get_all_by_role_and_label(egui::accesskit::Role::Splitter, "Resize panels")
|
||||
.into_iter()
|
||||
.find(|node| node.rect().width() > node.rect().height())
|
||||
.unwrap()
|
||||
.rect()
|
||||
.center();
|
||||
drag(&mut h, center, center + vec2(0.0, -45.0));
|
||||
let height = h.state().ui.docking.layout.location(&Panel::Layers).unwrap().stack.unwrap().1.unwrap();
|
||||
h.get_all_by_role_and_label(egui::accesskit::Role::Splitter, "Resize panels")
|
||||
.into_iter()
|
||||
.find(|node| node.rect().width() > node.rect().height())
|
||||
.unwrap()
|
||||
.focus();
|
||||
h.run_steps(2);
|
||||
h.key_press(egui::Key::ArrowDown);
|
||||
h.run_steps(4);
|
||||
let after = h.state().ui.docking.layout.location(&Panel::Layers).unwrap().stack.unwrap().1.unwrap();
|
||||
assert!(after > height, "keyboard resize must reach the same panel command handler");
|
||||
assert_eq!(h.state().session.doc().unwrap(), &drawing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_restore_preserves_order_and_rectangle_in_both_close_orders_after_reload() {
|
||||
use craft_ui::docking::{Action, Placement};
|
||||
for order in [[Panel::Layers, Panel::Properties], [Panel::Properties, Panel::Layers]] {
|
||||
let mut workspace = cadcraft_ui_egui::docking::Workspace::default();
|
||||
let rect = [50.0, 60.0, 320.0, 400.0];
|
||||
workspace.apply(Action::Float { panel: Panel::Layers, rect }).unwrap();
|
||||
workspace
|
||||
.apply(Action::Move { panel: Panel::Properties, anchor: Panel::Layers, placement: Placement::Tab { before: Some(Panel::Layers) } })
|
||||
.unwrap();
|
||||
let expected = workspace.layout.floating[0].panels.clone();
|
||||
for panel in order {
|
||||
workspace.set_visible(panel, false).unwrap();
|
||||
}
|
||||
let saved = serde_json::to_string(&workspace).unwrap();
|
||||
workspace = serde_json::from_str(&saved).unwrap();
|
||||
workspace.reveal_panel(Panel::Properties).unwrap();
|
||||
workspace.reveal_panel(Panel::Layers).unwrap();
|
||||
assert_eq!(workspace.layout.floating.len(), 1);
|
||||
assert_eq!(workspace.layout.floating[0].panels, expected);
|
||||
assert_eq!(workspace.layout.floating[0].rect, rect);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_properties_open_restores_closed_panel_and_activates_inactive_tab() {
|
||||
let mut harness = harness(None, 1000.0, 1.0);
|
||||
harness.state_mut().run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap();
|
||||
harness.state_mut().cmdline("PROPERTIES");
|
||||
assert!(harness.state().ui.docking.layout.contains(&Panel::Properties));
|
||||
harness.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,250,300,350]})).unwrap();
|
||||
harness.state_mut().run("ui.dock", json!({"operation":"move","panel":"properties","target":"layers","zone":"center"})).unwrap();
|
||||
harness.state_mut().run("ui.dock", json!({"operation":"activate","panel":"layers"})).unwrap();
|
||||
harness.state_mut().run("ui.panel.properties", json!({})).unwrap();
|
||||
harness.run_steps(3);
|
||||
let location = harness.state().ui.docking.layout.location(&Panel::Properties).unwrap();
|
||||
assert_eq!(location.anchor, Some(Panel::Layers));
|
||||
let group = harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Properties)).unwrap();
|
||||
assert_eq!(group.panels.get(group.active), Some(&Panel::Properties));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn middle_press_over_floating_panel_does_not_pan_drawing() {
|
||||
let mut harness = harness(None, 1280.0, 1.0);
|
||||
harness.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,250,300,350]})).unwrap();
|
||||
harness.run_steps(4);
|
||||
let before = harness.state().session.state().unwrap().view();
|
||||
let at = egui::pos2(450.0, 330.0);
|
||||
harness.event(Event::PointerMoved(at));
|
||||
harness.event(Event::PointerButton { pos: at, button: PointerButton::Middle, pressed: true, modifiers: Modifiers::NONE });
|
||||
harness.run_steps(2);
|
||||
harness.event(Event::PointerMoved(at + egui::vec2(40.0, 25.0)));
|
||||
harness.run_steps(3);
|
||||
let after = harness.state().session.state().unwrap().view();
|
||||
assert_eq!(after.center, before.center);
|
||||
assert_eq!(after.height, before.height);
|
||||
harness.event(Event::PointerButton { pos: at, button: PointerButton::Middle, pressed: false, modifiers: Modifiers::NONE });
|
||||
harness.run_steps(2);
|
||||
}
|
||||
|
||||
fn type_command(h: &mut Harness<'_, CadApp>, command: &str) {
|
||||
h.ctx.memory_mut(|memory| {
|
||||
if let Some(id) = memory.focused() {
|
||||
memory.surrender_focus(id);
|
||||
}
|
||||
});
|
||||
h.event(Event::Text(command.into()));
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().cmd.buffer, command);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().cmd.buffer.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_typed_in_rendered_command_line_reopens_and_selects_actual_workspace() {
|
||||
let mut h = harness(None, 1280.0, 1.0);
|
||||
h.state_mut().run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap();
|
||||
h.run_steps(3);
|
||||
let drawing = h.state().session.doc().unwrap().clone();
|
||||
let tools = h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap();
|
||||
type_command(&mut h, "PROPERTIES");
|
||||
assert!(h.state().ui.docking.layout.contains(&Panel::Properties));
|
||||
assert!(h.state().ui.docking.layout.contains(&Panel::Layers));
|
||||
let restored_tools = h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap();
|
||||
assert_eq!(restored_tools.anchor, tools.anchor);
|
||||
assert_eq!(restored_tools.placement, tools.placement);
|
||||
assert_eq!(restored_tools.size, tools.size);
|
||||
assert_eq!(restored_tools.floating, tools.floating);
|
||||
assert_eq!(restored_tools.stack, tools.stack);
|
||||
assert!(restored_tools.sibling.contains(&Panel::Properties));
|
||||
|
||||
h.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,250,300,350]})).unwrap();
|
||||
h.state_mut().run("ui.dock", json!({"operation":"move","panel":"properties","target":"layers","zone":"center"})).unwrap();
|
||||
h.state_mut().run("ui.dock", json!({"operation":"activate","panel":"layers"})).unwrap();
|
||||
h.run_steps(3);
|
||||
type_command(&mut h, "PROPERTIES");
|
||||
let group = h.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Properties)).unwrap();
|
||||
assert_eq!(group.panels.get(group.active), Some(&Panel::Properties));
|
||||
assert_eq!(h.state().session.doc().unwrap(), &drawing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_typed_in_canvas_preserves_authoritative_workspace_replacement() {
|
||||
let mut h = harness(None, 1280.0, 1.0);
|
||||
h.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,200,300,350]})).unwrap();
|
||||
h.run_steps(3);
|
||||
let drawing = h.state().session.doc().unwrap().clone();
|
||||
type_command(&mut h, "ui.dock.reset");
|
||||
let expected = cadcraft_ui_egui::docking::Workspace::default();
|
||||
assert_eq!(h.state().ui.docking.layout, expected.layout);
|
||||
assert_eq!(h.state().ui.docking.hidden, expected.hidden);
|
||||
assert_eq!(h.state().session.doc().unwrap(), &drawing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_reset_during_splitter_drag_discards_old_resize_and_ends_gesture() {
|
||||
use craft_ui::docking::Action;
|
||||
use craft_ui::layout::SplitSize;
|
||||
let mut h = harness(None, 1280.0, 1.0);
|
||||
// Give the old root divider a size different from Reset's 220px ToolSets pane.
|
||||
h.state_mut()
|
||||
.run(
|
||||
"ui.dock",
|
||||
json!({"action":Action::<Panel>::ResizeSplit {
|
||||
path: Vec::new(), size: SplitSize::FixedFirst(280.0),
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
h.run_steps(3);
|
||||
let drawing = h.state().session.doc().unwrap().clone();
|
||||
let splitter = egui::Id::new("cadcraft-docking").with(("split", Vec::<bool>::new()));
|
||||
let from = h.ctx.read_response(splitter).expect("rendered root divider").rect.center();
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
// Canvas is visited before the ancestor ResizeHandle. The command runs before
|
||||
// that ancestor queues this frame's ResizeSplit against the old immutable tree.
|
||||
assert_eq!(h.ctx.dragged_id(), Some(splitter), "actual divider owns the pointer");
|
||||
assert!(h.ctx.input(|input| input.pointer.primary_down()));
|
||||
// A ResizeHandle takes keyboard focus on press. Release only that keyboard focus
|
||||
// so the real Canvas command line can submit while the pointer remains captured.
|
||||
h.ctx.memory_mut(|memory| memory.surrender_focus(splitter));
|
||||
assert!(!h.ctx.egui_wants_keyboard_input());
|
||||
let to = from + vec2(36.0, 0.0);
|
||||
h.event(Event::PointerMoved(to));
|
||||
h.event(Event::Text("ui.dock.reset".into()));
|
||||
h.event(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
let expected = cadcraft_ui_egui::docking::Workspace::default();
|
||||
assert!(h.state().cmd.buffer.is_empty(), "Canvas submitted the actual reset command");
|
||||
assert_eq!(h.state().ui.docking.layout, expected.layout, "old-frame resize must not modify Reset's tree");
|
||||
assert_eq!(h.state().ui.docking.hidden, expected.hidden);
|
||||
// Keep the physical button down: replacement must terminate the old divider gesture,
|
||||
// rather than letting its stable root ID continue resizing the new workspace.
|
||||
let later = to + vec2(24.0, 0.0);
|
||||
h.event(Event::PointerMoved(later));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.docking.layout, expected.layout, "continued old drag must leave the replacement unchanged");
|
||||
h.event(Event::PointerButton { pos: later, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().ui.docking.layout, expected.layout);
|
||||
assert_eq!(h.state().session.doc().unwrap(), &drawing);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Interaction and optional offscreen evidence for the application's painted controls.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::{CadApp, Services, icons, theme};
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Response, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
fn app() -> CadApp {
|
||||
let mut app = CadApp::new(Session::default(), Services::default());
|
||||
app.run("ui.sample", serde_json::json!({})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ButtonState {
|
||||
selected: bool,
|
||||
disabled: bool,
|
||||
clicks: usize,
|
||||
response: Option<Response>,
|
||||
}
|
||||
|
||||
fn button_frame(ctx: &egui::Context, state: &mut ButtonState, events: Vec<Event>) -> egui::FullOutput {
|
||||
let mut output = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
|
||||
let response = ui.add_enabled_ui(!state.disabled, |ui| icons::button(ui, icons::Icon::Line, 40.0, "Draw line", state.selected)).inner;
|
||||
state.clicks += usize::from(response.clicked());
|
||||
state.response = Some(response);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
output
|
||||
}
|
||||
|
||||
fn key(key: Key, pressed: bool) -> Event {
|
||||
Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
fn pointer(pos: egui::Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drafting_button_preserves_multicolor_selection_and_accessibility() {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.enable_accesskit();
|
||||
let mut state = ButtonState { selected: true, ..Default::default() };
|
||||
button_frame(&ctx, &mut state, vec![]);
|
||||
let center = state.response.as_ref().unwrap().rect.center();
|
||||
let out = button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true)]);
|
||||
let t = theme::Tokens::get();
|
||||
assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.fill == t.tab_active)));
|
||||
assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::LineSegment { stroke, .. } if stroke.color == t.icon_accent)));
|
||||
assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Circle(c) if c.fill == t.icon_point)));
|
||||
let update = out.platform_output.accesskit_update.unwrap();
|
||||
let node = &update.nodes.iter().find(|(_, n)| n.label() == Some("Draw line")).unwrap().1;
|
||||
assert_eq!(node.role(), egui::accesskit::Role::Button);
|
||||
assert_eq!(node.toggled(), Some(egui::accesskit::Toggled::True));
|
||||
button_frame(&ctx, &mut state, vec![pointer(center, false)]);
|
||||
assert_eq!(state.clicks, 1);
|
||||
assert!(state.selected, "selection belongs to the caller");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drafting_button_keyboard_disabled_state_and_click_cancellation() {
|
||||
for key_code in [Key::Enter, Key::Space] {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.enable_accesskit();
|
||||
let mut state = ButtonState::default();
|
||||
button_frame(&ctx, &mut state, vec![]);
|
||||
button_frame(&ctx, &mut state, vec![]);
|
||||
let response = state.response.clone().unwrap();
|
||||
ctx.memory_mut(|m| m.request_focus(response.id));
|
||||
let out = button_frame(&ctx, &mut state, vec![]);
|
||||
assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r)
|
||||
if r.rect == response.rect.shrink(2.0) && r.stroke.color == theme::Tokens::get().accent)));
|
||||
button_frame(&ctx, &mut state, vec![key(key_code, true)]);
|
||||
button_frame(&ctx, &mut state, vec![key(key_code, false)]);
|
||||
assert_eq!(state.clicks, 1);
|
||||
let center = response.rect.center();
|
||||
button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true)]);
|
||||
button_frame(&ctx, &mut state, vec![Event::PointerMoved(pos2(300.0, 150.0))]);
|
||||
button_frame(&ctx, &mut state, vec![pointer(pos2(300.0, 150.0), false)]);
|
||||
assert_eq!(state.clicks, 1, "releasing away must not start a command");
|
||||
state.disabled = true;
|
||||
button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true), key(key_code, true)]);
|
||||
let out = button_frame(&ctx, &mut state, vec![pointer(center, false), key(key_code, false)]);
|
||||
assert_eq!(state.clicks, 1);
|
||||
assert!(!state.response.as_ref().unwrap().enabled());
|
||||
assert!(out.platform_output.accesskit_update.unwrap().nodes.iter().any(|(_, n)| n.label() == Some("Draw line") && n.is_disabled()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drafting_palette_pointer_and_keyboard_start_the_same_commands() {
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|
||||
|ui, app: &mut CadApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
app(),
|
||||
);
|
||||
h.run_steps(5);
|
||||
h.get_by_label("Line").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.running.as_ref().unwrap().id, "line");
|
||||
h.state_mut().session.cancel();
|
||||
for key in [Key::Enter, Key::Space] {
|
||||
h.get_by_label("Circle").focus();
|
||||
h.run_steps(2);
|
||||
assert!(h.get_by_label("Circle").is_focused());
|
||||
h.key_press(key);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.running.as_ref().unwrap().id, "circle");
|
||||
h.state_mut().session.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_controls_visuals() {
|
||||
let Some(directory) = std::env::var_os("CRAFT_UI_SUITE_SHOTS").map(std::path::PathBuf::from) else { return };
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
for scale in [1.0, 2.0] {
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
|
||||
|ui, app: &mut CadApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
app(),
|
||||
);
|
||||
for (name, size) in [("normal", vec2(1600.0, 1000.0)), ("narrow", vec2(900.0, 600.0))] {
|
||||
h.set_size(size);
|
||||
h.run_steps(6);
|
||||
h.render().unwrap().save(directory.join(format!("cadcraft-{name}-dark-{scale}x.png"))).unwrap();
|
||||
}
|
||||
let mut controls = Harness::builder().with_size(vec2(340.0, 150.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
|
||||
|ui, rects: &mut Vec<egui::Rect>| {
|
||||
theme::apply(ui.ctx(), egui::Theme::Dark, false);
|
||||
rects.clear();
|
||||
for enabled in [true, false] {
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for selected in [false, true] {
|
||||
for icon in [icons::Icon::Line, icons::Icon::Circle, icons::Icon::Move] {
|
||||
rects.push(icons::button(ui, icon, 40.0, "Drafting control", selected).rect);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
Vec::new(),
|
||||
);
|
||||
controls.run_steps(4);
|
||||
controls.render().unwrap().save(directory.join(format!("cadcraft-controls-dark-{scale}x.png"))).unwrap();
|
||||
for (name, index, pressed) in [("hover", 0, false), ("selected-hover", 3, false), ("disabled-hover", 6, false), ("pressed", 0, true)] {
|
||||
let center = controls.state()[index].center();
|
||||
controls.event(Event::PointerMoved(center));
|
||||
if pressed {
|
||||
controls.event(pointer(center, true));
|
||||
}
|
||||
controls.run_steps(2);
|
||||
controls.render().unwrap().save(directory.join(format!("cadcraft-controls-{name}-dark-{scale}x.png"))).unwrap();
|
||||
}
|
||||
controls.event(pointer(controls.state()[0].center(), false));
|
||||
controls.event(Event::PointerGone);
|
||||
controls.run_steps(2);
|
||||
controls.key_press(Key::Tab);
|
||||
controls.run_steps(2);
|
||||
controls.render().unwrap().save(directory.join(format!("cadcraft-controls-focus-dark-{scale}x.png"))).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Real key traversal of the custom painted controls, without assigning widget focus.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::{CadApp, Services};
|
||||
use egui::{Key, Modifiers, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
fn harness() -> Harness<'static, CadApp> {
|
||||
let mut app = CadApp::new(Session::default(), Services::default());
|
||||
app.integrated_titlebar = true;
|
||||
app.ui.in_window_menu = false;
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|
||||
|ui, app: &mut CadApp| {
|
||||
ui.ctx().set_os(egui::os::OperatingSystem::Mac);
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(5);
|
||||
h
|
||||
}
|
||||
|
||||
fn tab_to(h: &mut Harness<'_, CadApp>, label: &str) {
|
||||
for _ in 0..160 {
|
||||
if h.query_by_label(label).is_some_and(|node| node.is_focused()) {
|
||||
return;
|
||||
}
|
||||
h.key_press(Key::Tab);
|
||||
h.run_steps(2);
|
||||
}
|
||||
panic!("Tab traversal did not reach {label}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toolbar_tab_and_shift_tab_skip_title_drag_and_activate_once() {
|
||||
let mut h = harness();
|
||||
h.key_press(Key::Tab);
|
||||
h.run_steps(2);
|
||||
assert!(h.get_by_label("New Drawing (⌘N)").is_focused(), "the title drag region is not a keyboard control");
|
||||
h.key_press(Key::Tab);
|
||||
h.run_steps(2);
|
||||
assert!(h.get_by_label("Open (⌘O)").is_focused());
|
||||
h.key_press_modifiers(Modifiers::SHIFT, Key::Tab);
|
||||
h.run_steps(2);
|
||||
assert!(h.get_by_label("New Drawing (⌘N)").is_focused());
|
||||
let before = h.state().session.docs.len();
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.docs.len(), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toolset_tabs_traverse_and_activate_without_drafting_commands() {
|
||||
let mut h = harness();
|
||||
tab_to(&mut h, "Drafting");
|
||||
h.key_press(Key::Tab);
|
||||
h.run_steps(2);
|
||||
assert!(h.get_by_label("Modeling").is_focused());
|
||||
h.key_press(Key::Space);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().ui.toolset_tab, "Modeling");
|
||||
assert!(h.state().session.running.is_none());
|
||||
h.key_press_modifiers(Modifiers::SHIFT, Key::Tab);
|
||||
h.run_steps(2);
|
||||
assert!(h.get_by_label("Drafting").is_focused());
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().ui.toolset_tab, "Drafting");
|
||||
tab_to(&mut h, "Line");
|
||||
h.key_press(Key::Tab);
|
||||
h.run_steps(2);
|
||||
assert!(h.get_by_label("Polyline").is_focused());
|
||||
h.key_press_modifiers(Modifiers::SHIFT, Key::Tab);
|
||||
h.run_steps(2);
|
||||
assert!(h.get_by_label("Line").is_focused());
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.running.as_ref().map(|command| command.id.as_str()), Some("line"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_tabs_have_distinct_keyboard_identity_and_activation() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("new", serde_json::json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
let title = h.state().session.docs[0].title.clone();
|
||||
tab_to(&mut h, "Start");
|
||||
h.key_press(Key::Tab);
|
||||
h.run_steps(2);
|
||||
assert!(h.get_by_label(&title).is_focused());
|
||||
h.key_press(Key::Space);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.active, 0);
|
||||
assert!(!h.state().ui.start_tab);
|
||||
}
|
||||
@@ -28,7 +28,8 @@ The server waits up to 60 s for the app to answer a request. After that it repli
|
||||
| `engine.execute` | `{command, params}` | Run any command with JSON parameters. Never opens a dialog. |
|
||||
| `engine.commands` | | Every command: id, label, menu path, shortcut, aliases, params doc, enabled. |
|
||||
| `drawing.inspect` | `{entities?, limit?}` | Drawing summary (counts, extents, layers, styles, blocks, layouts, selection, undo) and entities with handles and geometry. |
|
||||
| `ui.inspect` | | UI state, canvas rect, view, cursor and snap, performance counters; `closePrompt`: the drawing a "Save changes?" prompt is asking about (null when none). |
|
||||
| `ui.inspect` | | UI state, canvas rect, view, cursor and snap, performance counters; `closePrompt`: the drawing a "Save changes?" prompt is asking about (null when none); and actual egui keyboard focus (`focus.id`, `focus.label`, `focus.role`). |
|
||||
| `ui.elements` | `{label?, role?, focused?}` | Observed toolbar, tab, drafting and layer-palette controls from the latest UI frame. Filters match exactly. Each entry has its real egui `id`, label, role, rectangle `[x,y,width,height]`, enabled/visible/selected state and current focus. |
|
||||
| `ui.menu.list` / `ui.menu.invoke` | `{command}` | The menu tree; invoke an item like a click (interactive). |
|
||||
| `ui.pointer` | `{x, y, space?: "world"\|"screen", button?, action?: "click"\|"move"}` | Click in the drawing area (world coordinates by default). Right button = Enter. |
|
||||
| `ui.click` / `ui.move` | `{x, y, button?, shift?}` | Real egui pointer events in screen points (reach every widget). |
|
||||
@@ -36,6 +37,7 @@ The server waits up to 60 s for the app to answer a request. After that it repli
|
||||
| `ui.key` / `ui.text` | `{key, cmd?, shift?, alt?}` / `{text}` | Synthetic keyboard input. |
|
||||
| `ui.set` | UiState fields | Show/hide palettes, toolbars, command line, ViewCube…; `theme: "system"\|"light"\|"dark"` (also `engine.execute` with `ui.theme {theme}`); `ui.inspect` reports the theme shown (`theme`: light/dark). `saveFormat: "dxf"\|"dwg"` (also `ui.saveformat {format}`) is the format Save As suggests for new drawings. |
|
||||
| `ui.resize` | `{width, height}` | Resize the window. |
|
||||
| `ui.focus` | | Activate the native window. Does not assign focus to a widget. |
|
||||
| `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). |
|
||||
| `ui.render` | `{path?, width?, height?, fit?}` | Headless render. `fit` (default true) frames the whole drawing; `false` uses the current view. Replies `{pngBase64, width, height, bytes, path?}`. |
|
||||
| `app.open` / `app.save` / `app.quit` | `{path}` | File operations. |
|
||||
@@ -43,6 +45,41 @@ The server waits up to 60 s for the app to answer a request. After that it repli
|
||||
The MCP server (`cadcraft-cli mcp --connect 127.0.0.1:PORT`) wraps this protocol; headless
|
||||
(`cadcraft-cli mcp`) it serves the same methods from an in-process session.
|
||||
|
||||
`ui.key` queues real egui key events; its reply acknowledges the queue. Poll `ui.inspect` or
|
||||
`ui.elements` for the resulting focus or state before sending the next action. Use Tab and
|
||||
Shift-Tab to traverse controls and Enter/Space to activate them. The observed registry is not
|
||||
the operating system's accessibility tree and does not establish physical input or screen-reader
|
||||
acceptance. Unregistered controls still report their actual focus ID, with a null label/role.
|
||||
|
||||
The native host restores layout choices from `workspace.json` and saves changes atomically.
|
||||
It stores palette/toolset visibility, selected toolset, collapsed groups, command-line history
|
||||
height and related chrome choices. It does not store drawings, command buffers, dialogs or the
|
||||
current Start tab. The default directory is `~/Library/Application Support/CADCraft` on macOS,
|
||||
`%APPDATA%/CADCraft` on Windows, and `$XDG_CONFIG_HOME/cadcraft` or `~/.config/cadcraft` elsewhere.
|
||||
Set `CADCRAFT_CONFIG_DIR` to a private directory for an isolated profile; an explicitly empty
|
||||
value disables workspace reads and writes. An invalid, oversized or newer-version file is
|
||||
reported and left untouched, with persistence disabled for that launch. Repair or move that
|
||||
file before restarting to resume saving workspace choices.
|
||||
|
||||
## Panel 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. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents.
|
||||
|
||||
Panel IDs: `toolSets`, `layers`, `properties`. The protected center is `canvas`.
|
||||
|
||||
`ui.dock` accepts `operation`; basic per-panel operations also require `panel`. The advanced `resizeSplit` operation below uses a split `path` instead of a panel. Basic operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
|
||||
|
||||
Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"layers","rect":[100,100,320,440]}}`.
|
||||
|
||||
Advanced `ui.dock` operations use the same validated transaction as pointer gestures:
|
||||
`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and
|
||||
`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`),
|
||||
`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null.
|
||||
A center `move` can include `before` (panel ID or null to append) for tab order.
|
||||
The equivalent `{"action": ...}` form accepts a serialized shared docking action;
|
||||
this is also the form emitted by the renderer. Protected-canvas and app-specific panel
|
||||
rules apply equally to both forms.
|
||||
|
||||
## Command-line replies
|
||||
|
||||
The app and the headless session reply with the same fields.
|
||||
|
||||
+2
-2
@@ -69,7 +69,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", "egui-wgpu", "wgpu", "winit", "egui_kittest", "rfd", "bevy*"];
|
||||
pub const UI_CRATES: &[&str] = &["craft-ui", "egui", "eframe", "egui-wgpu", "wgpu", "winit", "egui_kittest", "rfd", "bevy*"];
|
||||
|
||||
/// First layer allowed to use UI crates.
|
||||
pub const UI_MIN_LAYER: u8 = 5;
|
||||
@@ -282,7 +282,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ui_crates_below_l6_flagged() {
|
||||
for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] {
|
||||
for dep in ["craft-ui", "egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] {
|
||||
let v = check(&[c("cadcraft-engine", &[(dep, Normal, false)])]);
|
||||
assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 4, .. }]), "{dep}");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user