Merge pull request #1311 from aiongg/docking-trial

Panels: move onto craft-ui's shared docking
This commit is contained in:
aiongg
2026-10-25 14:36:15 -04:00
committed by GitHub
26 changed files with 3023 additions and 1287 deletions
Generated
+18 -15
View File
@@ -950,7 +950,7 @@ dependencies = [
[[package]]
name = "craft-atomic"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e#e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e"
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
dependencies = [
"rustix 1.1.5",
]
@@ -958,7 +958,7 @@ dependencies = [
[[package]]
name = "craft-cms"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e#e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e"
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
dependencies = [
"rayon",
]
@@ -966,12 +966,12 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
dependencies = [
"bytemuck",
"hayro",
@@ -984,7 +984,7 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
dependencies = [
"craft-winprint-gdi",
"miniz_oxide 0.8.9",
@@ -997,16 +997,20 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
dependencies = [
"egui",
"log",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"serde",
]
[[package]]
name = "craft-window"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68"
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
dependencies = [
"egui",
"objc2 0.6.4",
@@ -2467,7 +2471,7 @@ dependencies = [
[[package]]
name = "hayro"
version = "0.7.1"
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
dependencies = [
"bytemuck",
"hayro-interpret",
@@ -2487,7 +2491,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
[[package]]
name = "hayro-cmap"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
dependencies = [
"brotli",
"hayro-postscript",
@@ -2496,7 +2500,7 @@ dependencies = [
[[package]]
name = "hayro-interpret"
version = "0.7.0"
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
dependencies = [
"bitflags 2.13.2",
"hayro-cmap",
@@ -2514,7 +2518,7 @@ dependencies = [
[[package]]
name = "hayro-jbig2"
version = "0.3.0"
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
dependencies = [
"fearless_simd",
"hayro-ccitt",
@@ -2532,13 +2536,12 @@ dependencies = [
[[package]]
name = "hayro-postscript"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
dependencies = [
"flate2",
"hayro-ccitt",
@@ -3039,7 +3042,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
dependencies = [
"base64 0.22.1",
"bumpalo",
+15 -14
View File
@@ -12,7 +12,7 @@ repository = "https://github.com/storytold/designcraft"
[workspace.dependencies]
# Shared reader and writer; publish the immutable revision before this consumer.
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", version = "=0.1.0", default-features = false }
# internal
designcraft-geom = { path = "crates/geom" }
designcraft-color = { path = "crates/color" }
@@ -75,20 +75,20 @@ zip = { version = "3", default-features = false, features = ["deflate"] }
indd = { git = "https://github.com/storytold/indd-utils", branch = "main" }
# Which paths stored in documents may be followed (craft-libs).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
# Atomic file replacement for saves and exports (craft-libs).
craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" }
craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
# File › Print through the system print spooler (craft-libs).
craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
# ICC colour management: working spaces, transforms, black point compensation (craft-libs).
craft-cms = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" }
craft-cms = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
# ui
# Shared frontend components; keep this dependency above the engine boundary.
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] }
# The application bar as the window's title bar (craft-libs); its egui 0.37 port.
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68", features = ["egui"] }
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["serde"] }
# The application bar as the window's title bar (craft-libs).
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["egui"] }
egui = "0.37"
egui_extras = { version = "0.37", default-features = false, features = ["svg"] }
eframe = { version = "0.37", default-features = false }
@@ -126,13 +126,14 @@ debug = 0
# Opt-in cancelable macOS termination on the pinned winit 0.30 line.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", version = "=0.8.2" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
hayro = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.1" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.0" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.2" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.3.0" }
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0" }
hayro = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", version = "=0.7.1" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", version = "=0.7.0" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", version = "=0.7.2" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", version = "=0.3.0" }
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", version = "=0.1.0" }
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", version = "=0.1.0" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+2 -1
View File
@@ -194,7 +194,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`.
- Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`.
- Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character).
- Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`.
- Floating panels: drag a panel's tab, or a group by its drag bar, off the dock (or choose Float Panel) into a movable floating window that resizes from every edge; Dock Panel (in its tab menu and ≡ menu) puts it back where it was; positions persist; `window.panel.float` / `window.panel.dock` (aliases `window.floatPanel` / `window.dockPanel`).
- Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing.
- View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough).
- OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes.
@@ -381,6 +381,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-24** · Panels dock through the suite's shared docking (craft-libs), and on desktop a floating panel opens in its own window that can leave the main window: keys pressed there run the main window's shortcuts, clipboard and tool keys, and the canvas paints no tool cursor while another of the app's windows has the pointer. The web build keeps floating panels in the page. No readiness percentage change.
- **2026-10-24** · Story Options rejects malformed or nonexistent explicit story/frame IDs without changing the selected story, including an invalid frame alongside a valid story. Null targets remain absent; omitted targets still use the selection. No readiness percentage change.
- **2026-10-24** · Text Frame Options explains that a custom baseline-grid increment must be a measure greater than zero when zero, a negative value or a non-measure is entered. Validation is unchanged. No readiness percentage change.
- **2026-10-24** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change.
+53 -3
View File
@@ -147,15 +147,47 @@ fn prefs_path() -> Option<std::path::PathBuf> {
profile::config_dir().map(|b| b.join("ui.json"))
}
/// Set when `ui.json` could not be read in full and no copy of it could be kept: the file is
/// then left as it is rather than overwritten.
static KEEP_UI_PREFS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Copy an unreadable (or partly unreadable) `ui.json` beside it, as `ui.unreadable.json` or the
/// first free `ui.unreadable-N.json`, before the next save replaces it. A newer DesignCraft may
/// have written it.
fn keep_unreadable_prefs(path: &std::path::Path, bytes: &[u8]) {
use std::io::Write as _;
for n in 1..100u32 {
let name = if n == 1 { "ui.unreadable.json".to_string() } else { format!("ui.unreadable-{n}.json") };
match std::fs::OpenOptions::new().write(true).create_new(true).open(path.with_file_name(name)) {
Ok(mut file) => {
if file.write_all(bytes).is_ok() {
return;
}
break;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(_) => break,
}
}
KEEP_UI_PREFS.store(true, std::sync::atomic::Ordering::Relaxed);
}
fn load_prefs(app: &mut DesignApp) {
if no_prefs() {
return;
}
if let Some(p) = prefs_path()
&& let Ok(bytes) = std::fs::read(&p)
&& let Ok(ui) = designcraft_ui_egui::UiState::from_preferences_json(&bytes)
{
app.ui = ui;
match designcraft_ui_egui::UiState::read_preferences(&bytes) {
Ok(read) => {
app.ui = read.ui;
if !read.intact {
keep_unreadable_prefs(&p, &bytes);
}
}
Err(_) => keep_unreadable_prefs(&p, &bytes),
}
}
// Engine preferences (Preferences dialog, favourites…) live beside the UI state.
if let Some(p) = prefs_path().map(|p| p.with_file_name("prefs.json"))
@@ -172,7 +204,9 @@ fn save_prefs(app: &DesignApp) {
}
if let Some(p) = prefs_path() {
let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));
if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) {
if !KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed)
&& let Ok(bytes) = serde_json::to_vec_pretty(&app.ui)
{
let _ = std::fs::write(&p, bytes);
}
if let Ok(bytes) = serde_json::to_vec_pretty(&app.session.prefs) {
@@ -579,6 +613,22 @@ fn main() -> std::process::ExitCode {
#[cfg(test)]
mod tests {
#[test]
fn an_unreadable_ui_json_is_kept_beside_it() {
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("designcraft-ui-prefs-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let path = dir.join("ui.json");
super::keep_unreadable_prefs(&path, b"{\"docking\": {\"root\": {\"FutureLayout\": {}}}}");
super::keep_unreadable_prefs(&path, b"not json");
let first = std::fs::read(dir.join("ui.unreadable.json")).unwrap();
let second = std::fs::read(dir.join("ui.unreadable-2.json")).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(first.starts_with(b"{\"docking\""), "the first copy is not replaced");
assert_eq!(second, b"not json");
assert!(!super::KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed), "ui.json can be saved once a copy is kept");
}
#[test]
fn relink_open_dialog_lists_graphics() {
let filters = super::open_filters("relink");
+1
View File
@@ -463,6 +463,7 @@ fn file_info_metadata(doc: &Document, title: Option<&str>, author: Option<&str>)
description: (!description.is_empty()).then(|| description.to_string()),
keywords: info.keywords.clone(),
created: None,
modified: None,
}
}
+1
View File
@@ -497,6 +497,7 @@ fn paint_order(bytes: &[u8]) -> Vec<(String, f32)> {
GlyphDrawMode::Fill => ("fill text".into(), 0.0),
GlyphDrawMode::Stroke(p) => ("stroke text".into(), p.line_width),
GlyphDrawMode::Invisible => ("invisible text".into(), 0.0),
GlyphDrawMode::Clip => ("clip text".into(), 0.0),
});
}
fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {}
+2 -1
View File
@@ -105,7 +105,8 @@ impl<'a> Device<'a> for Bounds {
fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, paint: &Paint<'a>, mode: &GlyphDrawMode) {
let mode = match mode {
GlyphDrawMode::Invisible => return,
// Clipping text paints nothing; its clip arrives through `push_clip_path`.
GlyphDrawMode::Invisible | GlyphDrawMode::Clip => return,
GlyphDrawMode::Fill => PathDrawMode::Fill(FillRule::NonZero),
GlyphDrawMode::Stroke(s) => PathDrawMode::Stroke(s.clone()),
};
+1 -1
View File
@@ -10,7 +10,7 @@ description = "DesignCraft egui frontend (InDesign-style UI); swappable — noth
workspace = true
[dependencies]
craft-ui = { workspace = true }
craft-ui = { workspace = true, features = ["native-windows"] }
craft-window = { workspace = true }
designcraft-geom = { workspace = true }
designcraft-color = { workspace = true }
+82 -29
View File
@@ -685,9 +685,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
return;
}
let sel_rect = draw_selection(app, &painter, &xf, &doc, &layout);
// Where the pointer moved on to another of the app's windows, this window's last pointer
// position is stale: no hover feedback or tool cursor there.
let elsewhere = pointer_elsewhere(app, ui.ctx());
if !preview {
// Content grabbers: the selected frame's and the hovered one's (Selection tool).
let hover = ui.ctx().pointer_hover_pos().filter(|p| screen.contains(*p)).map(|p| xf.to_canvas(p));
let hover = ui.ctx().pointer_hover_pos().filter(|p| screen.contains(*p) && !elsewhere).map(|p| xf.to_canvas(p));
let vi = app.view_info();
for c in app.session.content_grabbers(hover, vi) {
let c = xf.to_screen(c);
@@ -709,7 +712,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
crate::taskbar::show(app, &ui.ctx().clone(), r, screen);
}
// Cursor.
if let Some(p) = resp.hover_pos().filter(|p| screen.contains(*p)) {
if let Some(p) = resp.hover_pos().filter(|p| screen.contains(*p) && !elsewhere) {
let wants_text = app.session.wants_text();
let space = navigation_space(app, ui.ctx());
let zoom = spring_zoom_mode(app, ui.ctx(), space);
@@ -750,6 +753,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
}
}
/// Whether the pointer this canvas's window last saw is in another of the app's windows: a
/// floating panel's OS window, or the other window of New Window.
fn pointer_elsewhere(app: &DesignApp, ctx: &egui::Context) -> bool {
let mut others = crate::panel_docking::native_viewports(app, ctx);
if app.second_window && !ctx.embed_viewports() {
others.extend([egui::ViewportId::ROOT, crate::second_window_viewport()]);
}
crate::cursors::pointer_elsewhere(ctx, &others)
}
/// The loaded text cursor's badge beside the system cursor: lines of text, a closed chain
/// (a click threads) or a broken one (a click unthreads). Drawn in code.
fn draw_cursor_badge(painter: &egui::Painter, p: Pos2, c: Cursor, t: &Tokens) {
@@ -2184,7 +2197,7 @@ fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf,
k += 1;
}
// Pointer position markers.
if let Some(p) = ui.ctx().pointer_hover_pos().filter(|p| rect.contains(*p)) {
if let Some(p) = ui.ctx().pointer_hover_pos().filter(|p| rect.contains(*p) && !pointer_elsewhere(app, ui.ctx())) {
let m = Stroke::new(1.0, t.text_dim);
dashed(&painter, pos2(p.x, top.min.y), pos2(p.x, top.max.y), m, 1.5, 1.5);
dashed(&painter, pos2(left.min.x, p.y), pos2(left.max.x, p.y), m, 1.5, 1.5);
@@ -2746,30 +2759,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
crate::menus::activate(app, id, &params);
}
}
egui::Event::Key { key, pressed: true, modifiers, .. } => {
let k = match key {
egui::Key::ArrowLeft => Some(ToolKey::Left),
egui::Key::ArrowRight => Some(ToolKey::Right),
egui::Key::ArrowUp => Some(ToolKey::Up),
egui::Key::ArrowDown => Some(ToolKey::Down),
egui::Key::Enter => Some(ToolKey::Enter),
egui::Key::Escape => Some(ToolKey::Escape),
egui::Key::Backspace => Some(ToolKey::Backspace),
egui::Key::Delete => Some(ToolKey::Delete),
egui::Key::Tab => Some(ToolKey::Tab),
egui::Key::Home => Some(ToolKey::Home),
egui::Key::End => Some(ToolKey::End),
_ => None,
};
if let Some(k) = k {
let m = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command, ctrl: modifiers.ctrl, space: false };
let handled = app.session.tool_key(k, m, vi).unwrap_or(false);
if !handled && k == ToolKey::Escape {
let _ = app.run("edit.deselectAll", json!({}));
}
app.after_engine();
}
}
egui::Event::Key { key, pressed: true, modifiers, .. } => tool_key(app, key, modifiers, vi),
_ => {}
}
}
@@ -2777,6 +2767,66 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
let _ = DVec2::ZERO;
}
/// A key for the active tool (caret movement, nudging, Backspace); Escape it leaves alone
/// deselects.
fn tool_key(app: &mut DesignApp, key: egui::Key, modifiers: egui::Modifiers, vi: designcraft_engine::ViewInfo) {
let k = match key {
egui::Key::ArrowLeft => ToolKey::Left,
egui::Key::ArrowRight => ToolKey::Right,
egui::Key::ArrowUp => ToolKey::Up,
egui::Key::ArrowDown => ToolKey::Down,
egui::Key::Enter => ToolKey::Enter,
egui::Key::Escape => ToolKey::Escape,
egui::Key::Backspace => ToolKey::Backspace,
egui::Key::Delete => ToolKey::Delete,
egui::Key::Tab => ToolKey::Tab,
egui::Key::Home => ToolKey::Home,
egui::Key::End => ToolKey::End,
_ => return,
};
let m = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command, ctrl: modifiers.ctrl, space: false };
let handled = app.session.tool_key(k, m, vi).unwrap_or(false);
if !handled && k == ToolKey::Escape {
let _ = app.run("edit.deselectAll", json!({}));
}
app.after_engine();
}
/// The keys a floating panel's own OS window left over once the shortcuts took theirs: the
/// clipboard and the active tool's keys, as the canvas handles them while no text is edited.
/// `modifiers` are those held in that window; egui-winit sends ⌘C, ⌘X and ⌘V as `Copy`, `Cut`
/// and `Paste` without their key press.
pub(crate) fn forwarded_keys(app: &mut DesignApp, events: Vec<egui::Event>, modifiers: egui::Modifiers) {
if !keyboard_pane(app) || modal_open(app) || app.session.wants_text() || app.session.tool_composing() {
return;
}
let vi = app.view_info();
for e in events {
#[cfg(not(target_arch = "wasm32"))]
let chord = if matches!(e, egui::Event::Paste(_)) { crate::menus::paste_chord_command(app, modifiers) } else { None };
// The web keeps floating panels in the page, so nothing is forwarded there.
#[cfg(target_arch = "wasm32")]
let chord: Option<&str> = {
let _ = modifiers;
None
};
let (id, params) = match (e, chord) {
(egui::Event::Paste(_), Some(id)) => (id, serde_json::Value::Null),
(egui::Event::Copy, _) if !app.native_menu => ("edit.copy", serde_json::Value::Null),
(egui::Event::Cut, _) if !app.native_menu => ("edit.cut", serde_json::Value::Null),
(egui::Event::Paste(t), _) if !app.native_menu => ("edit.paste", json!({"text": t})),
(egui::Event::Key { key, pressed: true, modifiers, .. }, _) => {
tool_key(app, key, modifiers, vi);
continue;
}
_ => continue,
};
if crate::menus::enabled(app, id) {
crate::menus::activate(app, id, &params);
}
}
}
/// A dialog or Quick Apply has the keyboard. The canvas keeps egui's focus behind a modal, and it
/// runs before the modal draws, so it must not also act on the Esc or Return that closes it.
fn modal_open(app: &DesignApp) -> bool {
@@ -5796,16 +5846,19 @@ mod tests {
let out = &h.output().platform_output;
(out.cursor_icon, out.cursor_image.clone())
};
let (pen, direct, text) = (shown("pen"), shown("directSelection"), shown("type"));
let (pen, direct, text, selection) = (shown("pen"), shown("directSelection"), shown("type"), shown("selection"));
let ppp = h.ctx.pixels_per_point();
let images = &mut h.state_mut().cursors;
match crate::cursors::mode() {
crate::cursors::Mode::Os => {
assert_eq!(pen.1, images.get(Cursor::Pen, ppp), "Pen");
assert_eq!(direct.1, images.get(Cursor::ArrowHollow, ppp), "Direct Selection");
assert_eq!(selection.1, images.get(Cursor::Arrow, ppp), "Selection");
assert_eq!(text, (egui::CursorIcon::Text, None), "the I-beam stays a system cursor");
}
crate::cursors::Mode::Painted => assert_eq!((pen.0, direct.0), (egui::CursorIcon::None, egui::CursorIcon::None)),
crate::cursors::Mode::Painted => {
assert_eq!((pen.0, direct.0, selection.0), (egui::CursorIcon::None, egui::CursorIcon::None, egui::CursorIcon::None));
}
crate::cursors::Mode::System => assert_eq!(pen, (egui::CursorIcon::Crosshair, None)),
}
}
+13 -11
View File
@@ -1102,10 +1102,9 @@ pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) {
}
}
/// Show the Preflight panel. `window.panel` toggles a panel that is already open, so it runs
/// only when Preflight isn't showing.
/// Show the Preflight panel. An open Preflight flyout stays: `window.panel` would close it.
fn show_preflight(app: &mut DesignApp) {
if app.ui.open_panel.as_deref() != Some("preflight")
if app.ui.docking_flyout.as_deref() != Some("preflight")
&& let Err(e) = app.run("window.panel", json!({"panel": "preflight"}))
{
app.status(e);
@@ -1653,15 +1652,15 @@ mod tests {
h.run_steps(3);
h.get_by_label("Preflight Panel…").click();
h.run_steps(3);
// Preflight is not a dock tab: `window.panel` shows it as its own panel.
assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight"));
h.get_by_label("Preflight");
// Preflight is in the iconic column: `window.panel` shows it.
assert!(crate::panel_docking::showing(&h.state().app, "preflight"));
assert!(h.query_all_by_label("Preflight").count() >= 2, "its icon and its tab in the flyout");
// With the panel already showing, the entry keeps it open.
h.get_by_label("No errors ▾").click();
h.run_steps(3);
h.get_by_label("Preflight Panel…").click();
h.run_steps(3);
assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight"));
assert!(crate::panel_docking::showing(&h.state().app, "preflight"));
}
/// As in InDesign, double-clicking the preflight count (or its light) shows the panel.
@@ -1675,15 +1674,18 @@ mod tests {
count.click();
count.click();
h.run_steps(3);
assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight"));
h.get_by_label("Preflight");
assert!(crate::panel_docking::showing(&h.state().app, "preflight"));
assert!(h.query_all_by_label("Preflight").count() >= 2, "its icon and its tab in the flyout");
assert!(h.query_by_label("Preflight Panel…").is_none(), "the menu isn't left open");
// A double-click with the panel already showing keeps it open.
// A double-click with the panel already showing keeps it open (the first click closes
// its flyout, the double-click opens it again). Past the double-click time, so the
// clicks are not counted with the ones before.
h.run_steps(30);
let count = h.get_by_label("No errors ▾");
count.click();
count.click();
h.run_steps(3);
assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight"));
assert!(crate::panel_docking::showing(&h.state().app, "preflight"));
}
/// A very long document name made its tab grow past the window, hiding the close button
+4 -1
View File
@@ -4,6 +4,7 @@
//! - `engine.execute {command, params}` / `ui.menu.invoke {command|id, params}`: run any command
//! - `engine.commands`: engine + UI commands with enablement
//! - `document.inspect`: document summary; `ui.inspect`: UI state
//! - `ui.docking`: the panel layout in use and the focused panel
//! - `ui.menu.list`: flattened menu tree
//! - `ui.tool.select {tool}`, `ui.tool.list`
//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen"}], mods?}`:
@@ -77,6 +78,7 @@ pub fn inspect(app: &DesignApp, ctx: &egui::Context) -> Value {
"ui": serde_json::to_value(&app.ui).unwrap_or_default(),
"view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()),
"canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
"docking": crate::panel_docking::inspect(app, ctx),
"window": [r.width(), r.height()],
"documents": app
.session
@@ -169,6 +171,7 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest
"engine.commands" => ok(all_commands(app)),
"document.inspect" => wrap(app.run("document.inspect", json!({}))),
"ui.inspect" => ok(inspect(app, ctx)),
"ui.docking" => ok(crate::panel_docking::inspect(app, ctx)),
"ui.menu.list" => ok(serde_json::to_value(menus::MENUS).unwrap_or_default()),
"ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))),
"ui.tool.list" => ok(serde_json::to_value(designcraft_tools::TOOL_GROUPS).unwrap_or_default()),
@@ -361,7 +364,7 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest
r = crate::panel_docking::close_visible(app).map(|_| Value::Null);
}
if let Some(v) = p.get("dockExpanded").and_then(Value::as_bool) {
app.ui.dock_expanded = v;
r = crate::panel_docking::set_iconic(app, !v);
}
wrap(r)
}
+73
View File
@@ -7,6 +7,11 @@
//! so they never trail the pointer. macOS sizes a cursor bitmap in points (a Retina one would show
//! twice as big, or blurred) and a Wayland session (native or through XWayland) scales it by its own
//! rules, so there the canvas paints the glyph at the pointer. The web build keeps the system cursors.
//!
//! A painted glyph needs a live pointer position. macOS sends a window no pointer-left event when
//! the pointer moves onto another of the app's windows lying over it (a panel floating in its own
//! OS window), so the window's egui pointer stays where it was last seen; [`pointer_elsewhere`]
//! tells the canvas to paint nothing there.
use egui::epaint::{Mesh, TessellationOptions, Tessellator, Vertex};
use egui::{Color32, CustomCursorImage, Pos2, Shape, Stroke, Vec2, pos2, vec2};
@@ -338,10 +343,78 @@ pub fn show(ctx: &egui::Context, images: &mut Images, c: Cursor, hover: Pos2) ->
}
}
/// Whether the pointer this viewport last saw has moved on to another of the app's windows,
/// `others` (the current viewport among them is skipped): from the frame one of them reports the
/// pointer until this viewport gets a pointer event again. That covers the pointer moving on from
/// the other window to the desktop too, where this one still hears nothing. Always false where
/// egui embeds viewports, so all of them share one window's pointer.
pub(crate) fn pointer_elsewhere(ctx: &egui::Context, others: &[egui::ViewportId]) -> bool {
let own = ctx.viewport_id();
let key = egui::Id::unique(("pointer-elsewhere", own));
let before = ctx.data(|d| d.get_temp::<bool>(key)).unwrap_or(false);
let here = ctx
.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::PointerGone)));
// A pointer that arrived: moving or pressing there. A pointer that only stayed (a hidden
// window keeps its last one) does not count, so it never takes the pointer from this window.
let arrived = |i: &egui::InputState| {
i.pointer.has_pointer() && i.events.iter().any(|e| matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }))
};
let separate = !ctx.embed_viewports() && others.iter().any(|&v| v != own);
// Where this window has pointer events of its own too, another window's report counts only
// if this window's pointer lies inside that window: one just outside it (its edge, where
// both may hear the pointer) keeps belonging here, frame after frame.
let at = ctx.input(|i| i.viewport().inner_rect.zip(i.pointer.latest_pos()).map(|(r, p)| r.min + p.to_vec2()));
let there = separate && others.iter().any(|&v| v != own && ctx.input_for(v, |i| arrived(i) && (!here || covers(i.viewport().inner_rect, at))));
let now = separate && elsewhere(before, here, there);
if now != before {
ctx.data_mut(|d| d.insert_temp(key, now));
}
now
}
/// Whether a window at `rect` (desktop points) lies over the desktop point `at`; unknown when
/// either is, which counts as covering.
fn covers(rect: Option<egui::Rect>, at: Option<egui::Pos2>) -> bool {
rect.zip(at).is_none_or(|(r, p)| r.contains(p))
}
/// [`pointer_elsewhere`] for one frame: whether the pointer belonged to another window `before`,
/// whether this window got a pointer event (`here`) and whether another window reports the
/// pointer arriving (`there`). Both in one frame mean the pointer just crossed into the other
/// window: this window's last events come from before the crossing.
fn elsewhere(before: bool, here: bool, there: bool) -> bool {
there || (before && !here)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_pointer_belongs_elsewhere_from_another_windows_report_until_this_one_hears_it_again() {
// Main window, then a floating panel's window over it, then the desktop, then back.
let mut state = false;
let mut frame = |here, there| {
state = elsewhere(state, here, there);
state
};
assert!(!frame(true, false), "over the main window");
assert!(frame(true, true), "crossing into the floating window");
assert!(frame(false, true), "moving in the floating window");
assert!(frame(false, false), "on the desktop: the main window still heard nothing");
assert!(frame(false, false), "the next frame too");
assert!(!frame(true, false), "back over the main window");
assert!(!frame(false, false), "and resting there");
}
#[test]
fn a_pointer_just_outside_another_window_stays_this_windows() {
let float = Some(egui::Rect::from_min_size(egui::pos2(100.0, 100.0), egui::vec2(200.0, 300.0)));
assert!(covers(float, Some(egui::pos2(150.0, 150.0))), "over the floating window");
assert!(!covers(float, Some(egui::pos2(98.0, 150.0))), "beside its left edge");
assert!(covers(None, Some(egui::pos2(98.0, 150.0))) && covers(float, None), "unknown places count as covered");
}
/// Every cursor with a glyph.
const GLYPHS: [Cursor; 9] = [
Cursor::Arrow,
+23 -321
View File
@@ -1,16 +1,16 @@
//! The right dock: an expanded panel group (Properties · Pages · Layers) and a collapsed icon
//! column whose panels open as flyouts beside the dock.
//! The panels: their ids, names, icons and contents, and the windows of the float-only panels.
//! `panel_docking` places the others in the docking layout.
use egui::{Color32, Sense, Stroke, vec2};
use egui::{Sense, vec2};
use crate::theme::{Tokens, semibold};
use crate::{DesignApp, icons, panels};
use crate::{DesignApp, panels};
/// (id, label, icon) of the tabs in the expanded group.
/// (id, label, icon) of the Properties · Pages · Layers group of the default dock.
pub const DOCK_TABS: &[(&str, &str, &str)] =
&[("properties", "Properties", "panel-properties"), ("pages", "Pages", "panel-pages"), ("layers", "Layers", "panel-layers")];
/// Panels in the collapsed icon column.
/// (id, label, icon) of the other dock panels, which start in the iconic column.
pub const ICON_PANELS: &[(&str, &str, &str)] = &[
("stroke", "Stroke", "panel-stroke"),
("swatches", "Swatches", "panel-swatches"),
@@ -51,7 +51,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
("dataMerge", "Data Merge", "panel-datamerge"),
];
/// Panels that only float (they open from a menu, not from the icon column).
/// Panels that only float, outside the docking layout (they open from a menu).
pub const FLOATING_PANELS: &[(&str, &str)] = &[("tabs", "Tabs")];
/// The English name of panel `id`.
@@ -59,202 +59,6 @@ pub fn panel_label(id: &str) -> &'static str {
DOCK_TABS.iter().chain(ICON_PANELS).map(|p| (p.0, p.1)).chain(FLOATING_PANELS.iter().copied()).find(|p| p.0 == id).map_or("Panel", |p| p.1)
}
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
if crate::panel_docking::show(app, ui) {
return;
}
let t = Tokens::get(ui.ctx());
// Expanded panel stack (outermost, 253 pt) — declared first so it sits at the far right.
if app.ui.dock_expanded {
egui::Panel::right("dock").default_size(253.0).size_range(220.0..=420.0).resizable(true).frame(egui::Frame::NONE.fill(t.panel)).show(
ui,
|ui| {
if dock_header(ui, &t, "»").clicked() {
let _ = app.run("window.collapseToIcons", serde_json::json!({}));
}
// Tab strip (27 pt).
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 27.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
let mut x = strip.min.x;
for (id, label, _) in DOCK_TABS {
let active = app.ui.dock_tab == *id;
let g = crate::rtl::plain(
ui.ctx(),
crate::i18n::tr(&app.ui.language, label),
semibold(11.5),
if active { t.text_strong } else { t.text_dim },
);
let w = g.size().x + 26.0;
let r = egui::Rect::from_min_size(egui::pos2(x, strip.min.y), vec2(w, 27.0));
if active {
ui.painter().rect_filled(r, 0.0, t.panel);
}
ui.painter().galley(egui::pos2(r.min.x + 13.0, r.center().y - g.size().y / 2.0), g, Color32::WHITE);
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
let response = craft_ui::tabs::Tab::new(ui.scope_id().with(("docktab", *id)), label, active)
.sense(Sense::click_and_drag())
.show_at(ui, r, |_, _| {});
if response.clicked() {
app.ui.dock_tab = id.to_string();
}
crate::panel_docking::legacy_tab(app, ui, id, &response);
x += w;
}
let menu_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 24.0, strip.min.y + 4.0), vec2(18.0, 18.0));
let menu = ui.interact(menu_r, ui.scope_id().with("dock-menu"), Sense::click());
if menu.hovered() {
ui.painter().rect_filled(menu_r, 3.0, t.hover);
}
icons::paint(ui.painter(), menu_r, "menu", t.icon);
let tab = app.ui.dock_tab.clone();
egui::Popup::menu(&menu)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| crate::panel_docking::three_line_menu(app, ui, &tab));
egui::Frame::NONE.inner_margin(egui::Margin { left: 10, right: 10, top: 4, bottom: 6 }).show(ui, |ui| {
// The Pages panel scrolls its parts itself and keeps its footer at the bottom.
if app.ui.dock_tab == "pages" {
panels::pages::show(app, ui);
return;
}
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match app.ui.dock_tab.as_str() {
"layers" => panels::layers::show(app, ui),
_ => panels::properties::show(app, ui),
});
});
},
);
}
// Collapsed icon strip (37 pt) to the left of the panel stack; a workspace's iconic groups
// (Essentials Classic) show their names.
let groups = app.ui.icon_groups.clone();
egui::Panel::right("icon_column").exact_size(icon_column_width(app)).resizable(false).frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| {
if dock_header(ui, &t, "«").clicked() {
app.ui.dock_expanded = true;
}
ui.add_space(4.0);
match &groups {
Some(groups) => {
for (i, group) in groups.iter().enumerate() {
if i > 0 {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover());
ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.border));
}
for (id, label, icon) in group.iter().filter_map(|id| panel_entry(id)) {
let on = app.ui.open_panel.as_deref() == Some(id) || app.ui.floating.iter().any(|(p, _)| p == id);
let (r, response) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click_and_drag());
if on {
ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.tool_active);
} else if response.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.hover);
}
icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(8.0, 4.0), vec2(18.0, 18.0)), icon, t.icon);
crate::rtl::paint(
ui.painter(),
r.left_center() + vec2(32.0, 0.0),
egui::Align2::LEFT_CENTER,
crate::i18n::tr(&app.ui.language, label),
semibold(11.5),
t.text,
);
icon_clicked(app, ui, id, &response);
}
}
}
None => {
ui.vertical_centered(|ui| {
for (id, label, icon) in ICON_PANELS {
let on = app.ui.open_panel.as_deref() == Some(*id) || app.ui.floating.iter().any(|(p, _)| p == id);
let response = icons::button(ui, icon, 28.0, on, crate::i18n::tr(&app.ui.language, label)).interact(Sense::click_and_drag());
icon_clicked(app, ui, id, &response);
ui.add_space(1.0);
}
});
}
}
});
}
/// Width of the icon column: icons only, or icons with names for a workspace's groups.
fn icon_column_width(app: &DesignApp) -> f32 {
if app.ui.icon_groups.is_some() { 150.0 } else { 37.0 }
}
/// (id, label, icon) of any dock panel.
fn panel_entry(id: &str) -> Option<(&'static str, &'static str, &'static str)> {
DOCK_TABS.iter().chain(ICON_PANELS).find(|p| p.0 == id).copied()
}
/// A click on a panel's icon: raise it when it floats, else open or close its flyout.
fn icon_clicked(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, response: &egui::Response) {
if response.clicked() {
if app.ui.floating.iter().any(|(p, _)| p == id) {
ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::unique(("floating_panel", id))));
} else {
let open = app.ui.open_panel.as_deref() == Some(id);
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
}
}
crate::panel_docking::legacy_tab(app, ui, id, response);
}
/// 11 pt dock header strip with a collapse chevron; clicking it reports a click on the response.
fn dock_header(ui: &mut egui::Ui, t: &Tokens, chevron: &str) -> egui::Response {
let (r, response) = ui.allocate_exact_size(vec2(ui.available_width(), 11.0), Sense::click());
ui.painter().rect_filled(r, 0.0, t.tab_strip);
ui.painter().text(egui::pos2(r.max.x - 8.0, r.center().y), egui::Align2::RIGHT_CENTER, chevron, egui::FontId::proportional(9.0), t.text_dim);
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border));
response
}
/// A panel opened from the icon column, shown as a floating flyout next to the dock.
pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) {
if app.ui.docking.is_some() {
return;
}
let Some(id) = app.ui.open_panel.clone() else { return };
let t = Tokens::get(ctx);
let label = crate::i18n::tr(&app.ui.language, panel_label(&id)).to_owned();
let screen = ctx.content_rect();
let dock_w = if app.ui.dock_expanded { ctx.memory(|m| m.area_rect(egui::Id::unique("dock")).map(|r| r.width())).unwrap_or(280.0) } else { 0.0 };
let pos = egui::pos2(screen.max.x - icon_column_width(app) - dock_w - 262.0, 120.0);
let mut open = true;
egui::Area::new(egui::Id::unique("panel_flyout")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(0)).show(ui, |ui| {
ui.set_width(256.0);
// A previous shorter flyout must not constrain the next panel's scroll viewport.
ui.set_max_height((screen.max.y - pos.y - 12.0).max(80.0));
let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.panel_darker);
crate::rtl::paint(ui.painter(), strip.min + vec2(10.0, 13.0), egui::Align2::LEFT_CENTER, &label, semibold(12.0), t.text_strong);
let close = egui::Rect::from_min_size(egui::pos2(strip.max.x - 22.0, strip.min.y + 4.0), vec2(18.0, 18.0));
if ui.interact(close, ui.scope_id().with("flyclose"), Sense::click()).clicked() {
open = false;
}
ui.painter().text(close.center(), egui::Align2::CENTER_CENTER, "×", egui::FontId::proportional(15.0), t.text_dim);
// Float: the button beside the close box, or drag the header away from the dock.
let float_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 44.0, strip.min.y + 4.0), vec2(18.0, 18.0));
let fr = ui.interact(float_r, ui.scope_id().with("flyfloat"), Sense::click()).on_hover_ui(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Float panel"));
});
float_glyph(ui.painter(), float_r, t.text_dim);
let head = egui::Rect::from_min_max(strip.min, egui::pos2(float_r.min.x - 2.0, strip.max.y));
let hr = ui.interact(head, ui.scope_id().with("flyhead"), Sense::drag());
let torn = hr.drag_stopped() && hr.total_drag_delta().is_some_and(|d| d.length() > 24.0);
if fr.clicked() || torn {
let at = if torn { ui.ctx().pointer_latest_pos().unwrap_or(pos) - vec2(20.0, 10.0) } else { pos - vec2(30.0, -30.0) };
float_panel(app, &id, at);
return;
}
egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| {
scrolled_body(app, ui, &id, screen.height() - 220.0);
});
});
});
if !open {
app.ui.open_panel = None;
}
}
/// A panel's contents at most `max_h` tall, scrolling past that. The Pages panel scrolls its
/// parts itself and keeps its footer at the bottom, so it gets the height instead.
fn scrolled_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, max_h: f32) {
@@ -322,42 +126,16 @@ pub(crate) fn float_only(id: &str) -> bool {
FLOATING_PANELS.iter().any(|p| p.0 == id)
}
/// Tear panel `id` off the dock into a floating window at `at`.
pub fn float_panel(app: &mut DesignApp, id: &str, at: egui::Pos2) {
if float_only(id) {
app.ui.floating.retain(|(p, _)| p != id);
app.ui.floating.push((id.to_string(), [at.x, at.y]));
return;
}
if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.float", &serde_json::json!({"panel":id,"x":at.x,"y":at.y})) {
app.status(error);
}
}
/// Put a floating panel back in the dock's icon column.
pub fn dock_panel(app: &mut DesignApp, id: &str) {
if app.ui.docking.is_some() && !float_only(id) {
if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.dock", &serde_json::json!({"panel":id})) {
app.status(error);
}
return;
}
app.ui.floating.retain(|(p, _)| p != id);
}
/// Floating panels: movable, resizable windows; "Dock" returns one to the icon column.
pub fn floating(app: &mut DesignApp, ctx: &egui::Context) {
// A customized layout draws its own floating groups; float-only panels are drawn here.
let customized = app.ui.docking.is_some();
/// The float-only panels' windows (the Tabs panel). The docking layout draws the other
/// floating panels.
pub fn float_only_windows(app: &mut DesignApp, ctx: &egui::Context) {
let t = Tokens::get(ctx);
for (id, at) in app.ui.floating.clone() {
if customized && !float_only(&id) {
if !float_only(&id) {
continue;
}
let label = crate::i18n::tr(&app.ui.language, panel_label(&id)).to_owned();
let docks = !float_only(&id);
let mut open = true;
let mut dock = false;
let mut w = egui::Window::new(label.clone())
.id(egui::Id::unique(("floating_panel", &id)))
.title_bar(false)
@@ -372,7 +150,7 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) {
w = w.current_pos(pos).min_width(width).max_width(width);
}
let r = w.show(ctx, |ui| {
// Header strip (drag it to move the panel): name, Dock, close.
// Header strip (drag it to move the panel): name and close.
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width().max(200.0), 24.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.panel_darker);
crate::rtl::paint(ui.painter(), strip.min + vec2(10.0, 12.0), egui::Align2::LEFT_CENTER, &label, semibold(12.0), t.text_strong);
@@ -387,20 +165,6 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) {
open = false;
}
ui.painter().text(close.center(), egui::Align2::CENTER_CENTER, "×", egui::FontId::proportional(15.0), t.text_dim);
let dock_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 44.0, strip.min.y + 3.0), vec2(18.0, 18.0));
if docks
&& ui
.interact(dock_r, ui.scope_id().with("fdock"), Sense::click())
.on_hover_ui(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Dock panel"));
})
.clicked()
{
dock = true;
}
if docks {
dock_glyph(ui.painter(), dock_r, t.text_dim);
}
egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| {
scrolled_body(app, ui, &id, ctx.content_rect().height() - 160.0);
});
@@ -411,29 +175,16 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) {
{
f.1 = [r.response.rect.min.x, r.response.rect.min.y];
}
if !open || dock {
dock_panel(app, &id);
if !open {
app.ui.floating.retain(|(p, _)| *p != id);
}
}
}
/// Two overlapping boxes: "float this panel".
fn float_glyph(p: &egui::Painter, r: egui::Rect, c: Color32) {
let s = Stroke::new(1.0, c);
p.rect_stroke(egui::Rect::from_min_size(r.min + vec2(3.5, 6.5), vec2(9.0, 8.0)), 0.0, s, egui::StrokeKind::Middle);
p.rect_stroke(egui::Rect::from_min_size(r.min + vec2(6.5, 3.5), vec2(9.0, 8.0)), 0.0, s, egui::StrokeKind::Middle);
}
/// A box with a bar on its right: "back into the dock".
fn dock_glyph(p: &egui::Painter, r: egui::Rect, c: Color32) {
let b = egui::Rect::from_min_size(r.min + vec2(3.5, 4.5), vec2(11.0, 9.0));
p.rect_stroke(b, 0.0, Stroke::new(1.0, c), egui::StrokeKind::Middle);
p.rect_filled(egui::Rect::from_min_max(egui::pos2(b.max.x - 3.5, b.min.y), b.max), 0.0, c);
}
#[cfg(test)]
mod visual_regressions {
use super::*;
use egui::Color32;
fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput {
let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() };
@@ -449,9 +200,7 @@ mod visual_regressions {
for brightness in crate::theme::Brightness::ALL {
for width in [800.0, 1280.0] {
for scale in [1.0, 1.5, 2.0] {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.ui.dock_expanded = true;
app.ui.dock_tab = "properties".into();
let app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
let mut ready = false;
let mut harness =
egui_kittest::Harness::builder().with_size(vec2(width, 640.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
@@ -462,7 +211,7 @@ mod visual_regressions {
ready = true;
return;
}
show(app, ui);
crate::panel_docking::show(app, ui);
},
app,
);
@@ -470,10 +219,11 @@ mod visual_regressions {
harness.run_steps(4);
let stem = format!("designcraft-dock-{}-{width}-{scale}x", brightness.id());
harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap();
let panel = egui::containers::panel::PanelState::load(&harness.ctx, harness.ui_id().with("dock")).unwrap().outer_rect;
let tab = crate::panel_docking::tab_id("properties");
let tab = harness.ctx.read_response(tab).unwrap().rect;
let region = serde_json::json!({
"screen": [width, 640.0], "scale": scale,
"regions": {"dock-tabs": [panel.left(), panel.top(), panel.width(), 45.0]}
"regions": {"dock-tabs": [tab.left(), tab.top(), (width - tab.left()), tab.height()]}
});
std::fs::write(directory.join(format!("{stem}.json")), serde_json::to_vec_pretty(&region).unwrap()).unwrap();
}
@@ -499,9 +249,9 @@ mod visual_regressions {
let tokens = Tokens::for_brightness(brightness);
crate::theme::apply(&ctx, &tokens);
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.ui.dock_expanded = true;
app.ui.dock_tab = "properties".into();
let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| show(&mut app, ui));
let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| crate::panel_docking::show(&mut app, ui));
output.textures_delta.clear();
let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| crate::panel_docking::show(&mut app, ui));
output.textures_delta.clear();
assert_eq!(ctx.content_rect().size(), vec2(width, 640.0));
assert_eq!(ctx.pixels_per_point(), scale);
@@ -523,51 +273,3 @@ mod visual_regressions {
}
}
}
#[cfg(test)]
mod chevron_tests {
use super::*;
use egui_kittest::Harness;
fn click(harness: &mut Harness<'_, DesignApp>, at: egui::Pos2) {
harness.hover_at(at);
harness.run_steps(1);
harness.drag_at(at);
harness.run_steps(1);
harness.drop_at(at);
harness.run_steps(3);
}
/// The » / « chevron painted at the right end of a panel's 11 pt header.
fn chevron(harness: &Harness<'_, DesignApp>, panel: &str) -> egui::Pos2 {
// A panel is keyed by the parent Ui the app draws in (see `test_window::panel_rect`).
let r = egui::containers::panel::PanelState::load(&harness.ctx, harness.ui_id().with(panel)).expect("the panel is laid out").outer_rect;
egui::pos2(r.max.x - 10.0, r.min.y + 5.0)
}
/// Issue #1203: the header chevrons were painted text; clicking them did nothing.
#[test]
fn the_dock_chevron_collapses_and_expands_the_dock() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.ui.dock_expanded = true;
let mut ready = false;
let mut harness = Harness::builder().with_size(vec2(1280.0, 640.0)).build_ui_state(
move |ui, app: &mut DesignApp| {
if !ready {
crate::theme::install_fonts(ui.ctx(), "en");
ready = true;
return;
}
show(app, ui);
},
app,
);
harness.run_steps(4);
let at = chevron(&harness, "dock");
click(&mut harness, at);
assert!(!harness.state().ui.dock_expanded, "» collapses the dock to icons");
let at = chevron(&harness, "icon_column");
click(&mut harness, at);
assert!(harness.state().ui.dock_expanded, "« expands the dock again");
}
}
+93 -4
View File
@@ -210,6 +210,96 @@ const TABLE: &[(&str, [&str; 5])] = &[
),
("Minimize", ["Minimieren", "Réduire", "Minimizar", "最小化", "最小化"]),
("Collapse to Icons", ["Auf Symbole minimieren", "Réduire en icônes", "Contraer a iconos", "アイコンパネルに折りたたむ", "折叠为图标"]),
// Docking chrome (craft-ui labels in DesignCraft's wording, `panel_docking::DOCK_LABELS`).
("Group with", ["Gruppieren mit", "Grouper avec", "Agrupar con", "次とグループ化", "编组到"]),
("Panels", ["Bedienfelder", "Panneaux", "Paneles", "パネル", "面板"]),
(
"Resize panels",
["Größe der Bedienfelder ändern", "Redimensionner les panneaux", "Cambiar tamaño de los paneles", "パネルのサイズを変更", "调整面板大小"],
),
(
"Resize panel window",
[
"Größe des Bedienfeldfensters ändern",
"Redimensionner la fenêtre de panneau",
"Cambiar tamaño de la ventana de panel",
"パネルウィンドウのサイズを変更",
"调整面板窗口大小",
],
),
(
"Move tab group",
["Registerkartengruppe verschieben", "Déplacer le groupe d'onglets", "Mover grupo de fichas", "タブグループを移動", "移动选项卡组"],
),
("Expand", ["Erweitern", "Développer", "Expandir", "展開", "展开"]),
(
"Float Tab Group",
[
"Registerkartengruppe lösen",
"Détacher le groupe d'onglets",
"Hacer flotante el grupo de fichas",
"タブグループをフローティング表示",
"浮动选项卡组",
],
),
(
"Group Tab Group with",
[
"Registerkartengruppe gruppieren mit",
"Grouper le groupe d'onglets avec",
"Agrupar grupo de fichas con",
"タブグループを次とグループ化",
"将选项卡组编组到",
],
),
(
"Move panel column",
["Bedienfeldspalte verschieben", "Déplacer la colonne de panneaux", "Mover columna de paneles", "パネル列を移動", "移动面板列"],
),
("Expand Panels", ["Bedienfelder erweitern", "Développer les panneaux", "Expandir paneles", "パネルを展開", "展开面板"]),
(
"Move panel window",
["Bedienfeldfenster verschieben", "Déplacer la fenêtre de panneau", "Mover ventana de panel", "パネルウィンドウを移動", "移动面板窗口"],
),
(
"Close panel window",
["Bedienfeldfenster schließen", "Fermer la fenêtre de panneau", "Cerrar ventana de panel", "パネルウィンドウを閉じる", "关闭面板窗口"],
),
(
"Close Other Panels in Group",
[
"Andere Bedienfelder der Gruppe schließen",
"Fermer les autres panneaux du groupe",
"Cerrar los demás paneles del grupo",
"グループ内の他のパネルを閉じる",
"关闭组中的其他面板",
],
),
("Maximize Frame", ["Bereich maximieren", "Agrandir la zone", "Maximizar área", "領域を最大化", "最大化区域"]),
(
"Restore Frame Size",
["Bereichsgröße wiederherstellen", "Restaurer la taille de la zone", "Restaurar tamaño del área", "領域のサイズを元に戻す", "恢复区域大小"],
),
(
"Move Panel to New Window",
[
"Bedienfeld in neues Fenster verschieben",
"Déplacer le panneau dans une nouvelle fenêtre",
"Mover panel a una ventana nueva",
"パネルを新規ウィンドウに移動",
"将面板移到新窗口",
],
),
(
"Auto-Collapse Iconic Panels",
[
"Symbolbedienfelder automatisch minimieren",
"Réduire automatiquement les panneaux d’icônes",
"Contraer automáticamente los paneles de iconos",
"アイコンパネルを自動的に折りたたむ",
"自动折叠图标面板",
],
),
("Panel Options", ["Bedienfeldoptionen", "Options du panneau", "Opciones de panel", "パネルオプション", "面板选项"]),
("Panel Options…", ["Bedienfeldoptionen…", "Options du panneau…", "Opciones de panel…", "パネルオプション…", "面板选项…"]),
("Show Thumbnails", ["Miniaturen anzeigen", "Afficher les vignettes", "Mostrar miniaturas", "サムネールを表示", "显示缩览图"]),
@@ -1962,7 +2052,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
],
),
("Flattener Preview", ["Flattener Preview", "Aperçu de l'aplatissement", "Flattener Preview", "Flattener Preview", "拼合器预览"]),
("Float Panel", ["Float Panel", "Détacher le panneau", "Float Panel", "Float Panel", "浮动面板"]),
("Float Panel", ["Bedienfeld lösen", "Détacher le panneau", "Hacer flotante el panel", "パネルをフローティング表示", "浮动面板"]),
("Fonts in Document", ["Fonts in Document", "Polices du document", "Fonts in Document", "Fonts in Document", "文档中的字体"]),
("Footnotes", ["Footnotes", "Notes de bas de page", "Footnotes", "Footnotes", "脚注"]),
("Form Fields", ["Form Fields", "Champs de formulaire", "Form Fields", "Form Fields", "表单域"]),
@@ -5060,6 +5150,8 @@ mod tests {
keys.extend(crate::about::SIBLINGS.iter().map(|(_, _, what)| *what));
keys.extend(crate::credits::NameMode::ALL.iter().map(|mode| mode.label()));
keys.extend(crate::credits::SortKey::ALL.iter().flat_map(|key| [key.label().0, key.label().1]));
// Docking chrome labels reach `tr` through craft-ui's localizer.
keys.extend(crate::panel_docking::DOCK_LABELS.iter().map(|(_, english)| *english));
// UI labels passed to `tr` that no other catalog has yet.
keys.extend([
"(any)",
@@ -5075,14 +5167,11 @@ mod tests {
"Email Address:",
"Email:",
"Find fill:",
"Group with",
"H:",
"Message:",
"Organization:",
"Phone:",
"W:",
"Resize panel window",
"Resize panels",
"Subject:",
"Text:",
"Unsaved Changes",
-4
View File
@@ -151,7 +151,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Gradient Feather Tool", "Outil Contour progressif dégradé"),
("Gradient Swatch Tool", "Outil Dégradé de nuance"),
("Graphic", "Graphique"),
("Group with", "Grouper avec"),
("Hair Space", "Espace ultrafine"),
("Hand Tool", "Outil Main"),
("Hide Guides", "Masquer les repères"),
@@ -258,7 +257,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Page Break", "Saut de page"),
("Page Options", "Options de page"),
("Page Tool", "Outil Page"),
("Panels", "Panneaux"),
("Paragraph Return", "Retour de paragraphe"),
("Paragraph Style", "Style de paragraphe"),
("Paragraph Symbol", "Symbole de paragraphe"),
@@ -303,8 +301,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Reset Bar Position", "Réinitialiser la position de la barre"),
("Resize Row or Column", "Redimensionner la ligne ou la colonne"),
("Resize Window", "Redimensionner la fenêtre"),
("Resize panel window", "Redimensionner la fenêtre du panneau"),
("Resize panels", "Redimensionner les panneaux"),
("Reverse the order", "Inverser l'ordre"),
("Right Indent Tab", "Tabulation de retrait à droite"),
("Rotate Tool", "Outil Rotation"),
+165 -69
View File
@@ -44,6 +44,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
pub use control::{ControlRequest, ControlResponse};
pub use prefs::ReadPreferences;
pub type ReadFn = Box<dyn Fn(&str) -> Result<Vec<u8>, String>>;
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
@@ -89,6 +90,59 @@ pub struct Services {
pub clipboard_text: Option<ClipboardFn>,
}
/// Closed panels, each with where it goes back, in craft-ui's `Workspace::hidden` shape
/// (`[[panel, location], …]`). A floating panel's entry is where Dock Panel returns it.
/// Files from before this shape hold an object keyed by panel; it still loads.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(transparent)]
pub struct HiddenPanels(pub Vec<(String, craft_ui::docking::Location<String>)>);
impl<'de> Deserialize<'de> for HiddenPanels {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Shape {
List(Vec<(String, craft_ui::docking::Location<String>)>),
Keyed(std::collections::BTreeMap<String, craft_ui::docking::Location<String>>),
}
let entries = match Shape::deserialize(deserializer)? {
Shape::List(entries) => entries,
Shape::Keyed(entries) => entries.into_iter().collect(),
};
let mut hidden = Self::default();
for (panel, location) in entries {
hidden.insert(panel, location);
}
Ok(hidden)
}
}
impl HiddenPanels {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn contains_key(&self, panel: &str) -> bool {
self.0.iter().any(|(p, _)| p == panel)
}
pub fn get(&self, panel: &str) -> Option<&craft_ui::docking::Location<String>> {
self.0.iter().find(|(p, _)| p == panel).map(|(_, location)| location)
}
/// Record where `panel` goes back, replacing an earlier entry.
pub fn insert(&mut self, panel: String, location: craft_ui::docking::Location<String>) {
self.remove(&panel);
self.0.push((panel, location));
}
pub fn remove(&mut self, panel: &str) {
self.0.retain(|(p, _)| p != panel);
}
pub fn keys(&self) -> impl Iterator<Item = &String> {
self.0.iter().map(|(p, _)| p)
}
pub fn clear(&mut self) {
self.0.clear();
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ScreenMode {
@@ -116,9 +170,9 @@ pub struct SavedWorkspace {
/// Custom panel membership and placement; absent in older workspace files.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docking: Option<craft_ui::docking::Layout<String>>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub docking_hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
/// The workspace's own icon column (see [`UiState::icon_groups`]).
#[serde(default, skip_serializing_if = "HiddenPanels::is_empty")]
pub docking_hidden: HiddenPanels,
/// The workspace's own iconic column (see [`UiState::icon_groups`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon_groups: Option<Vec<Vec<String>>>,
/// Saved with New Workspace › Capture › Panel Locations off: choosing the workspace leaves the
@@ -183,6 +237,11 @@ impl Default for PdfExportSettings {
}
}
/// The viewport of New Window's OS window, which shows pane 1.
pub(crate) fn second_window_viewport() -> egui::ViewportId {
egui::ViewportId::from_hash_of("designcraft_second_window")
}
/// Persisted UI state.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -266,29 +325,44 @@ pub struct UiState {
pub shortcuts: std::collections::BTreeMap<String, String>,
pub control_bar: bool,
pub tools_double_column: bool,
/// Expanded right-dock panel group tab.
/// The front tab of the default layout's Properties · Pages · Layers group. `dock_tab`,
/// `open_panel`, `floating`, `dock_expanded` and `icon_groups` describe a workspace's
/// default layout (`panel_docking::default_layout`).
pub dock_tab: String,
/// Panel opened from the collapsed icon column (flyout).
/// The panel it opens below that group (Printing and Proofing: Swatches).
pub open_panel: Option<String>,
/// Panels torn off the dock into their own floating windows: (panel id, top-left position).
/// Its floating panels, and the float-only panels' windows (Type › Tabs): (panel id,
/// top-left position). A customized layout holds its own floating panels.
pub floating: Vec<(String, [f32; 2])>,
/// Shared panel layout after the user customizes the legacy dock.
/// The panel layout once the user customizes it; `None` is the workspace's default layout
/// (`panel_docking::default_layout`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docking: Option<craft_ui::docking::Layout<String>>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub docking_hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
#[serde(default, skip_serializing_if = "HiddenPanels::is_empty")]
pub docking_hidden: HiddenPanels,
/// The panel (or the canvas) last clicked or selected in the docking layout, while the panel
/// stays where the user left it.
#[serde(skip)]
pub docking_focused: Option<String>,
/// Invalidates in-flight gestures when a workspace is replaced.
#[serde(skip)]
pub docking_generation: u64,
/// One-frame reveal request; z-order is never persisted.
#[serde(skip)]
pub docking_raise: Option<String>,
/// Last explicitly revealed customized panel, for the legacy closePanel control.
/// The panel last revealed explicitly, which `ui.set closePanel` closes.
#[serde(skip)]
pub docking_opened: Option<String>,
/// The panel whose iconic-column flyout is open, as of the last frame or a command that
/// opened it.
#[serde(skip)]
pub docking_flyout: Option<String>,
/// Auto-Collapse Iconic Panels: a flyout closes on Escape or a click outside it.
pub dock_auto_collapse: bool,
/// Whether the default layout's outer column is expanded (false: collapsed to icons).
pub dock_expanded: bool,
/// A workspace's iconic panel groups (Essentials Classic), shown with their names in the icon
/// column instead of every panel; `None` is the full icon column.
/// A workspace's iconic panel groups (Essentials Classic), shown with their names in the
/// iconic column instead of every panel; `None` is one icon per panel.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon_groups: Option<Vec<Vec<String>>>,
pub units: Unit,
@@ -426,6 +500,9 @@ impl Default for UiState {
docking_generation: 0,
docking_raise: None,
docking_opened: None,
docking_flyout: None,
dock_auto_collapse: true,
docking_focused: None,
dock_expanded: true,
icon_groups: None,
units: Unit::Picas,
@@ -1597,6 +1674,18 @@ impl DesignApp {
self.open_dropped_at(path, None)
}
/// Open or place each file dropped on a floating panel's own window. `pointer` is in the main
/// window's coordinates.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn open_dropped_files(&mut self, files: &[egui::DroppedFileHandle], pointer: Option<egui::Pos2>) {
for f in files {
let p = f.path().to_string_lossy().to_string();
if !p.is_empty() {
let _ = self.open_dropped_at(&p, pointer);
}
}
}
/// [`Self::open_dropped`] at the pointer: a graphic's top left lands where the file was
/// dropped on the canvas, text goes to a new frame on the document page there.
pub fn open_dropped_at(&mut self, path: &str, pointer: Option<egui::Pos2>) -> Result<Value, String> {
@@ -1880,6 +1969,63 @@ impl DesignApp {
self.control_input_frame.store(false, std::sync::atomic::Ordering::Relaxed);
}
/// The document area: the start screen, or the document tabs over one or two canvas panes.
pub(crate) fn canvas_area(&mut self, ui: &mut egui::Ui, presenting: bool) {
let t = theme::Tokens::get(ui.ctx());
if self.session.active().is_none() {
chrome::start_screen(self, ui);
return;
}
if !presenting {
chrome::doc_tabs(self, ui);
}
let split_layout = (self.split && !presenting)
.then(|| {
craft_ui::layout::split_rect(
ui.available_rect_before_wrap(),
craft_ui::layout::SplitAxis::Horizontal,
craft_ui::layout::SplitSize::Ratio(0.5),
2.0,
0.0,
)
})
.flatten();
if let Some(layout) = split_layout {
// Two panes with a divider; each has its own view (zoom, scroll) and render cache.
ui.painter().rect_filled(layout.divider, 0.0, t.border);
// Resolve the clicked pane before either canvas handles this frame's keys.
if let Some(p) = ui.input(|i| i.pointer.any_pressed().then(|| i.pointer.interact_pos()).flatten()) {
if layout.first.contains(p) {
self.focus_pane = 0;
} else if layout.second.contains(p) {
self.focus_pane = 1;
}
}
for (k, half) in [layout.first, layout.second].into_iter().enumerate() {
self.switch_pane(k as u8);
// A new pane starts at the other pane's view.
if k == 1 && self.view().is_none() {
self.switch_pane(0);
let v = self.view().copied();
self.switch_pane(1);
if let (Some(mut v), Some(m)) = (v, self.view_mut()) {
v.fitted = false;
*m = v;
}
}
ui.scope_builder(egui::UiBuilder::new().max_rect(half), |ui| canvas::show(self, ui));
}
self.switch_pane(self.focus_pane);
} else {
self.switch_pane(0);
// With a second window, the main one takes the focus back when clicked.
if !self.second_window || (ui.input(|i| i.pointer.any_pressed()) && ui.ui_contains_pointer()) {
self.focus_pane = 0;
}
canvas::show(self, ui);
}
}
/// Lay out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
#[cfg(test)]
@@ -1924,7 +2070,7 @@ impl DesignApp {
let t = theme::Tokens::get(&ctx);
let presenting = self.ui.screen_mode == ScreenMode::Presentation;
if presenting || self.ui.hidden_panels != 0 {
panel_docking::cancel_gesture(self, &ctx);
panel_docking::cancel_gesture(&ctx);
}
canvas::begin_frame(&ctx);
let dbg = std::env::var_os("DESIGNCRAFT_DEBUG_LAYOUT").is_some();
@@ -1946,62 +2092,13 @@ impl DesignApp {
if self.ui.hidden_panels != 1 {
toolbar::show(self, ui);
}
if self.ui.hidden_panels == 0 {
dock::show(self, ui);
}
}
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| {
if self.session.active().is_none() {
chrome::start_screen(self, ui);
return;
}
if !presenting {
chrome::doc_tabs(self, ui);
}
let split_layout = (self.split && !presenting)
.then(|| {
craft_ui::layout::split_rect(
ui.available_rect_before_wrap(),
craft_ui::layout::SplitAxis::Horizontal,
craft_ui::layout::SplitSize::Ratio(0.5),
2.0,
0.0,
)
})
.flatten();
if let Some(layout) = split_layout {
// Two panes with a divider; each has its own view (zoom, scroll) and render cache.
ui.painter().rect_filled(layout.divider, 0.0, t.border);
// Resolve the clicked pane before either canvas handles this frame's keys.
if let Some(p) = ui.input(|i| i.pointer.any_pressed().then(|| i.pointer.interact_pos()).flatten()) {
if layout.first.contains(p) {
self.focus_pane = 0;
} else if layout.second.contains(p) {
self.focus_pane = 1;
}
}
for (k, half) in [layout.first, layout.second].into_iter().enumerate() {
self.switch_pane(k as u8);
// A new pane starts at the other pane's view.
if k == 1 && self.view().is_none() {
self.switch_pane(0);
let v = self.view().copied();
self.switch_pane(1);
if let (Some(mut v), Some(m)) = (v, self.view_mut()) {
v.fitted = false;
*m = v;
}
}
ui.scope_builder(egui::UiBuilder::new().max_rect(half), |ui| canvas::show(self, ui));
}
self.switch_pane(self.focus_pane);
// The panel layout holds the canvas as its protected `canvas` panel.
if presenting || self.ui.hidden_panels != 0 {
self.canvas_area(ui, presenting);
} else {
self.switch_pane(0);
// With a second window, the main one takes the focus back when clicked.
if !self.second_window || (ui.input(|i| i.pointer.any_pressed()) && ui.ui_contains_pointer()) {
self.focus_pane = 0;
}
canvas::show(self, ui);
panel_docking::show(self, ui);
}
});
// New Window: pane 1 in a window of its own.
@@ -2012,7 +2109,7 @@ impl DesignApp {
let fill = t.pasteboard;
let ctx = ui.ctx().clone();
ctx.show_viewport_immediate(
egui::ViewportId::from_hash_of("designcraft_second_window"),
second_window_viewport(),
egui::ViewportBuilder::default().with_title(title).with_inner_size([960.0, 720.0]),
|ui, _class| {
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(fill)).show(ui, |ui| {
@@ -2047,8 +2144,7 @@ impl DesignApp {
}
// Tab, Shift+Tab and Presentation hide the floating panels with the docked ones.
if !presenting && self.ui.hidden_panels == 0 {
dock::flyout(self, &ctx);
dock::floating(self, &ctx);
dock::float_only_windows(self, &ctx);
}
story_editor::show(self, &ctx);
dialogs::show(self, &ctx);
+300 -141
View File
@@ -264,7 +264,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
None,
"{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links|table|preflight}",
),
("window.floatPanel", "Float Panel", None, "{panel, x?, y?} — the panel in its own movable window"),
("window.floatPanel", "Float Panel", None, "{panel, x?, y?, width?, height?} — the panel in its own floating window (window.panel.float)"),
(
"window.panel.layout",
"Change Panel Layout",
@@ -275,15 +275,45 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
"window.panel.move",
"Move Panel",
None,
"{panel, anchor, zone?: tab|center|left|right|top|bottom, before?: panel} — group, split or reorder a panel",
"{panel, anchor, zone?: tab|center|left|right|top|bottom|above|below, before?: panel|null, group?, placement?} — group, split or reorder a panel (group: its whole tab group)",
),
(
"window.panel.float",
"Float Panel",
None,
"{panel, x?, y?, width?, height?, group?, column?} — detach a panel (or its group, or its dock column) into a floating window",
),
(
"window.panel.dock",
"Dock Panel",
None,
"{panel, anchor?, zone?, before?, group?, column?, side?: Start|End} — dock a floating panel: beside anchor, in a new column at side, or back where it floated from",
),
("window.panel.float", "Float Panel", None, "{panel, x?, y?, width?, height?} — detach a panel inside the application window"),
("window.panel.dock", "Dock Panel", None, "{panel, anchor?, zone?, before?} — return a floating panel to a dock group"),
("window.panel.activate", "Activate Panel", None, "{panel} — show and select a panel in its current group"),
("window.panel.close", "Close Panel", None, "{panel} — hide a panel"),
("window.panel.closeGroup", "Close Tab Group", None, "{panel} — hide the panel and the panels tabbed with it"),
("window.panel.closeOthers", "Close Other Panels in Group", None, "{panel} — hide the panels tabbed with it"),
("window.panel.minimize", "Minimize", None, "{panel, minimized?} — minimize the panel's group to its tab strip (toggles without minimized)"),
("window.panel.iconic", "Collapse to Icons", None, "{panel, iconic?} — collapse the panel's dock column to icons (toggles without iconic)"),
(
"window.panel.window",
"Move Panel to New Window",
None,
"{panel, native?} — not available: a floating panel has its own window on the desktop and stays in the page on the web",
),
("window.workspace.new", "New Workspace", None, "{name} — save the current workspace (window.newWorkspace)"),
("window.workspace.delete", "Delete Workspace", None, "{name} — delete a saved workspace (window.deleteWorkspace)"),
("window.workspace.reset", "Reset Workspace", None, "{} — back to the current workspace as saved (window.resetWorkspace)"),
("window.workspace.lock", "Lock Workspace", None, "{locked?} — not available in DesignCraft"),
(
"ui.dock",
"Change Panel Layout",
None,
"{action} | {operation: open|close|activate|float|moveFloating|move|setStackOpen|resizeStack|resizeSplit, …} — craft-ui's shared ui.dock shape",
),
("ui.dock.reset", "Reset Workspace", None, "{} — window.workspace.reset"),
("window.collapseToIcons", "Collapse to Icons", None, "{} — the panel dock shrinks to its column of icons"),
("window.dockPanel", "Dock Panel", None, "{panel} — back into the dock's icon column"),
("window.dockPanel", "Dock Panel", None, "{panel} — back to its place in the dock (window.panel.dock)"),
("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"),
// Object › Fitting with InDesign's shortcuts (one `object.fit` mode each).
("fit.fillFrameProportionally", "Fill Frame Proportionally", Some("Cmd+Alt+Shift+C"), "{} — object.fit {mode: fillProportionally}"),
@@ -969,7 +999,16 @@ pub const MENUS: &[(&str, &[&str])] = &[
pub const WORKSPACES: &[&str] =
&["Advanced", "Book", "Digital Publishing", "Essentials", "Essentials Classic", "Interactive for PDF", "Printing and Proofing", "Typography"];
/// Essentials Classic's icon column: InDesign's iconic panel groups, top to bottom.
/// The built-in workspaces that open a panel below the Properties · Pages · Layers group.
pub const WORKSPACE_OPEN_PANELS: &[(&str, &str)] =
&[("Printing and Proofing", "swatches"), ("Interactive for PDF", "buttons"), ("Digital Publishing", "liquid")];
/// The panel the built-in workspace `name` opens below the Properties · Pages · Layers group.
pub fn workspace_open_panel(name: &str) -> Option<&'static str> {
WORKSPACE_OPEN_PANELS.iter().find(|(workspace, _)| *workspace == name).map(|(_, panel)| *panel)
}
/// Essentials Classic's iconic column: InDesign's iconic panel groups, top to bottom.
pub const CLASSIC_GROUPS: &[&[&str]] = &[
&["pages", "layers", "links"],
&["stroke", "color"],
@@ -1000,10 +1039,10 @@ pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> {
/// Type › Tabs: float the Tabs panel (placed above the selected text frame) or close it.
fn toggle_tabs_panel(app: &mut DesignApp) {
if app.ui.floating.iter().any(|(p, _)| p == "tabs") {
crate::dock::dock_panel(app, "tabs");
app.ui.floating.retain(|(p, _)| p != "tabs");
} else {
let at = app.canvas_rect.map_or(egui::pos2(300.0, 140.0), |r| r.min + egui::vec2(80.0, 60.0));
crate::dock::float_panel(app, "tabs", at);
app.ui.floating.push(("tabs".into(), [at.x, at.y]));
app.ui.tabs_panel.snap_request = 2;
}
}
@@ -1399,10 +1438,6 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
};
return Some(r);
}
"app.tablePanel" => {
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some("table") { None } else { Some("table".into()) };
Ok(Value::Null)
}
"app.tabsPanel" => {
toggle_tabs_panel(app);
Ok(Value::Null)
@@ -1715,19 +1750,13 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
run_ui(app, "window.panel", &json!({"panel": panel})).unwrap_or(Ok(Value::Null))
}
"app.insertCurrentPageNumber" => app.run("text.insert", json!({"text": "\u{e000}", "raw": true})),
// `panel_docking::command` takes the dock panels (and the Window › Styles group) first.
"window.panel" => {
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("properties").to_string();
// A panel in the workspace's icon groups opens from there (Essentials Classic's Pages).
let iconic = app.ui.icon_groups.as_ref().is_some_and(|g| g.iter().flatten().any(|p| *p == panel));
if panel == "tabs" {
toggle_tabs_panel(app);
} else if !iconic && crate::dock::DOCK_TABS.iter().any(|(id, _, _)| *id == panel) {
app.ui.dock_tab = panel;
app.ui.dock_expanded = true;
app.ui.open_panel = None;
} else if !app.ui.floating.iter().any(|(p, _)| *p == panel) {
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(panel.as_str()) { None } else { Some(panel) };
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("properties");
if panel != "tabs" {
return crate::panel_docking::command(app, "window.panel.activate", &json!({"panel": panel}));
}
toggle_tabs_panel(app);
Ok(Value::Null)
}
"fit.fillFrameProportionally" | "fit.contentProportionally" | "fit.frameToContent" | "fit.contentToFrame" | "fit.centerContent" => {
@@ -1816,22 +1845,6 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
app.ui.ui_scale = v.clamp(0.5, 3.0);
Ok(json!({"scale": app.ui.ui_scale}))
}
"window.floatPanel" | "window.dockPanel" => {
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
if !crate::dock::ICON_PANELS.iter().any(|(id, _, _)| *id == panel) && !crate::dock::FLOATING_PANELS.iter().any(|(id, _)| *id == panel) {
return Some(Err(format!("{id}: unknown panel `{panel}`")));
}
if id == "window.floatPanel" {
let at = egui::pos2(
p.get("x").and_then(Value::as_f64).unwrap_or(400.0) as f32,
p.get("y").and_then(Value::as_f64).unwrap_or(160.0) as f32,
);
crate::dock::float_panel(app, panel, at);
} else {
crate::dock::dock_panel(app, panel);
}
Ok(json!({"floating": app.ui.floating.iter().map(|(p, _)| p.clone()).collect::<Vec<_>>()}))
}
"window.controlBar" => flag(&mut app.ui.control_bar),
"view.flattenerPreview" => flag(&mut app.ui.flattener_preview),
"view.tagMarkers" => {
@@ -1912,11 +1925,6 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
Ok(json!({"url": url}))
}
"window.toolsDoubleColumn" => flag(&mut app.ui.tools_double_column),
"window.collapseToIcons" => {
app.ui.dock_expanded = false;
app.ui.open_panel = None;
Ok(Value::Null)
}
"window.newWorkspace" => {
let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) else {
app.ui.dialog =
@@ -1963,8 +1971,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
}
"window.workspace" => {
app.ui.docking_generation = app.ui.docking_generation.wrapping_add(1);
app.ui.docking_raise = None;
app.ui.docking_opened = None;
crate::panel_docking::forget_panels(app);
let name = p.get("name").and_then(Value::as_str).unwrap_or("Essentials");
if let Some(w) = app.ui.custom_workspaces.iter().find(|w| w.name == name).cloned() {
if let Some(hidden) = w.hidden_menu_items {
@@ -1995,7 +2002,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
app.ui.docking = None;
app.ui.docking_hidden.clear();
app.ui.control_bar = matches!(name, "Advanced" | "Typography" | "Printing and Proofing" | "Book" | "Essentials Classic");
// Essentials Classic: the Control panel and an icon column of grouped panels, no
// Essentials Classic: the Control panel and an iconic column of grouped panels, no
// Properties stack.
let groups = match name {
"Essentials Classic" => Some(CLASSIC_GROUPS),
@@ -2012,12 +2019,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
app.ui.dock_tab = tab.into();
app.ui.dock_expanded = true;
}
app.ui.open_panel = match name {
"Printing and Proofing" => Some("swatches".into()),
"Interactive for PDF" => Some("buttons".into()),
"Digital Publishing" => Some("liquid".into()),
_ => None,
};
app.ui.open_panel = workspace_open_panel(name).map(str::to_string);
app.ui.workspace = name.to_string();
Ok(Value::Null)
}
@@ -2226,6 +2228,10 @@ pub fn enabled(app: &DesignApp, id: &str) -> bool {
if is_window_control(id) {
return window_control_available(app, id);
}
// Shared docking commands DesignCraft does not provide (native panel windows, Lock Workspace).
if matches!(id, "window.panel.window" | "window.workspace.lock") {
return false;
}
match designcraft_engine::find_command(id) {
Some(c) => (c.enabled)(&app.session).is_ok(),
None => true,
@@ -3195,93 +3201,149 @@ pub fn paste_chord_command(app: &DesignApp, modifiers: egui::Modifiers) -> Optio
pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) {
// Only a focused text field takes the keys: the canvas (or a button) having focus after a click
// must not swallow tool shortcuts until Esc clears it.
// While an IME composes at the text caret, the keyboard is its own. An open canvas context menu
// takes the keys (Escape closes it), as a native menu would.
if ctx.text_edit_focused()
|| app.ui.dialog.is_some()
|| app.ui.palette.is_some()
|| app.session.tool_composing()
|| crate::canvas::context_menu_open(ctx)
|| crate::chrome::search_had_focus(ctx)
{
if ctx.text_edit_focused() || keys_taken(app, ctx) {
return;
}
let typing = app.session.wants_text();
let events = ctx.input(|i| i.events.clone());
for e in events {
let egui::Event::Key { key, pressed: true, modifiers, repeat: false, .. } = e else { continue };
// Presentation hides every control, so Esc always leaves it, and only that: the canvas must
// not also see the press and switch tools or deselect.
if key == egui::Key::Escape && app.ui.screen_mode == ScreenMode::Presentation {
if !shortcut_key(app, key, modifiers, typing, &mut || {
ctx.input_mut(|i| i.consume_key(modifiers, key));
let _ = app.run("view.togglePresentation", json!({}));
continue;
}) {
escape_to_selection(app, key, modifiers, typing);
}
// Presentation goes through the spreads with the arrow keys, Page Up/Down, Home and End.
if app.ui.screen_mode == ScreenMode::Presentation && !modifiers.command && !modifiers.alt {
let step = match key {
egui::Key::ArrowRight | egui::Key::ArrowDown | egui::Key::PageDown => Some(1),
egui::Key::ArrowLeft | egui::Key::ArrowUp | egui::Key::PageUp => Some(-1),
egui::Key::Home => Some(isize::MIN),
egui::Key::End => Some(isize::MAX),
_ => None,
};
if let Some(step) = step {
ctx.input_mut(|i| i.consume_key(modifiers, key));
crate::canvas::step_spread(app, step);
}
}
/// Keys that a floating panel in its own OS window left over (craft-ui's
/// `DockOutput::forwarded_events`, with the modifiers held in that window): they run the main
/// window's shortcuts, clipboard and tool keys in the frame they arrive, so the keyboard works
/// while a floating window has focus. craft-ui forwards nothing while one of that window's
/// widgets has keyboard focus.
pub fn forwarded_shortcuts(app: &mut DesignApp, ctx: &egui::Context, events: Vec<egui::Event>, modifiers: egui::Modifiers) {
if events.is_empty() || keys_taken(app, ctx) {
return;
}
let typing = app.session.wants_text();
let mut rest = Vec::with_capacity(events.len());
let mut escape = None;
for e in events {
if let egui::Event::Key { key, pressed: true, modifiers, repeat: false, .. } = e {
if shortcut_key(app, key, modifiers, typing, &mut || {}) {
continue;
}
}
// Command shortcuts.
let mut fired: Option<(String, bool)> = None;
let all = effective_shortcuts(app);
for (id, sc) in &all {
let id = id.as_str();
if let Some((m, k)) = parse_shortcut(sc)
&& let Some(exact) = key_matches(m, k, key, modifiers)
&& (m.command || m.alt || !typing)
&& !(id == "edit.clear" && typing)
{
// Single-key shortcuts (W) only when not typing.
if !m.command && !m.alt && !m.shift && typing {
continue;
}
if id == "edit.clear" || (typing && matches!(id, "edit.copy" | "edit.cut" | "edit.paste" | "edit.pasteInPlace")) {
continue; // Handled by the active tool / text clipboard events.
}
fired = Some((id.to_string(), exact));
break;
if key == egui::Key::Escape {
escape = Some(modifiers);
}
}
if let Some((id, exact)) = fired {
// The native menu bar carries the shortcuts in effect, the user's included, and its
// accelerator fires only for the shortcut's own key and modifiers; the Plus key standing
// in for = never reaches it.
if exact && app.native_shortcuts.contains(&id) {
continue; // The native menu handles it.
}
// The key press belongs to the shortcut: what it opens (Quick Apply's list, a dialog's
// default button) must not see the same press as Enter or Space this frame.
ctx.input_mut(|i| i.consume_key(modifiers, key));
// Like choosing the menu item: "…" commands open their dialog.
activate(app, &id, &Value::Null);
continue;
rest.push(e);
}
crate::canvas::forwarded_keys(app, rest, modifiers);
if let Some(m) = escape {
escape_to_selection(app, egui::Key::Escape, m, typing);
}
}
/// Whether something other than the shortcuts has the keyboard: a dialog or Quick Apply, an input
/// method composing at the text caret, an open canvas context menu (Escape closes it, as a
/// native menu would) or the menu search.
fn keys_taken(app: &DesignApp, ctx: &egui::Context) -> bool {
app.ui.dialog.is_some()
|| app.ui.palette.is_some()
|| app.session.tool_composing()
|| crate::canvas::context_menu_open(ctx)
|| crate::chrome::search_had_focus(ctx)
}
/// Runs the presentation key, command shortcut or tool shortcut a key press stands for. `consume`
/// takes the press from the input where nothing else may see it. Returns whether the press was used.
fn shortcut_key(app: &mut DesignApp, key: egui::Key, modifiers: egui::Modifiers, typing: bool, consume: &mut dyn FnMut()) -> bool {
// Presentation hides every control, so Esc always leaves it, and only that: the canvas must
// not also see the press and switch tools or deselect.
if key == egui::Key::Escape && app.ui.screen_mode == ScreenMode::Presentation {
consume();
let _ = app.run("view.togglePresentation", json!({}));
return true;
}
// Presentation goes through the spreads with the arrow keys, Page Up/Down, Home and End.
if app.ui.screen_mode == ScreenMode::Presentation && !modifiers.command && !modifiers.alt {
let step = match key {
egui::Key::ArrowRight | egui::Key::ArrowDown | egui::Key::PageDown => Some(1),
egui::Key::ArrowLeft | egui::Key::ArrowUp | egui::Key::PageUp => Some(-1),
egui::Key::Home => Some(isize::MIN),
egui::Key::End => Some(isize::MAX),
_ => None,
};
if let Some(step) = step {
consume();
crate::canvas::step_spread(app, step);
return true;
}
// Tool shortcuts (single keys, Shift+key).
if !typing && !modifiers.command && !modifiers.alt {
let name = key.name();
let sc = if modifiers.shift { format!("Shift+{name}") } else { name.to_string() };
if let Some(tool) = designcraft_tools::tool_for_shortcut(&sc) {
log::debug!("tool shortcut {sc} -> {tool}");
if std::env::var_os("DESIGNCRAFT_DEBUG_KEYS").is_some() {
eprintln!("tool shortcut {sc} ({key:?}, {modifiers:?}) -> {tool}");
}
app.select_tool(tool);
} else if key == egui::Key::Escape && app.session.tool_id() != "selection" && !app.session.tool_busy() && app.ui.flyout.is_none() {
app.select_tool("selection");
}
// Command shortcuts.
let mut fired: Option<(String, bool)> = None;
let all = effective_shortcuts(app);
for (id, sc) in &all {
let id = id.as_str();
if let Some((m, k)) = parse_shortcut(sc)
&& let Some(exact) = key_matches(m, k, key, modifiers)
&& (m.command || m.alt || !typing)
&& !(id == "edit.clear" && typing)
{
// Single-key shortcuts (W) only when not typing.
if !m.command && !m.alt && !m.shift && typing {
continue;
}
if id == "edit.clear" || (typing && matches!(id, "edit.copy" | "edit.cut" | "edit.paste" | "edit.pasteInPlace")) {
continue; // Handled by the active tool / text clipboard events.
}
fired = Some((id.to_string(), exact));
break;
}
}
if let Some((id, exact)) = fired {
// The native menu bar carries the shortcuts in effect, the user's included, and its
// accelerator fires only for the shortcut's own key and modifiers; the Plus key standing
// in for = never reaches it.
if exact && app.native_shortcuts.contains(&id) {
return true; // The native menu handles it.
}
// The key press belongs to the shortcut: what it opens (Quick Apply's list, a dialog's
// default button) must not see the same press as Enter or Space this frame.
consume();
// Like choosing the menu item: "…" commands open their dialog.
activate(app, &id, &Value::Null);
return true;
}
// Tool shortcuts (single keys, Shift+key).
if !typing && !modifiers.command && !modifiers.alt {
let name = key.name();
let sc = if modifiers.shift { format!("Shift+{name}") } else { name.to_string() };
if let Some(tool) = designcraft_tools::tool_for_shortcut(&sc) {
log::debug!("tool shortcut {sc} -> {tool}");
if std::env::var_os("DESIGNCRAFT_DEBUG_KEYS").is_some() {
eprintln!("tool shortcut {sc} ({key:?}, {modifiers:?}) -> {tool}");
}
app.select_tool(tool);
return true;
}
}
false
}
/// Esc with no shortcut of its own goes back to the Selection tool.
fn escape_to_selection(app: &mut DesignApp, key: egui::Key, modifiers: egui::Modifiers, typing: bool) {
if key == egui::Key::Escape
&& !typing
&& !modifiers.command
&& !modifiers.alt
&& app.session.tool_id() != "selection"
&& !app.session.tool_busy()
&& app.ui.flyout.is_none()
{
app.select_tool("selection");
}
}
/// Quick Apply (⌘Return): the document's styles and every command, searched by name.
@@ -3422,6 +3484,19 @@ const NOT_SEARCHED: &[&str] = &[
"view.zoom",
// Where to pin the Contextual Task Bar.
"window.taskBarPin",
// Shared docking commands DesignCraft doesn't provide, those that need a panel or a name,
// and older ids that share a label with a searched docking command.
"window.panel",
"window.panel.window",
"window.workspace.lock",
"window.panel.closeOthers",
"window.panel.minimize",
"window.panel.iconic",
"window.workspace.new",
"window.workspace.delete",
"ui.dock",
"ui.dock.reset",
"window.resetWorkspace",
];
/// The commands Quick Apply and the application bar's search find: (id, label, default shortcut).
@@ -4107,9 +4182,10 @@ mod tests {
out.textures_delta.clear();
}
};
// The icon column's flyout (the classic dock) and a floating panel group (the docking layout).
let flyout = egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("panel_flyout"));
let group = egui::LayerId::new(egui::Order::Middle, egui::Id::unique("designcraft-panel-docking").with(("floating", "preflight")));
// The docked panel's tab and a floating panel group.
let tab = crate::panel_docking::tab_id("preflight");
let docked = || ctx.read_response(tab).is_some();
let group = crate::panel_docking::floating_layer("preflight");
let shown = |layer: egui::LayerId| ctx.memory(|m| m.areas().is_visible(&layer));
let toggles = [
("window.hidePanels", json!({}), json!({})),
@@ -4118,14 +4194,14 @@ mod tests {
];
app.run("window.panel", json!({"panel": "preflight"})).unwrap();
frame(&mut app);
assert!(app.ui.docking.is_none() && shown(flyout));
assert!(docked());
for (id, hide, show) in &toggles {
app.run(id, hide.clone()).unwrap();
frame(&mut app);
assert!(!shown(flyout), "{id} hides the flyout");
assert!(!docked(), "{id} hides the dock");
app.run(id, show.clone()).unwrap();
frame(&mut app);
assert!(shown(flyout), "{id} again brings it back");
assert!(docked(), "{id} again brings it back");
}
app.run("window.floatPanel", json!({"panel": "preflight", "x": 400, "y": 200})).unwrap();
frame(&mut app);
@@ -5117,13 +5193,21 @@ mod tests {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
let r = run_ui(&mut app, "window.floatPanel", &json!({"panel": "swatches", "x": 50, "y": 60})).unwrap().unwrap();
assert_eq!(r["floating"], json!(["swatches"]));
assert_eq!(app.ui.floating[0].1, [50.0, 60.0]);
// Showing a floating panel doesn't also open its flyout.
let window = &app.ui.docking.as_ref().unwrap().floating[0];
assert_eq!([window.rect[0], window.rect[1]], [50.0, 60.0]);
// Showing a floating panel leaves it where it is.
run_ui(&mut app, "window.panel", &json!({"panel": "swatches"})).unwrap().unwrap();
assert_eq!(app.ui.open_panel, None);
run_ui(&mut app, "window.dockPanel", &json!({"panel": "swatches"})).unwrap().unwrap();
assert!(app.ui.floating.is_empty());
let r = run_ui(&mut app, "window.dockPanel", &json!({"panel": "swatches"})).unwrap().unwrap();
assert_eq!(r["floating"], json!([]));
assert!(app.ui.docking.as_ref().unwrap().floating.is_empty());
assert!(run_ui(&mut app, "window.floatPanel", &json!({"panel": "nope"})).unwrap().is_err());
// Float-only panels (Type › Tabs) have their own windows outside the layout.
let r = run_ui(&mut app, "window.floatPanel", &json!({"panel": "tabs", "x": 10, "y": 20})).unwrap().unwrap();
assert_eq!(r["floating"], json!(["tabs"]));
assert_eq!(app.ui.floating, [("tabs".to_string(), [10.0, 20.0])]);
run_ui(&mut app, "window.dockPanel", &json!({"panel": "tabs"})).unwrap().unwrap();
assert!(app.ui.floating.is_empty());
}
/// One frame as the desktop app runs it: queued synthetic input, then the native menu item
@@ -5369,8 +5453,17 @@ mod tests {
assert!(!quick.iter().any(|i| i.id == "window.deleteWorkspace"));
// Each command runs as Quick Apply runs an entry. One that needs a selection may fail
// without it, but runs in one of the contexts; none changes a preference it wasn't asked to.
// Opening a panel changes where the panels are, as it should.
let opens_a_panel = ["app.tabsPanel", "app.characterStylesPanel", "app.paragraphStylesPanel"];
// Opening a panel or collapsing the dock to icons changes where the panels are, as it should.
let opens_a_panel = [
"app.tabsPanel",
"app.characterStylesPanel",
"app.paragraphStylesPanel",
"app.characterPanel",
"app.paragraphPanel",
"app.glyphsPanel",
"app.tablePanel",
"window.collapseToIcons",
];
let arrangement = ["docking", "dockingHidden", "floating"];
let mut failed = Vec::new();
for (id, ..) in quick_commands().filter(|(id, ..)| UI_COMMANDS.iter().any(|c| c.0 == *id)) {
@@ -5538,11 +5631,17 @@ mod tests {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
activate(&mut app, "app.characterPanel", &Value::Null);
assert_eq!(app.ui.open_panel.as_deref(), Some("character"));
assert!(crate::panel_docking::showing(&app, "character"));
// Paragraph Styles opens floating, grouped with Character Styles, as in InDesign.
activate(&mut app, "app.paragraphStylesPanel", &Value::Null);
let layout = app.ui.docking.as_ref().unwrap();
assert!(layout.floating.iter().any(|g| g.panels == ["characterStyles", "paragraphStyles"] && g.active == 1));
assert!(
layout
.floating
.iter()
.flat_map(crate::panel_docking::window_groups)
.any(|g| g.panels == ["characterStyles", "paragraphStyles"] && g.active == 1)
);
let story = app.run("frame.create", json!({"rect": [36, 36, 200, 100], "content": "text", "text": "Page "})).unwrap()["story"].clone();
app.run("text.select", json!({"story": story, "anchor": 5, "focus": 5})).unwrap();
activate(&mut app, "app.insertCurrentPageNumber", &Value::Null);
@@ -5615,7 +5714,10 @@ mod tests {
assert_eq!(checked(&app, "window.workspace", &json!({"name": "Essentials"})), Some(false));
// Window › Pages opens Pages from its icon group, not the Properties stack.
run_ui(&mut app, "window.panel", &json!({"panel": "pages"})).unwrap().unwrap();
assert_eq!((app.ui.open_panel.as_deref(), app.ui.dock_expanded), (Some("pages"), false));
assert!(crate::panel_docking::showing(&app, "pages"));
let layout = app.ui.docking.clone().unwrap();
assert!(!layout.contains(&"properties".to_string()));
assert_eq!(crate::panel_docking::tab_group(&layout, "pages"), ["pages", "layers", "links"]);
// A workspace saved from it keeps the groups.
run_ui(&mut app, "window.newWorkspace", &json!({"name": "Mine"})).unwrap().unwrap();
run_ui(&mut app, "window.workspace", &json!({"name": "Essentials"})).unwrap().unwrap();
@@ -5637,8 +5739,8 @@ mod tests {
assert_eq!(groups[5], ["character", "characterStyles"]);
for panel in ["pages", "paragraphStyles", "characterStyles"] {
run_ui(&mut app, "window.panel", &json!({"panel":panel})).unwrap().unwrap();
assert_eq!(app.ui.open_panel.as_deref(), Some(panel));
assert!(!app.ui.dock_expanded);
assert!(crate::panel_docking::showing(&app, panel), "{panel}");
assert!(app.ui.docking.as_ref().unwrap().floating.is_empty(), "{panel} opens from its icon group");
}
run_ui(&mut app, "window.resetWorkspace", &json!({})).unwrap().unwrap();
assert_eq!(app.ui.icon_groups, Some(groups));
@@ -6268,4 +6370,61 @@ mod tests {
}
assert!(app.ui.dialog.is_some(), "clicking the empty end of the Preferences… row must choose it");
}
/// Quick Apply and the menu search list no unavailable docking command and no label twice
/// among the UI commands.
#[test]
fn searched_ui_commands_are_available_and_named_once() {
let ui_ids: Vec<&str> = UI_COMMANDS.iter().map(|c| c.0).collect();
let searched: Vec<(&str, &str)> = quick_commands().filter(|c| ui_ids.contains(&c.0)).map(|c| (c.0, c.1)).collect();
assert!(!searched.iter().any(|c| matches!(c.0, "window.panel.window" | "window.workspace.lock" | "window.workspace")), "{searched:?}");
assert!(searched.iter().any(|c| c.0 == "window.collapseToIcons"), "Collapse to Icons is searched");
// Quick Apply runs an entry as `app.run(id, {})`, so each docking command it lists must
// work without params.
let docking = |id: &str| {
id.starts_with("window.panel")
|| id.starts_with("window.workspace")
|| id.starts_with("ui.dock")
|| matches!(id, "window.floatPanel" | "window.dockPanel" | "window.collapseToIcons" | "window.resetWorkspace")
};
let failing: Vec<(&str, String)> = searched
.iter()
.filter(|c| docking(c.0))
.filter_map(|c| {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run(c.0, json!({})).err().map(|e| (c.0, e))
})
.collect();
assert!(failing.is_empty(), "searched docking commands that fail with {{}}: {failing:?}");
let mut labels: Vec<&str> = searched.iter().map(|c| c.1).collect();
labels.sort_unstable();
let before = labels.len();
labels.dedup();
assert_eq!(labels.len(), before, "a label twice: {searched:?}");
}
/// Keys a floating panel's own OS window left over run the main window's tool shortcuts,
/// Escape and the active tool's keys.
#[test]
fn forwarded_keys_run_shortcuts() {
use egui::{Event, Key, Modifiers};
fn press(key: Key) -> Vec<Event> {
[true, false].map(|pressed| Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers: Modifiers::NONE }).to_vec()
}
let ctx = egui::Context::default();
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", &json!({})).unwrap();
let frame = app.session.execute("frame.create", &json!({"rect": [36, 36, 200, 200]})).unwrap();
app.session.execute("selection.set", &json!({"ids": [frame["id"]]})).unwrap();
forwarded_shortcuts(&mut app, &ctx, press(Key::P), Modifiers::NONE);
assert_ne!(app.session.tool_id(), "selection", "P picks a tool");
forwarded_shortcuts(&mut app, &ctx, press(Key::Escape), Modifiers::NONE);
assert_eq!(app.session.tool_id(), "selection", "Escape goes back to the Selection tool");
app.session.execute("selection.set", &json!({"ids": [frame["id"]]})).unwrap();
forwarded_shortcuts(&mut app, &ctx, press(Key::Delete), Modifiers::NONE);
assert!(
app.session.active().unwrap().doc.item(designcraft_doc::ItemId(frame["id"].as_u64().unwrap())).is_none(),
"Delete clears the selection"
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9
View File
@@ -797,6 +797,15 @@ mod font_menu_ui_tests {
for k in 0..count {
h.state_mut().app.run("type.char", json!({"attrs": {"fontFamily": designcraft_fonts::DEFAULT_FAMILY}})).unwrap();
h.run_steps(4);
// A short Properties group (below it, the workspace's open panel) scrolls to the field.
let mut nodes: Vec<_> = h
.query_all_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(designcraft_fonts::DEFAULT_FAMILY))
.collect();
nodes.sort_by(|a, b| a.rect().min.y.total_cmp(&b.rect().min.y).then(a.rect().min.x.total_cmp(&b.rect().min.x)));
if let Some(node) = nodes.get(k) {
node.scroll_to_me();
}
h.run_steps(4);
let at = fields(&h, designcraft_fonts::DEFAULT_FAMILY)[k];
search_in_menu(&mut h, at, &format!("{name}, field {k}"));
assert_eq!(family(&mut h), "Source Sans 3", "{name}, field {k}");
+16 -10
View File
@@ -16,10 +16,10 @@ use crate::widgets::{
};
use crate::{DesignApp, icons};
/// A link to another panel. `window.panel` shows it in the docked layout or as a flyout; it
/// toggles the flyout, so a flyout that is already open is left as it is.
/// A link to another panel: `window.panel` shows it in the docking layout. An open flyout of
/// that panel stays: `window.panel` would close it.
fn show_panel(app: &mut DesignApp, panel: &str) {
if app.ui.open_panel.as_deref() != Some(panel) {
if app.ui.docking_flyout.as_deref() != Some(panel) {
let _ = app.run("window.panel", json!({"panel": panel}));
}
}
@@ -1212,7 +1212,7 @@ fn text_style_section(app: &mut DesignApp, ui: &mut Ui) {
)
.clicked()
{
app.ui.open_panel = Some(if tab == 0 { "paragraphStyles" } else { "characterStyles" }.into());
show_panel(app, if tab == 0 { "paragraphStyles" } else { "characterStyles" });
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 12.0;
@@ -1230,7 +1230,7 @@ fn text_style_section(app: &mut DesignApp, ui: &mut Ui) {
let _ = app.run("style.paragraph.apply", json!({"name": cur, "clearOverrides": true}));
}
if icon_toggle(ui, "style-load", false, crate::i18n::tr(&app.ui.language, "Styles panel")).clicked() {
app.ui.open_panel = Some(if tab == 0 { "paragraphStyles" } else { "characterStyles" }.into());
show_panel(app, if tab == 0 { "paragraphStyles" } else { "characterStyles" });
}
});
});
@@ -1927,12 +1927,8 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
}
}
/// Opens the Typography panel, or focuses it when it is already showing. `window.panel` toggles a
/// flyout, so it is skipped while the Typography flyout is open.
/// Opens the Typography panel, or brings it to the front when it is already open.
pub fn show_typography_panel(app: &mut DesignApp) {
if app.ui.open_panel.as_deref() == Some("typography") {
return;
}
let _ = app.run("window.panel", json!({"panel": "typography"}));
}
@@ -3201,6 +3197,16 @@ mod tests {
use super::*;
/// A link to a panel whose flyout is open leaves the flyout open.
#[test]
fn a_panel_link_keeps_its_open_flyout() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
show_panel(&mut app, "stroke");
assert_eq!(app.ui.docking_flyout.as_deref(), Some("stroke"));
show_panel(&mut app, "stroke");
assert!(crate::panel_docking::showing(&app, "stroke"));
}
fn language(app: &mut DesignApp) -> Value {
text_attrs(app).unwrap()["chars"]["language"].clone()
}
+320 -17
View File
@@ -3,41 +3,117 @@
use craft_ui::docking::{Layout, Location};
use serde_json::Value;
use crate::UiState;
use crate::{HiddenPanels, SavedWorkspace, UiState, panel_docking};
/// Saved preferences, and whether reading them discarded anything.
pub struct ReadPreferences {
pub ui: UiState,
/// False when unreadable docking data, or an old layout that could not be converted, was
/// dropped. The caller keeps the file it read instead of overwriting it.
pub intact: bool,
}
impl UiState {
/// Read saved preferences, discarding only unreadable optional docking metadata.
/// A layout from a newer craft-ui version must not reset unrelated settings or named workspaces.
pub fn from_preferences_json(bytes: &[u8]) -> serde_json::Result<Self> {
Self::read_preferences(bytes).map(|read| read.ui)
}
/// [`Self::from_preferences_json`], also reporting whether anything was discarded. Layouts
/// saved before the canvas joined the panel layout are converted (see
/// [`panel_docking::from_dock_only`]); that is not a loss, unless one cannot be converted.
pub fn read_preferences(bytes: &[u8]) -> serde_json::Result<ReadPreferences> {
let mut saved: Value = serde_json::from_slice(bytes)?;
recover_docking(&mut saved);
let mut intact = recover_docking(&mut saved);
if let Some(workspaces) = saved.get_mut("customWorkspaces").and_then(Value::as_array_mut) {
for workspace in workspaces {
recover_docking(workspace);
intact &= recover_docking(workspace);
}
}
serde_json::from_value(saved)
let mut ui: UiState = serde_json::from_value(saved)?;
clear_stale_open_panels(&mut ui);
intact &= migrate(&mut ui.docking, &mut ui.floating, &ui.docking_hidden, ui.dock_expanded, ui.icon_groups.as_deref());
for workspace in &mut ui.custom_workspaces {
let SavedWorkspace { docking, floating, docking_hidden, dock_expanded, icon_groups, .. } = workspace;
intact &= migrate(docking, floating, docking_hidden, *dock_expanded, icon_groups.as_deref());
}
Ok(ReadPreferences { ui, intact })
}
}
fn recover_docking(saved: &mut Value) {
let Some(fields) = saved.as_object_mut() else { return };
if fields.get("docking").is_some_and(|value| serde_json::from_value::<Option<Layout<String>>>(value.clone()).is_err()) {
fields.remove("docking");
}
if let Some(hidden) = fields.get_mut("dockingHidden") {
if let Some(entries) = hidden.as_object_mut() {
entries.retain(|_, value| serde_json::from_value::<Location<String>>(value.clone()).is_ok());
} else {
fields.remove("dockingHidden");
/// Files from before the canvas joined the panel layout saved the flyout open at quit as
/// `openPanel`, which is the panel a workspace opens below Properties · Pages · Layers. Only a
/// workspace's own panel stays: a built-in workspace's (Printing and Proofing: Swatches), and in a
/// saved workspace one a built-in workspace opens (saving copies it). The current workspace keeps
/// a saved workspace's panel.
fn clear_stale_open_panels(ui: &mut UiState) {
let built_in = |panel: &str| crate::menus::WORKSPACE_OPEN_PANELS.iter().any(|(_, p)| *p == panel);
for workspace in &mut ui.custom_workspaces {
if workspace.open_panel.as_deref().is_some_and(|panel| !built_in(panel)) {
workspace.open_panel = None;
}
}
let own = match ui.custom_workspaces.iter().find(|w| w.name == ui.workspace) {
Some(saved) => saved.open_panel.as_deref(),
None => crate::menus::workspace_open_panel(&ui.workspace),
};
if ui.open_panel.as_deref() != own {
ui.open_panel = None;
}
}
/// Drop docking data this version cannot read. True when nothing was dropped.
fn recover_docking(saved: &mut Value) -> bool {
let Some(fields) = saved.as_object_mut() else { return true };
let mut intact = true;
if fields.get("docking").is_some_and(|value| serde_json::from_value::<Option<Layout<String>>>(value.clone()).is_err()) {
fields.remove("docking");
intact = false;
}
if let Some(hidden) = fields.get_mut("dockingHidden") {
// `[[panel, location], …]`, or an object keyed by panel from older files.
if let Some(entries) = hidden.as_array_mut() {
let before = entries.len();
entries.retain(|entry| serde_json::from_value::<(String, Location<String>)>(entry.clone()).is_ok());
intact &= entries.len() == before;
} else if let Some(entries) = hidden.as_object_mut() {
let before = entries.len();
entries.retain(|_, value| serde_json::from_value::<Location<String>>(value.clone()).is_ok());
intact &= entries.len() == before;
} else {
fields.remove("dockingHidden");
intact = false;
}
}
intact
}
/// Convert a layout saved before the canvas joined the panel layout. One that cannot be
/// converted gives way to the workspace's default layout, which the workspace fields
/// (`dockTab`, `openPanel`, `floating`, `dockExpanded`, `iconGroups`) still describe. False when
/// a layout was dropped that way.
fn migrate(
docking: &mut Option<Layout<String>>,
floating: &mut Vec<(String, [f32; 2])>,
hidden: &HiddenPanels,
dock_expanded: bool,
icon_groups: Option<&[Vec<String>]>,
) -> bool {
let Some(old) = docking.as_ref().filter(|layout| panel_docking::dock_only(layout)) else { return true };
*docking = panel_docking::from_dock_only(old, hidden.keys(), dock_expanded, icon_groups);
if docking.is_some() {
// The converted layout holds the floating dock panels.
floating.retain(|(id, _)| crate::dock::float_only(id));
}
docking.is_some()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DesignApp, SavedWorkspace};
use crate::DesignApp;
use craft_ui::docking::{Column, Node, Side};
use serde_json::json;
fn populated() -> UiState {
@@ -100,14 +176,241 @@ mod tests {
fn unreadable_hidden_origin_does_not_discard_other_origins() {
let original = populated();
let mut saved = serde_json::to_value(&original).unwrap();
saved["dockingHidden"]["futurePanel"] = json!({"FutureLocation":{}});
saved["customWorkspaces"][0]["dockingHidden"]["futurePanel"] = json!(false);
saved["dockingHidden"].as_array_mut().unwrap().push(json!(["futurePanel", {"FutureLocation":{}}]));
saved["customWorkspaces"][0]["dockingHidden"].as_array_mut().unwrap().push(json!(false));
let loaded = UiState::from_preferences_json(&serde_json::to_vec(&saved).unwrap()).unwrap();
assert!(!original.docking_hidden.is_empty());
assert_eq!(loaded.docking_hidden, original.docking_hidden);
assert_eq!(loaded.custom_workspaces[0].docking_hidden, original.custom_workspaces[0].docking_hidden);
}
#[test]
fn unreadable_docking_is_reported_so_the_file_is_kept() {
let mut saved = serde_json::to_value(populated()).unwrap();
assert!(UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap().intact);
saved["customWorkspaces"][1]["docking"] = json!({"root":{"FutureLayout":{}}});
assert!(!UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap().intact);
}
/// The dock of a layout in the current shape: the canvas beside a dock at the end edge.
fn dock(layout: &Layout<String>) -> &[Column<String>] {
assert!(crate::panel_docking::valid(layout), "{layout:?}");
let Some(Node::Split { first, second, .. }) = &layout.root else { panic!("expected the canvas split: {layout:?}") };
assert!(matches!(&**first, Node::Tabs { panels, .. } if panels == &["canvas"]));
let Node::Dock { side: Side::End, columns } = &**second else { panic!("expected an end dock: {second:?}") };
columns
}
fn panels(column: &Column<String>) -> Vec<Vec<&str>> {
column.groups.iter().map(|g| g.panels.iter().map(String::as_str).collect()).collect()
}
/// Hand-written pre-trial `ui.json` files: each loads through the app's path with nothing lost.
fn load(saved: serde_json::Value) -> UiState {
UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).map(|read| read.ui).unwrap()
}
#[test]
fn classic_dock_file_loads_as_its_workspace_default_layout() {
let ui = load(json!({
"workspace": "Printing and Proofing", "dockTab": "layers", "dockExpanded": true, "openPanel": "swatches",
"floating": [["glyphs", [100.0, 120.0]]],
}));
assert!(ui.docking.is_none());
let layout = crate::panel_docking::default_layout(&ui);
let columns = dock(&layout);
assert_eq!(panels(&columns[0]), [vec!["properties", "pages", "layers"], vec!["swatches"]]);
assert_eq!(columns[0].groups[0].active, 2, "Layers stays in front");
assert!(!columns[0].iconic && columns[1].iconic);
assert!(columns[1].groups.iter().all(|g| !g.panels.iter().any(|p| p == "glyphs" || p == "swatches")));
assert_eq!(layout.floating.len(), 1);
assert_eq!(crate::panel_docking::window_groups(&layout.floating[0])[0].panels, ["glyphs"]);
assert_eq!(&layout.floating[0].rect[..2], &[100.0, 120.0]);
}
/// An `openPanel` that was the flyout open at quit is not docked below Properties.
#[test]
fn a_flyout_open_at_quit_is_not_docked() {
let ui = load(json!({
"workspace": "Essentials", "dockExpanded": true, "openPanel": "stroke",
"customWorkspaces": [
{"name": "Mine", "dockExpanded": true, "openPanel": "effects"},
{"name": "Proof", "dockExpanded": true, "openPanel": "swatches"},
],
}));
assert_eq!(ui.open_panel, None);
let layout = crate::panel_docking::default_layout(&ui);
assert_eq!(panels(&dock(&layout)[0]), [vec!["properties", "pages", "layers"]], "Stroke stays in its icon group");
assert_eq!(ui.custom_workspaces[0].open_panel, None);
assert_eq!(ui.custom_workspaces[1].open_panel.as_deref(), Some("swatches"), "saved from Printing and Proofing");
let ui = load(json!({"workspace": "Proof", "openPanel": "swatches", "customWorkspaces": [{"name": "Proof", "openPanel": "swatches"}]}));
assert_eq!(ui.open_panel.as_deref(), Some("swatches"), "the saved workspace's own panel");
}
#[test]
fn dock_only_layout_becomes_the_main_column() {
let ui = load(json!({
"dockExpanded": true,
"docking": {"root": {"Tabs": {"panels": ["properties", "pages", "layers", "swatches"], "active": 3}}, "floating": []},
"customWorkspaces": [{
"name": "Split", "dockExpanded": true,
"docking": {"root": {"Split": {"axis": "Horizontal", "size": {"Ratio": 0.5},
"first": {"Tabs": {"panels": ["links"], "active": 0}},
"second": {"Split": {"axis": "Vertical", "size": {"Ratio": 0.5},
"first": {"Tabs": {"panels": ["properties", "pages"], "active": 1}},
"second": {"Stack": {"entries": [
{"panel": "layers", "open": true, "height": null},
{"panel": "swatches", "open": false, "height": 120.0}]}}}}}},
"floating": []},
}],
}));
let columns = dock(ui.docking.as_ref().unwrap());
assert_eq!(panels(&columns[0]), [vec!["properties", "pages", "layers", "swatches"]]);
assert_eq!(columns[0].groups[0].active, 3);
assert!(!columns[0].iconic);
let icons = &columns[1];
assert!(icons.iconic && icons.groups.iter().any(|g| g.panels == ["stroke", "color", "gradient"]));
assert!(icons.groups.iter().all(|g| !g.panels.iter().any(|p| p == "swatches")), "each panel appears once");
// Splits: a vertical split stacks groups in one column, a horizontal one adds a column
// (the dock lists columns from the window edge inwards).
let columns = dock(ui.custom_workspaces[0].docking.as_ref().unwrap());
assert_eq!(panels(&columns[0]), [vec!["properties", "pages"], vec!["layers"], vec!["swatches"]]);
assert_eq!(columns[0].groups[0].active, 1);
assert!(columns[0].groups[2].minimized && columns[0].groups[2].height == Some(120.0), "a closed accordion entry stays minimized");
assert_eq!(panels(&columns[1]), [vec!["links"]]);
assert!(columns[2].iconic);
}
#[test]
fn icon_groups_become_the_iconic_column_in_order() {
let ui = load(json!({
"dockExpanded": true,
"iconGroups": [["pages", "layers"], ["properties", "swatches", "color"]],
"docking": {"root": {"Tabs": {"panels": ["properties"], "active": 0}}, "floating": []},
}));
let columns = dock(ui.docking.as_ref().unwrap());
assert_eq!(columns.len(), 2);
assert_eq!(panels(&columns[0]), [vec!["properties"]]);
assert!(columns[1].iconic);
assert_eq!(panels(&columns[1]), [vec!["pages", "layers"], vec!["swatches", "color"]]);
}
#[test]
fn collapsed_dock_makes_the_main_column_iconic() {
let ui = load(json!({
"dockExpanded": false,
"docking": {"root": {"Tabs": {"panels": ["properties", "pages", "layers"], "active": 0}}, "floating": []},
}));
let columns = dock(ui.docking.as_ref().unwrap());
assert_eq!(panels(&columns[0]), [vec!["properties", "pages", "layers"]]);
assert!(columns.iter().all(|c| c.iconic));
}
#[test]
fn floating_windows_and_hidden_places_survive_the_migration() {
let saved = json!({
"dockExpanded": true,
"floating": [["glyphs", [40.0, 60.0]], ["character", [40.0, 60.0]], ["tabs", [10.0, 20.0]]],
"docking": {
"root": {"Tabs": {"panels": ["properties", "pages", "layers"], "active": 0}},
"floating": [{"panels": ["glyphs", "character"], "active": 1, "rect": [40.0, 60.0, 300.0, 400.0]}],
},
"dockingHidden": {"color": {"floating": null, "anchor": "layers", "placement": {"Split": "Bottom"},
"size": {"Ratio": 0.5}, "stack": null}},
});
let read = UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap();
assert!(read.intact, "converting is not a loss");
let ui = read.ui;
let layout = ui.docking.clone().unwrap();
let columns = dock(&layout);
assert_eq!(layout.floating.len(), 1);
assert_eq!(crate::panel_docking::window_groups(&layout.floating[0])[0].panels, ["glyphs", "character"]);
assert_eq!(crate::panel_docking::window_groups(&layout.floating[0])[0].active, 1);
assert_eq!(layout.floating[0].rect, [40.0, 60.0, 300.0, 400.0]);
assert_eq!(ui.floating, [("tabs".to_string(), [10.0, 20.0])], "the layout holds the floating dock panels");
assert!(ui.docking_hidden.contains_key("color") && !layout.contains(&"color".to_string()), "a closed panel stays closed");
assert!(columns[1].groups.iter().all(|g| g.panels != ["glyphs"]));
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.ui = ui;
app.run("window.panel", json!({"panel": "color"})).unwrap();
assert!(app.ui.docking.as_ref().unwrap().contains(&"color".to_string()));
assert!(!app.ui.docking_hidden.contains_key("color"));
// Saved again, the file is in the current shape and loads unchanged.
let again = UiState::from_preferences_json(&serde_json::to_vec(&app.ui).unwrap()).unwrap();
assert_eq!(again.docking, app.ui.docking);
}
/// Floating windows saved as a stack of groups (`groups`, before floating windows held split
/// trees) load as a vertical split of the groups in order, sized by their heights; a window
/// whose groups were all minimized loads minimized.
#[test]
fn floating_stacks_of_groups_load_as_split_windows() {
let mut saved = serde_json::to_value(UiState::default()).unwrap();
let mut docking = serde_json::to_value(crate::panel_docking::default_layout(&UiState::default())).unwrap();
docking["floating"] = json!([
{"groups": [
{"panels": ["swatches", "color"], "active": 1, "minimized": false, "height": 300.0},
{"panels": ["stroke"], "active": 0, "minimized": false, "height": 100.0},
], "rect": [400.0, 100.0, 280.0, 440.0]},
{"groups": [{"panels": ["glyphs"], "active": 0, "minimized": true, "height": null}], "rect": [40.0, 60.0, 300.0, 400.0]},
]);
docking["root"]["Split"]["second"]["Dock"]["columns"][1]["groups"] = json!([{"panels": ["effects"], "active": 0}]);
saved["docking"] = docking;
let read = UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap();
assert!(read.intact);
let layout = read.ui.docking.clone().unwrap();
dock(&layout);
let Node::Split { axis: craft_ui::layout::SplitAxis::Vertical, size: craft_ui::layout::SplitSize::Ratio(ratio), first, second } =
&layout.floating[0].root
else {
panic!("expected a vertical split: {:?}", layout.floating[0]);
};
assert!((ratio - 0.75).abs() < 1e-4, "{ratio}");
assert_eq!(**first, Node::Tabs { panels: vec!["swatches".into(), "color".into()], active: 1 });
assert_eq!(**second, Node::Tabs { panels: vec!["stroke".into()], active: 0 });
assert_eq!(layout.floating[0].rect, [400.0, 100.0, 280.0, 440.0]);
assert!(!layout.floating[0].minimized && layout.floating[1].minimized);
let again = UiState::from_preferences_json(&serde_json::to_vec(&read.ui).unwrap()).unwrap();
assert_eq!(again.docking, read.ui.docking);
}
/// An old layout that cannot be converted gives way to the workspace's default and is
/// reported, so the file it came from is kept.
#[test]
fn an_old_layout_that_cannot_be_converted_is_reported() {
let saved = json!({"docking": {"root": {"Tabs": {"panels": ["properties", "properties"], "active": 0}}, "floating": []}});
let read = UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap();
assert!(!read.intact);
assert_eq!(read.ui.docking, None);
}
/// `dockingHidden` as an object keyed by panel (files before craft-ui's `Workspace::hidden`
/// list) loads as the list, drops only its unreadable entries and saves as the list.
#[test]
fn hidden_panels_keyed_by_panel_load_as_the_workspace_list() {
let place = json!({"floating": null, "anchor": "layers", "placement": {"Split": "Bottom"}, "size": {"Ratio": 0.5}, "stack": null});
let saved = json!({
"dockingHidden": {"color": place, "swatches": place, "futurePanel": {"FutureLocation": {}}},
"customWorkspaces": [{"name": "Old", "dockingHidden": {"stroke": place}}],
});
let read = UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap();
assert!(!read.intact, "the unreadable entry is reported so the file is kept");
let ui = read.ui;
assert_eq!(ui.docking_hidden.keys().collect::<Vec<_>>(), ["color", "swatches"]);
assert_eq!(ui.custom_workspaces[0].docking_hidden.keys().collect::<Vec<_>>(), ["stroke"]);
let written = serde_json::to_value(&ui).unwrap();
assert_eq!(written["dockingHidden"][0][0], "color");
let workspace: craft_ui::docking::Workspace<String> =
serde_json::from_value(json!({"layout": {"root": null, "floating": []}, "hidden": written["dockingHidden"]})).unwrap();
assert_eq!(workspace.hidden, ui.docking_hidden.0, "the same shape as craft-ui's Workspace::hidden");
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.ui = ui;
app.run("window.panel", json!({"panel": "color"})).unwrap();
assert!(app.ui.docking.as_ref().unwrap().contains(&"color".to_string()));
assert!(!app.ui.docking_hidden.contains_key("color"));
}
#[test]
fn unrelated_malformed_preferences_are_still_rejected() {
assert!(UiState::from_preferences_json(br#"{"uiScale":"invalid","docking":false}"#).is_err());
+113 -21
View File
@@ -35,7 +35,8 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser
| `engine.execute` / `ui.menu.invoke` | `{command, params}` | Run any engine or UI command (see `engine.commands`). `ui.menu.invoke` without `params` acts like choosing the menu item: a command labelled "…" that takes parameters opens its dialog. A key the command's params description doesn't name is an error (`unknown parameter …`), not ignored |
| `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled |
| `document.inspect` | — | Pages, spreads, items, stories (overset), styles, swatches, selection |
| `ui.inspect` | — | Tool, UI state, view (zoom/origin), canvas rect, perf |
| `ui.inspect` | — | Tool, UI state, view (zoom/origin), canvas rect, panel layout (`docking`), perf |
| `ui.docking` | — | The panel layout in use, the focused panel and the docking hit rects (see Shared panel docking) |
| `ui.menu.list` / `ui.tool.list` | — | Menu tree / Tools panel groups |
| `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) |
| `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas", clicks?}], mods?}` | Drive the active tool through the same code path as the mouse; `clicks` on a `down` is the press count (3 = triple click); returns the selection, the tool and its cursor at the last event (e.g. `LoadedText`, `ThreadLink`, `Unthread`) |
@@ -74,36 +75,127 @@ to finish before retrying a request that would exceed the pending-event limit.
## Shared panel docking
Panel arrangements support tab grouping and reordering, horizontal and vertical splits,
and movable, resizable floating groups inside the application window. Drag a panel tab,
or use its context menu. Floating groups remain within the application viewport.
and movable, resizable floating windows. A floating window holds tab groups split side by side
or one above another, like the dock area. Drag a panel tab, or use its context menu. On the
desktop each floating window is a window of its own; on the web floating windows stay within
the application viewport.
The shared layout begins with the existing workspace on the first docking operation.
Workspace saves retain its groups, active tabs, split sizes, floating geometry, and the
positions of hidden panels. Resetting a built-in workspace restores its default dock.
The window is one panel layout: the protected `canvas` panel (the document area) beside a
dock at the logical end edge, the right in left-to-right languages and the left in
right-to-left ones. Each built-in workspace has a default layout. Essentials and most others
have an outer column with the Properties · Pages · Layers group and an iconic column with the
other panels; Essentials Classic and Typography have their named iconic groups only. A panel a
workspace opens (Printing and Proofing: Swatches) is a group below Properties · Pages · Layers.
The `canvas` panel never moves, floats, closes or takes tabs; a panel or tab group dropped
over it floats there. Workspace saves retain its groups, active tabs, split sizes, floating
geometry, and the positions of hidden panels. Resetting a built-in workspace restores its
default dock. Preferences saved before the canvas joined the layout load with nothing lost:
the classic dock becomes the workspace's default layout, and a customized dock-only layout
becomes the dock's expanded columns (iconic if the dock was collapsed), with the remaining
panels (or the workspace's named groups) in the iconic column and its floating windows and
hidden-panel positions kept. A `ui.json` the app cannot read in full is copied to
`ui.unreadable.json` before it is saved again.
- `window.panel.move {panel, anchor, zone?, before?}` moves a panel into the target group.
`zone` is `tab` (default), `center`, `left`, `right`, `top`, or `bottom`; `before` reorders
tabs by panel ID. The destination must already be visible.
- `window.panel.float {panel, x?, y?, width?, height?}` detaches a panel.
- `window.panel.dock {panel, anchor?, zone?, before?}` returns a panel to its previous
dock placement when possible, or to the supplied destination.
- `window.panel.activate {panel}` reveals and selects a panel, restoring its saved
position when possible. `window.panel.close {panel}` hides it.
The `window.panel.*`, `window.workspace.*` and `ui.dock` commands are craft-ui's shared
docking commands (`DockCommand`), with the same ids and params in every craft app. `panel`,
`anchor`, `target` and `before` take a panel ID or its English name in any case; `onto` is
read as `anchor`.
- `window.panel.move {panel, anchor, zone?, before?, group?, placement?}` moves a panel into
the target group. `zone` is `tab` (default), `center`, `left`, `right`, `top`, `bottom`,
`above` or `below`; `before` reorders tabs by panel ID (`null` appends). Beside a panel in a
dock column, `top` and `bottom` add a group above or below its group and `left` and `right`
add a column on that side; in a floating window, `top` and `bottom` add a group and `left`
and `right` are errors (craft-ui's `Placement::for_zone`). `group: true` moves the panel's
whole tab group; `placement` is a serialized craft-ui `Placement` and takes precedence over
`zone` and `before`.
- `window.panel.float {panel, x?, y?, width?, height?, group?, column?}` (and
`window.floatPanel`) detaches a panel, its tab group (`group`) or its dock column
(`column`). Without a position it floats 24 points inside the dock area's top-left corner,
clear of the menu bar; a panel floats at its own size and a tab group or column at the size
a drag would float it at. Given values replace these. A panel already alone in a floating
window moves or resizes, and a tab group that is already a whole floating window keeps its
rect unless the params change it.
- `window.panel.dock {panel, anchor?, zone?, before?, group?, column?, side?}` (and
`window.dockPanel`) docks beside `anchor`, or in a new column of the dock at `side`
(`Start` or `End`; a new dock when there is none). A panel that already leads that dock's
last column stays where it is. `zone` places the panel beside `anchor`, so a `zone` without
`anchor` or `side` is an error. Without `anchor` and `side`, a panel that floated from
the dock goes back to that place, and another floating panel joins the first dock group. For
the float-only Tabs panel (`tabs`), which has its own window outside the layout, float opens
that window and dock closes it.
- `window.panel.activate {panel}` (and `window.panel {panel}`) reveals and selects a panel,
restoring its saved position when possible; a panel in an iconic column opens as a flyout
beside its icon. `window.panel.close {panel}` hides it.
- `window.panel.closeGroup {panel}` hides the panel and the panels tabbed with it, and
`window.panel.closeOthers {panel}` hides the panels tabbed with it; the result lists them in
`closed`.
- `window.panel.minimize {panel, minimized?}` minimizes the panel's group to its tab strip and
`window.panel.iconic {panel, iconic?}` collapses its dock column to icons; without the flag
they toggle.
- `window.workspace.new {name}`, `window.workspace.delete {name}` and `window.workspace.reset`
(also `ui.dock.reset`) are `window.newWorkspace`, `window.deleteWorkspace` and
`window.resetWorkspace`. `window.workspace {name}` switches workspaces as before.
- `ui.dock {action}` or `ui.dock {operation, …}` is the shape LightCraft, PDFCraft, CADCraft
and SoundCraft use: `open`, `close`, `activate`, `float {rect}`, `moveFloating {rect}`,
`move {target, zone?, before?}`, `setStackOpen {open}`, `resizeStack {height}` and
`resizeSplit {path, size}`, with `rect` as `[x, y, width, height]`.
- `window.panel.window` (a panel into or out of its own window) and `window.workspace.lock` are
not available in DesignCraft and return an error.
- `window.collapseToIcons` collapses every dock column to icons.
Panel IDs are stable across themes. Invalid IDs, non-finite or invalid geometry, and
invalid destinations return an error without changing the arrangement.
`window.panel.layout {action}` also accepts the shared, externally tagged action schema.
For example, `{"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}}`
updates a floating group's position and size; `{"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}}`
resizes the root divider (`false`/`true` path entries select first/second children).
updates a floating window's position and size; `{"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}}`
resizes the root divider (`false`/`true` path entries select first/second children), and
`{"action":{"ResizeWindowSplit":{"panel":"swatches","path":[],"size":{"Ratio":0.6}}}}` resizes
a divider in the floating window that holds `panel`, by its path from the window's root.
Floating windows saved as a stack of groups (`{"groups": […], "rect": […]}`) load as groups
one above another, sized by their saved heights.
`Move` with `placement: {"Tab":{"before":"swatches"}}` reorders tabs, and
`SetStackOpen {panel,open}` / `ResizeStack {panel,height}` control accordion entries.
The UI dispatches these same commands. Unknown panel IDs, invalid geometry, and stale
split paths return an error without changing the workspace.
After panel customization, `app.tablePanel` (including Shift+F9) still toggles the
Table panel. `ui.set` with `closePanel` closes the last explicitly opened panel;
`dockExpanded:false` hides the root dock while floating groups remain visible.
The icon rail can reopen a collapsed dock through any root panel. Activating a
floating panel also raises its group. These operations change only UI layout.
`app.tablePanel` (including Shift+F9) toggles the Table panel. `ui.set` with `closePanel`
closes the last explicitly opened panel; `dockExpanded:false` collapses every dock column
to icons and `dockExpanded:true` expands the outer column. Activating a floating panel also
raises its group. These operations change only UI layout.
Clicking an icon in an iconic column opens its group as a flyout beside the column, on the
canvas side; clicking the icon again closes it. With Auto-Collapse Iconic Panels on (the
default; a column bar's menu toggles it), Escape or a click outside the flyout closes it too.
`ui.docking` returns the panel layout in use; `ui.inspect` includes the same object as
`docking`:
```json
{"layout": {"root": {"Split": {"axis": "Horizontal", "size": {"FixedSecond": 297.0},
"first": {"Tabs": {"panels": ["canvas"], "active": 0}},
"second": {"Dock": {"side": "End", "columns": [
{"groups": [{"panels": ["properties", "pages", "layers"], "active": 0}], "width": 260.0,
"iconic": false, "iconic_width": 37.0},
{"groups": [{"panels": ["stroke"], "active": 0}, …], "width": 260.0, "iconic": true,
"iconic_width": 37.0}]}}}},
"floating": []},
"customized": false, "focused": "canvas", "flyout": null, "autoCollapse": true,
"rightToLeft": false, "hidden": [],
"hitRects": [{"hit": {"Tab": "properties"}, "rect": [983.0, 0.0, 74.0, 27.0]},
{"hit": {"DragBar": "properties"}, "rect": [1190.0, 0.0, 56.0, 27.0]},
{"hit": {"Icon": "stroke"}, "rect": [1243.0, 14.0, 37.0, 30.0]}, …]}
```
`layout` is craft-ui's serialized `Layout`; `customized` is false while the workspace's
default layout is in use; `focused` is the panel (or `canvas`) last clicked or selected, or
null; `flyout` is the panel whose iconic-column flyout is open (as drawn in the last frame,
or as a command that opens one sets it); `autoCollapse` is the
Auto-Collapse Iconic Panels setting; `hidden` lists closed panels that remember their place.
`hitRects` lists where each docking gesture starts in the last frame, from craft-ui's
`DockArea::hit_rects`: `hit` is a serialized craft-ui `Hit` (`Tab`, `DragBar`, `Border`,
`Overflow`, `ColumnBar`, `Collapse`, `StackToggle`, `Title`, `Close`, `Icon`, `IconGrip`,
`Resize` with its edge, or `Divider`), naming groups, columns and windows by their first panel;
`rect` is `[x, y, width, height]` in points. Drag from a rect's centre with `ui.drag`, or click
it with `ui.click`, to script a gesture.
+5 -5
View File
@@ -1,8 +1,8 @@
# Shared PDF backend
DesignCraft pins `craft-pdf`, patched Krilla 0.8.2 and the five Hayro reader crates (with
`hayro-cmap`) to craft-libs revision `9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef`. Existing `craft-ui` and `craft-print` revisions
remain unchanged. The reader migration is reviewed on top of the separate writer change.
DesignCraft pins `craft-pdf`, patched Krilla 0.8.2 and the six Hayro reader crates (with
`hayro-cmap` and `hayro-postscript`) to craft-libs revision `a0916808f7d78bed8c7b90cdf937176015454a7f`. `craft-ui`,
`craft-print` and the other craft-libs crates are pinned to the same revision. The reader migration is reviewed on top of the separate writer change.
## Writing
@@ -62,8 +62,8 @@ report known backend warnings; they do not certify all PDF fidelity.
DesignCraft uses a 512 MiB reader input bound, checked before parsing or copying.
Raster output has shared geometry/allocation bounds. Native unwind guards turn escaped
backend panics into errors; WASM aborts and process-wide allocation failures are not recoverable.
Application fonts, image assets, editing and export presets remain app-owned. Existing
craft-ui/craft-print versions are preserved. The reader migration builds on the independently qualified writer change.
Application fonts, image assets, editing and export presets remain app-owned. craft-ui and
craft-print are pinned separately. The reader migration builds on the independently qualified writer change.
The shared commit must be published before a fresh public checkout can resolve these pins.
No sibling checkout is required. Licences and existing backend asset notices are recorded in
+1 -1
View File
@@ -442,7 +442,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Pathfinder panel as InDesign's: Paths (Join Path `path.join`, Open/Close Path, Reverse Path), Pathfinder, Convert Shape, Convert Point (`path.convertPoints`: plain, corner, smooth, symmetrical).
- Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`.
- Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character).
- Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`.
- Floating panels: drag a panel's tab, or a group by its drag bar, off the dock (or choose Float Panel) into a movable floating window that resizes from every edge; Dock Panel (in its tab menu and ≡ menu) puts it back where it was; positions persist; `window.panel.float` / `window.panel.dock` (aliases `window.floatPanel` / `window.dockPanel`).
- Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). A swatch on a placed greyscale or bitmap image (IDML `<Image FillColor>`) colours it on screen and in PDF. New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing.
- View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough).
- OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes.
+2 -1
View File
@@ -4,7 +4,7 @@ use std::process::Command;
use serde_json::Value;
const REV: &str = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef";
const REV: &str = "a0916808f7d78bed8c7b90cdf937176015454a7f";
const PACKAGES: &[(&str, &str)] = &[
("craft-pdf", "0.1.0"),
("krilla", "0.8.2"),
@@ -13,6 +13,7 @@ const PACKAGES: &[(&str, &str)] = &[
("hayro-syntax", "0.7.2"),
("hayro-jbig2", "0.3.0"),
("hayro-cmap", "0.1.0"),
("hayro-postscript", "0.1.0"),
];
pub fn run(root: &Path) -> Result<(), String> {