diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index 0aeb9adb..b9f908c9 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "2692f1dfc21b9d7284ba69316085fb9a2f30dcb6d6f336401d7b9aa4b3fcf67a"
+sha256 = "5aedd14b5c652b54db8bfb00d32657d690b4601618de1d574919ff8fec161694"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "242e4c748a66ee6ff18d657ee3e1ef0808be0d5adc005e45723f93c835d142f8"
+sha256 = "1cf01b46973e74f2df8d3269e6569cf1264ebcca5351a569bd494e9cfcf86b91"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "c6c0dec458b50a15fd5a7b38ce9ec5772e38231c5db584ff9aecceb4955ce132"
+sha256 = "b243b1b0152d1236b99fc375fb42f5810d55786292d3b8d5d2393593a5789a5c"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "ef28211bc332cc811a786952e4b60ba7dafb8e9a8553d75e11c4f800ad71832e"
+sha256 = "e0b6b7d1441501b929f9df6409edda88283d00a4343a0627acdaa126e549286a"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "ca6d842ff9568adc5f8d8223f7791f075faf94845369bc2dfc8e52fe727f8b23"
+sha256 = "3528e2b18c73c038b0ffc4b364155827ddede69a8cecd5aa0b20bbe409598738"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "7c791c69d517bcb16c487217861428b7f6a53b96e3c496e2cc6a8710e7c18365"
+sha256 = "36b08deba87a7cb9c52a678d37f4f289bb291a0a097615bc1a9a77f2d8b44b2a"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "9030aef3c2790bb4dd1dda022c0d527fdce73a29bd4d1875edcbbaa95a3bf49e"
+sha256 = "832a8f15f8402d0bc09a40937fd47feffe7afb002d41c288878c3920996fbd4f"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "bd15f74a2630e97216f56ff329e8e8d7cea2eae341f875dd7804908ad13e20f8"
+sha256 = "c22c0153196e3e8f6a846e5a2f4210ffe393cc6997a32a4a8214bea222f98d56"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "da28f8082b39ee9cff48217f69b920edde7107d140d18d212d1d1f110c5178df"
+sha256 = "2edbaa97d218efa890043f780268f74ee6d4b4b56512523bbd050f447ced37df"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "cbb0332f3e85a5267fc0af033aa77430a35e83171c3e2e5dfe02bf103e4fc0ce"
+sha256 = "64b352359b9b3919e5f04fa0fafa44f9cf1d3759f1829522a754bf391a20bd60"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "b4b1cef29030b23e8a3b10dc743a4a6394c0353aaddfc783bde79f6cfe8d81c5"
+sha256 = "3afb05c1bfedaca890e3d79ff27640be200b2174d550ce0474103eb4c33b31fa"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "efba2c1d7652d730c716eded3972982b5f6c1f797be48f4232a0d2efb7b19451"
+sha256 = "a91e7153bc3208aafc7705601848666499ae2ac95dc4bd3b9a78ce749693c092"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "d6043524e622f7e00f272566f779530ff3dc5f4e5be93f442be79172df7d2f5f"
+sha256 = "65927f653a4b33944f981ecef78c2bd81a7840924804063581bd09af94beee22"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "86512da6fab7b0860336437398b7ff57e03cee9db78e13dfd28b8aa8fee957a2"
+sha256 = "d96bb503b25afc2f584f00253b371e8bd0266fa277a0b631d537377742c272cb"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "0dbeebeebfa940260e4355aa2741ba68a3aacba7a3d24d721981035fa0bef254"
+sha256 = "f33971aa4e215933494fe181b83ee102f6e30178a83bfc944d35f7e7824d7692"
[[asset]]
path = "assets/icons/accessibility.svg"
diff --git a/Cargo.lock b/Cargo.lock
index 3523ac1d..1ca3786b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1090,6 +1090,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "craft-ui"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
+dependencies = [
+ "egui",
+ "serde",
+]
+
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -3906,6 +3915,7 @@ dependencies = [
"arboard",
"base64 0.22.1",
"bytemuck",
+ "craft-ui",
"eframe",
"egui",
"egui_extras",
diff --git a/Cargo.toml b/Cargo.toml
index 75e4741c..69cb24ff 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,6 +12,8 @@ rust-version = "1.90"
repository = "https://github.com/storytold/pdfcraft"
[workspace.dependencies]
+# Shared frontend components; engines remain independent of egui.
+craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# internal
pdfcraft-geom = { path = "crates/geom", version = "0.5.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.5.0" }
diff --git a/README.md b/README.md
index da4256e9..29f6636d 100644
--- a/README.md
+++ b/README.md
@@ -314,8 +314,25 @@ pdfcraft-cli ui --control ~/.pdfcraft-control.json key key=K modifiers='["comman
pdfcraft-cli ui --control ~/.pdfcraft-control.json command id=comment.square # pick a tool, then draw:
pdfcraft-cli ui --control ~/.pdfcraft-control.json drag from='[400,300]' to='[600,420]'
pdfcraft-cli ui --control ~/.pdfcraft-control.json screenshot --out window.png
+pdfcraft-cli ui --control ~/.pdfcraft-control.json resize width=1200 height=800
+pdfcraft-cli ui --control ~/.pdfcraft-control.json focus label="Read"
+pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect focused=true
+pdfcraft-cli ui --control ~/.pdfcraft-control.json quit
```
+`ui.dock` arranges the `tools` and `inspector` panels; `canvas` is the protected document view.
+Each per-panel request requires a `panel` ID and an `operation`: `open`, `close`,
+`activate`, `float` with `rect: [x, y, width, height]`, or `move` with `target` and
+`zone: center|left|right|top|bottom`. For example,
+`{"operation":"float","panel":"tools","rect":[100,100,320,440]}` floats Tools.
+`ui.dock.reset` restores the default
+arrangement. The control channel accepts these methods directly; `ui.command` also accepts
+`{"id":"ui.dock","params":{...}}`. Saved preferences retain groups, active tabs, visibility
+and floating geometry. Invalid requests return an error without changing the document or layout.
+
+
+`resize` requests a native inner-window size in logical points (each dimension must be between 320 and 8192); the window manager may constrain it. `state` reports the observed `window.width`, `window.height` and `window.pixels_per_point`. `focus` accepts exactly one widget `id` or unique enabled, focusable `label`; subsequent `key` requests traverse or activate the actual widgets. `inspect` includes focusability and accepts a `focused` filter. `quit` takes no parameters: clean documents exit normally, while unsaved edits or typing open the existing Save/Don't save/Cancel prompt and return `quitting: false, needs_confirmation: true`. It never silently discards work. Window resizing and application quit are native-only.
+
---
## How it's built
@@ -534,3 +551,14 @@ Forks and modified versions must remove them.

Made by the ArtCraft team and community.
+
+Tools and Inspector panels can be dragged into tab groups, split beside the PDF, or floated within the app window. Read and full-screen modes keep their existing focused document view. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
+
+Advanced `ui.dock` operations use the same validated transaction as pointer gestures:
+`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and
+`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`),
+`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null.
+A center `move` can include `before` (panel ID or null to append) for tab order.
+The equivalent `{"action": ...}` form accepts a serialized shared docking action;
+this is also the form emitted by the renderer. Protected-canvas and app-specific panel
+rules apply equally to both forms.
diff --git a/ROADMAP.md b/ROADMAP.md
index 9b14cbf2..62b10704 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -97,7 +97,7 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
| M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches). Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel. |
-| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; late browser startup downloads preserve the current document and Save target (#171). |
+| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; reusable icon catalog/buttons with keyboard focus indication; late browser startup downloads preserve the current document and Save target (#171). |
| M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 15–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools. Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M6 | Forms + JavaScript | 160–320 | 45% | 95–185 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done. Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA |
@@ -153,10 +153,15 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
- **2026-10-09 (M14, #577):** The desktop GPU setup enables the selected adapter's supported 2D texture extent instead of egui-wgpu's fixed 8192-pixel extent. This removes the artificial surface ceiling behind a mixed-DPI Windows startup crash when opening Outlook PDF attachments. Synthetic regressions cover the observed 8600 × 3423 surface, downlevel adapters and preservation of other device limits. Hardware resolution limits still apply; milestone and overall effort estimates are unchanged.
- **2026-10-09 (M14, #577 follow-up):** A window larger than the GPU's texture limit no longer closes PdfCraft. Vendored egui-wgpu 0.36.2 fits the window's surface within `max_texture_dimension_2d` and draws egui at the matching scale, on every path that sizes a surface (start-up restore, resize, maximise, DPI change, a recreated surface); upstream is emilk/egui#8361. Root cause of the Outlook-only trigger: winit creates the window on the launching app's monitor and converts the restored size at that monitor's scale. Unit tests cover the 8600 × 3423 case and edge sizes; a Windows Sandbox (WARP) run of a window one pixel over the limit panicked before and kept drawing after. Estimates unchanged.
-- **2026-10-09 (M14, #577 follow-up):** A window larger than the GPU's texture limit no longer closes PdfCraft. Vendored egui-wgpu 0.36.2 fits the window's surface within `max_texture_dimension_2d` and draws egui at the matching scale, on every path that sizes a surface (start-up restore, resize, maximise, DPI change, a recreated surface); upstream is emilk/egui#8361. Root cause of the Outlook-only trigger: winit creates the window on the launching app's monitor and converts the restored size at that monitor's scale. Unit tests cover the 8600 × 3423 case and edge sizes; a Windows Sandbox (WARP) run of a window one pixel over the limit panicked before and kept drawing after. Estimates unchanged.
- **2026-10-09 (M14, #519):** A GPU that can't configure the window's surface at launch no longer panics, so PdfCraft's OpenGL retry runs. Vendored egui-wgpu configures a new window's surface inside wgpu error scopes (`winit::catch_errors`) and returns a failure as an error, so eframe returns `Error::Wgpu` and the existing OpenGL retry (#530) runs (a device lost during that configure still panics, on the first frame). Before, wgpu's default handler panicked: on a 2015 Intel GPU with no D3D12 driver wgpu picked the GL adapter over WARP and timed out in `Surface::configure` (`GpuWaitTimeout`). A real-device regression test (a wgpu validation error inside `catch_errors`) panicked with the scopes removed; in Windows Sandbox a forced configure failure panicked unpatched and retried with OpenGL patched (Sandbox has no OpenGL 2.0, so the OpenGL window itself was not shown there). Defaulting Windows to DX12 only (WARP instead of wgpu's GL backend) is left to the maintainers. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M5, Fill & Sign flatten on save):** Preferences and `doc_save` can bake Fill & Sign text, marks and signatures into the page when saving. The option defaults off. Other comments and form fields stay, a second flatten does not reuse an XObject name already on the page, and a document that cannot be changed is left unwritten. Overall estimate unchanged (about 30–35%).
+- **2026-10-09 (M3.9, native control lifecycle):** The opt-in UI control channel can request bounded window sizes, report observed dimensions, focus accessible widgets and inspect actual keyboard focus. Graceful quit uses the existing unsaved-work prompt, including uncommitted typing; force/discard parameters are rejected. Tests cover resize validation, Tab/Shift-Tab traversal, keyboard activation, invalid focus targets and quit cancellation with retained edits. Focused buttons preserve document undo/redo shortcuts, while text editors keep their own undo history. No milestone percentage change.
+
+- **2026-10-09 (UI.5, shared tab interaction):** Document and Combine tabs use revision-pinned `craft-ui` interaction, with accessible tab roles, selected state, keyboard activation and a theme-colored focus outline. Document IDs keep tab identity stable through dirty-label changes and earlier-tab removal. Existing labels, painting, horizontal scrolling and close requests stay in the frontend. No milestone percentage change.
+
+- **2026-10-09 (UI.2, shared icon buttons):** The frontend uses revision-pinned `craft-ui` for its SVG catalog and square icon buttons. Existing assets, fallback cache keys, dark/light colors, tool labels and commands remain app-owned. Buttons gain a visible keyboard-focus outline; the layer checker keeps the shared dependency at L7. No milestone percentage change.
+
- **2026-10-09 (M5.7, #434 partial):** Natural-size image stamps placed at a click or through `stamp_custom` remain upright on rotated pages, with a displayed-space-sized rectangle and a counterrotated standard appearance matrix. Move, resize, restyle and undo/redo preserve that orientation. Native and external Poppler save/reopen rendering cover all four quarter-turns; UI tests verify the displayed bounds. Explicit rectangle placement, image signatures, PDF-page stamps and image-file orientation metadata remain outside this repair. #434 stays open; milestone table and effort estimates remain unchanged.
- **2026-10-09 (M1, raw-stream decoding limits):** COS checks unfiltered decoded data against the caller and per-stream limits before copying it, including empty filter arrays and streams whose Crypt filter was applied on load. Strict decoding uses the same raw-data guard. Tiny synthetic regressions cover exceeded, exact and zero-byte limits while retaining the filtered decoder behavior. Milestone and overall effort estimates unchanged (about 30–35%).
@@ -337,3 +342,4 @@ Newest first. One line per session: the date, what moved, and the new overall pe
- **2026-09-30 (session 1):** planning complete; viewer vertical slice.
- M13: common command, batch, inspection and bounded preview tools share the existing confined automation path. MCP tools carry complete hints and reject unknown top-level keys as invalid parameters.
+- **2026-10-10 (UI docking):** Tools and Inspector can be regrouped, split beside the PDF, or floated inside the app window. Existing toggles reopen panels at their saved locations; UI commands arrange/reset panels, and preferences retain their layout. Read/full-screen modes preserve the focused document view. No milestone percentage change.
diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs
index f8f0ae9f..6489d80f 100644
--- a/crates/engine/src/commands.rs
+++ b/crates/engine/src/commands.rs
@@ -205,6 +205,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"),
c("view.read_mode", "Read mode", VIEW, Some(Shortcut { command: true, shift: false, mac_ctrl: true, key: "H" }), Document, "book-open"),
c("view.focus_page_input", "Go to page…", VIEW, Some(Shortcut::cmd_shift("N")), Document, "text-cursor-input"),
+ c("ui.dock.reset", "Reset panel layout", VIEW, None, Nothing, "settings"),
c("view.theme", "Switch light / dark theme", None, None, Nothing, "moon"),
c("view.theme.system", "Use system setting", None, None, Nothing, "settings"),
c("view.theme.light", "Light gray", None, None, Nothing, "sun"),
diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml
index f7f0406a..713e5b2a 100644
--- a/crates/ui-egui/Cargo.toml
+++ b/crates/ui-egui/Cargo.toml
@@ -4,11 +4,13 @@ description = "The egui shell of PdfCraft"
version.workspace = true
edition.workspace = true
license.workspace = true
+rust-version = "1.95"
[lints]
workspace = true
[dependencies]
+craft-ui = { workspace = true }
pdfcraft-fonts = { workspace = true }
pdfcraft-engine = { workspace = true }
pdfcraft-render = { workspace = true }
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index d81cf8c9..0591fe67 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -78,7 +78,19 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let (name, dirty) = (doc.display_name(), doc.dirty);
// A tab showing the document's title names its file on hover.
let file = (name != doc.name).then_some(doc.name.as_str());
- let response = tab(ui, &t, "file-text", &name, file, dirty, app.active == Some(i), &mut close, i, cap);
+ let response = tab(
+ ui,
+ ui.id().with(("document-tab", app.views[i].id)),
+ &t,
+ "file-text",
+ &name,
+ file,
+ dirty,
+ app.active == Some(i),
+ &mut close,
+ i,
+ cap,
+ );
if changed && app.active == Some(i) && !app.combine_showing() {
response.scroll_to_me(Some(Align::Center));
}
@@ -92,8 +104,19 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
if app.combine_tab.open {
// After the document tabs; its index can't clash with theirs.
let mut close = None;
- let response =
- tab(ui, &t, "files", tl!("Combine files"), None, false, app.combine_showing(), &mut close, usize::MAX, cap);
+ let response = tab(
+ ui,
+ ui.id().with("combine-tab"),
+ &t,
+ "files",
+ tl!("Combine files"),
+ None,
+ false,
+ app.combine_showing(),
+ &mut close,
+ usize::MAX,
+ cap,
+ );
if changed && app.combine_showing() {
response.scroll_to_me(Some(Align::Center));
}
@@ -212,6 +235,7 @@ fn tab_cap(natural: &[f32], budget: f32, gap: f32, min: f32) -> Option {
#[allow(clippy::too_many_arguments)]
fn tab(
ui: &mut egui::Ui,
+ id: egui::Id,
t: &Tokens,
icon: &str,
name: &str,
@@ -235,21 +259,22 @@ fn tab(
let label = crate::bidi::visual(&label).into_owned();
let text_w = ui.fonts_mut(|f| f.layout_no_wrap(label.clone(), font.clone(), t.text).size().x);
let width = cap.map_or(text_w + TAB_CHROME, |cap| (text_w + TAB_CHROME).min(cap));
- let (rect, resp) = ui.allocate_exact_size(vec2(width, 30.0), Sense::click());
+ let (rect, _) = ui.allocate_exact_size(vec2(width, 30.0), Sense::hover());
let a11y = if dirty { format!("{name} (edited)") } else { name.to_string() };
- resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, active, &a11y));
- let bg = if active {
- t.chrome
- } else if resp.hovered() {
- t.hover
- } else {
- Color32::TRANSPARENT
- };
- ui.painter().rect_filled(rect, CornerRadius { nw: 7, ne: 7, sw: 0, se: 0 }, bg);
- icons::paint(ui, Rect::from_min_size(rect.min + vec2(6.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, if active { t.accent } else { t.text_muted });
- ui.painter().text(rect.min + vec2(28.0, rect.height() / 2.0), Align2::LEFT_CENTER, label, font, if active { t.text } else { t.text_muted });
+ let resp = craft_ui::tabs::Tab::new(id, &a11y, active).focus_stroke(Stroke::new(1.0, t.accent)).show_at(ui, rect, |ui, resp| {
+ let bg = if active {
+ t.chrome
+ } else if resp.hovered() {
+ t.hover
+ } else {
+ Color32::TRANSPARENT
+ };
+ ui.painter().rect_filled(rect, CornerRadius { nw: 7, ne: 7, sw: 0, se: 0 }, bg);
+ icons::paint(ui, Rect::from_min_size(rect.min + vec2(6.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, if active { t.accent } else { t.text_muted });
+ ui.painter().text(rect.min + vec2(28.0, rect.height() / 2.0), Align2::LEFT_CENTER, label, font, if active { t.text } else { t.text_muted });
+ });
let x_rect = Rect::from_center_size(rect.right_center() - vec2(16.0, 0.0), vec2(20.0, 20.0));
- let x = ui.interact(x_rect, ui.id().with(("tabclose", index)), Sense::click());
+ let x = ui.interact(x_rect, id.with("close"), Sense::click());
if x.hovered() {
ui.painter().rect_filled(x_rect, CornerRadius::same(4), t.pressed);
}
@@ -419,7 +444,7 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 4.0;
let mut rail_button = |ui: &mut egui::Ui, panel: RightPanel, icon: &str, tip: &str, has: bool| {
- let selected = app.right == Some(panel);
+ let selected = app.right == Some(panel) && (app.mode == Mode::Read || app.docking.exposed(crate::docking::Panel::Inspector));
let r = icons::button(ui, icon, 34.0, selected, tl!(tip));
if has && !selected {
let c = r.rect.right_top() + vec2(-8.0, 8.0);
diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs
index 90f8050d..ba77f0d5 100644
--- a/crates/ui-egui/src/commands.rs
+++ b/crates/ui-egui/src/commands.rs
@@ -124,6 +124,11 @@ impl PdfCraftApp {
let active = self.active;
let targets = active.map(|i| self.views[i].target_pages()).unwrap_or_default();
match id {
+ "ui.dock.reset" => {
+ if let Err(error) = crate::docking::command(self, true, &serde_json::Value::Null) {
+ self.notify(error);
+ }
+ }
"file.open" => self.open_dialog(),
"file.open_recent" => match self.recent.first().map(|r| r.path.clone()) {
// The palette runs commands without a submenu: open the most recent file.
@@ -235,7 +240,7 @@ impl PdfCraftApp {
self.quick_tool = crate::QuickTool::Comment(tool);
// Acrobat opens the Comments panel with the commenting tools, unless the user closed it.
if self.right.is_none() && !self.comments_panel_closed {
- self.right = Some(RightPanel::Comments);
+ self.choose_right_panel(Some(RightPanel::Comments));
}
// A text selection made before picking a markup tool is marked right away.
if let (Some(kind), Some(i)) = (tool.markup(), active)
@@ -251,7 +256,7 @@ impl PdfCraftApp {
}
}
}
- "form.fields" => self.right = Some(RightPanel::Fields),
+ "form.fields" => self.choose_right_panel(Some(RightPanel::Fields)),
"comment.flatten" => {
self.apply_edit(Edit::Flatten { comments: true, fields: false });
}
@@ -341,7 +346,7 @@ impl PdfCraftApp {
"edit.edit_text" => {
self.quick_tool = crate::QuickTool::EditText;
self.left = crate::LeftPanel::Tool("edit");
- self.left_open = true;
+ self.reveal_tools();
self.notify_tr("Click text or an image to edit it");
}
"edit.advanced_search" => {
@@ -349,7 +354,7 @@ impl PdfCraftApp {
let f = self.views[i].find.get_or_insert_with(Default::default);
f.in_panel = true;
f.focus = true;
- self.right = Some(crate::RightPanel::Search);
+ self.choose_right_panel(Some(crate::RightPanel::Search));
}
}
"a11y.report" => self.show_accessibility_report(),
@@ -359,7 +364,7 @@ impl PdfCraftApp {
"edit.text" => {
self.quick_tool = crate::QuickTool::AddText;
self.left = crate::LeftPanel::Tool("edit");
- self.left_open = true;
+ self.reveal_tools();
}
"edit.image" => self.add_image_dialog(),
"edit.link" => {
@@ -373,7 +378,7 @@ impl PdfCraftApp {
"redact.mark" => {
self.quick_tool = crate::QuickTool::Redact;
self.left = crate::LeftPanel::Tool("redact");
- self.left_open = true;
+ self.reveal_tools();
// A text selection made first is marked right away.
if let Some(i) = active
&& let Some(doc) = self.session.get(self.views[i].id)
@@ -424,7 +429,7 @@ impl PdfCraftApp {
"comment.import" | "form.import_data" => self.import_data_dialog(),
"comment.stamp" => {
self.left = crate::LeftPanel::Tool("stamp");
- self.left_open = true;
+ self.reveal_tools();
}
"comment.export" => self.export_data_dialog(true, false),
"comment.summarize" => self.dialog = Some(Dialog::SummarizeComments),
@@ -440,9 +445,9 @@ impl PdfCraftApp {
"sign.validate" => {
let certs = self.session.trusted_certificates().to_vec();
self.session.set_trusted_certificates(certs);
- self.right = Some(RightPanel::Signatures);
+ self.choose_right_panel(Some(RightPanel::Signatures));
}
- "sign.panel" => self.right = Some(RightPanel::Signatures),
+ "sign.panel" => self.choose_right_panel(Some(RightPanel::Signatures)),
"optimize.advanced" => self.dialog = Some(Dialog::Optimize),
"view.fit_visible" => {
if let Some(i) = self.active
@@ -475,8 +480,8 @@ impl PdfCraftApp {
"export.rtf" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Rtf),
"form.prepare" => {
self.left = crate::LeftPanel::Tool("form");
- self.left_open = true;
- self.right = Some(RightPanel::Fields);
+ self.reveal_tools();
+ self.choose_right_panel(Some(RightPanel::Fields));
// Like Acrobat, a document without fields gets them detected on the way in.
if let Some((_, id)) = self.active_ids()
&& self.session.get(id).is_some_and(|d| d.form.is_empty() && d.editable())
@@ -508,7 +513,7 @@ impl PdfCraftApp {
let Some(tool) = crate::prepare::FieldTool::from_command(field) else { return false };
self.quick_tool = crate::QuickTool::Field(tool);
self.left = crate::LeftPanel::Tool("form");
- self.left_open = true;
+ self.reveal_tools();
if let Some(i) = active {
self.views[i].forms.focus = None;
}
@@ -575,7 +580,9 @@ impl PdfCraftApp {
/// Run the registered keyboard shortcuts (more specific combinations first).
pub(crate) fn registry_shortcuts(&mut self, ctx: &egui::Context) {
use egui::{Key, KeyboardShortcut, Modifiers};
- let typing = ctx.egui_wants_keyboard_input();
+ // Buttons also own keyboard focus for Tab/Enter activation, but they must not
+ // suppress document shortcuts. Text editors retain their own undo/redo handling.
+ let typing = ctx.text_edit_focused();
// A form field's editor is open on the page: its text isn't in the document until
// committed. (Not egui's keyboard focus: an Escape in this frame has already cleared that,
// while the field has yet to see the Escape and discard its draft.)
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index 4e80e52d..b335db5d 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -13,11 +13,16 @@
//!
//! Methods (JSON in, JSON out):
//! - `ui.state`: open documents, active document, page, zoom, page errors, mode, panels, dialog,
-//! notice.
-//! - `ui.inspect {query?, role?, limit?}`: widgets in tree order with `id` (a string), `role`, `label`,
-//! `value`, `rect` (points), `enabled`, `toggled`, `selected`, `clickable`, `depth`.
+//! notice and observed window dimensions in logical points.
+//! - `ui.inspect {query?, role?, focused?, limit?}`: widgets in tree order with `id` (a string), `role`, `label`,
+//! `value`, `rect` (points), `enabled`, `toggled`, `selected`, `clickable`, `focusable`, `focused`, `depth`.
//! - `ui.click {id}` | `{label}` | `{x, y, button?}`: click a widget (by its AccessKit action) or a
//! point (`button`: primary, secondary, or middle).
+//! - `ui.focus {id}` | `{label}`: focus one enabled widget through its AccessKit action.
+//! - `ui.resize {width, height}`: request a native window size (320–8192 logical points per dimension).
+//! The window manager may constrain it; `ui.state.window` reports the observed result.
+//! - `ui.quit {}`: normal native exit, or `quitting: false, needs_confirmation: true` and the
+//! existing save prompt when any document has unsaved edits or typing. No discard parameter.
//! - `ui.move {x, y}`: move the pointer without pressing a button (e.g. latched autoscroll).
//! - `ui.drag {from: [x, y], to: [x, y], steps?, modifiers?, button?}`: press, move and release (drawing
//! comments, selecting text, moving comments). `ui.state` reports `pages_on_screen` to aim at.
@@ -172,9 +177,11 @@ pub fn attach(ctx: &egui::Context) -> (Control, ControlClient) {
pub(crate) trait Host {
fn state(&self) -> Value;
fn command(&mut self, id: &str) -> Reply;
+ fn dock(&mut self, reset: bool, params: &Value) -> Reply;
fn commands(&self) -> Value;
fn set(&mut self, key: &str, value: &str) -> Reply;
fn open(&mut self, path: &str) -> Reply;
+ fn quit(&mut self, ctx: &egui::Context) -> bool;
}
impl Control {
@@ -265,8 +272,15 @@ impl Control {
fn handle(&mut self, ctx: &egui::Context, host: &mut impl Host, method: &str, p: &Value) -> Handled {
let str_param = |k: &str| p.get(k).and_then(Value::as_str).ok_or_else(|| format!("{method}: missing string parameter {k}"));
let r: Result = (|| match method {
- "ui.state" => Ok(Handled::Now(Ok(host.state()))),
+ "ui.state" => {
+ let mut state = host.state();
+ let size = ctx.input(|i| i.viewport_rect().size());
+ state["window"] = json!({ "width": size.x, "height": size.y, "pixels_per_point": ctx.pixels_per_point() });
+ Ok(Handled::Now(Ok(state)))
+ }
"ui.commands" => Ok(Handled::Now(Ok(host.commands()))),
+ "ui.dock" | "ui.dock.reset" => Ok(Handled::Now(host.dock(method == "ui.dock.reset", p))),
+ "ui.command" if str_param("id")? == "ui.dock" => Ok(Handled::Now(host.dock(false, p.get("params").unwrap_or(p)))),
"ui.command" => Ok(Handled::Now(host.command(str_param("id")?))),
"ui.set" => {
let value = match p.get("value") {
@@ -279,6 +293,32 @@ impl Control {
"ui.open" => Ok(Handled::Now(host.open(str_param("path")?))),
"ui.inspect" => Ok(Handled::Now(Ok(self.inspect(p)))),
"ui.click" => self.click(p),
+ "ui.focus" => self.focus(p),
+ "ui.resize" => {
+ only_params(p, method, &["width", "height"])?;
+ let dimension = |key: &str| -> Result {
+ p.get(key)
+ .and_then(Value::as_f64)
+ .filter(|n| n.is_finite() && (320.0..=8192.0).contains(n))
+ .map(|n| n as f32)
+ .ok_or_else(|| format!("ui.resize: {key} must be between 320 and 8192 logical points"))
+ };
+ let size = egui::vec2(dimension("width")?, dimension("height")?);
+ if cfg!(target_arch = "wasm32") {
+ return Err("ui.resize needs a native window".into());
+ }
+ ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
+ ctx.request_repaint();
+ Ok(Handled::AfterFrames(2, json!({ "requested": [size.x, size.y] })))
+ }
+ "ui.quit" => {
+ only_params(p, method, &[])?;
+ if cfg!(target_arch = "wasm32") {
+ return Err("ui.quit needs a native window".into());
+ }
+ let quitting = host.quit(ctx);
+ Ok(Handled::Now(Ok(json!({ "quitting": quitting, "needs_confirmation": !quitting }))))
+ }
"ui.drag" => self.drag(p),
"ui.move" => {
let point = |key: &str| -> Result {
@@ -312,7 +352,7 @@ impl Control {
Ok(Handled::Screenshot(region))
}
other => Err(format!(
- "unknown method {other:?} (ui.state, ui.inspect, ui.click, ui.move, ui.drag, ui.type, ui.key, ui.command, ui.commands, ui.set, ui.open, ui.screenshot)"
+ "unknown method {other:?} (ui.state, ui.inspect, ui.click, ui.focus, ui.resize, ui.quit, ui.move, ui.drag, ui.type, ui.key, ui.command, ui.commands, ui.set, ui.open, ui.screenshot)"
)),
})();
r.unwrap_or_else(|e| Handled::Now(Err(e)))
@@ -321,6 +361,7 @@ impl Control {
fn inspect(&self, p: &Value) -> Value {
let query = p.get("query").and_then(Value::as_str).map(str::to_lowercase);
let role = p.get("role").and_then(Value::as_str).map(str::to_lowercase);
+ let focused = p.get("focused").and_then(Value::as_bool);
let limit = p.get("limit").and_then(Value::as_u64).unwrap_or(500) as usize;
let Ok(s) = self.shared.lock() else { return json!({ "widgets": [] }) };
let mut out = Vec::new();
@@ -337,7 +378,8 @@ impl Control {
.to_lowercase();
let role_ok = role.as_ref().is_none_or(|r| w["role"].as_str().is_some_and(|x| x.to_lowercase() == *r));
let query_ok = query.as_ref().is_none_or(|q| text.contains(q.as_str()));
- if role_ok && query_ok && id != s.root.unwrap_or(id) {
+ let focus_ok = focused.is_none_or(|want| (s.focus == Some(id)) == want);
+ if role_ok && query_ok && focus_ok && id != s.root.unwrap_or(id) {
total += 1;
if out.len() < limit {
out.push(w);
@@ -414,6 +456,38 @@ impl Control {
let frames = self.inject(vec![vec![egui::Event::AccessKitActionRequest(action)]]);
Ok(Handled::AfterFrames(frames, json!({ "clicked": id.0.to_string(), "label": label })))
}
+
+ fn focus(&mut self, p: &Value) -> Result {
+ only_params(p, "ui.focus", &["id", "label"])?;
+ let s = self.shared.lock().map_err(|_| "control state poisoned")?;
+ let id = match (p.get("id"), p.get("label")) {
+ (Some(id), None) => {
+ NodeId(id.as_u64().or_else(|| id.as_str().and_then(|s| s.parse().ok())).ok_or("ui.focus: id must be a widget id from ui.inspect")?)
+ }
+ (None, Some(Value::String(label))) => {
+ let matches: Vec = s
+ .nodes
+ .iter()
+ .filter(|(_, n)| n.supports_action(Action::Focus) && !n.is_disabled() && n.label().is_some_and(|l| l.eq_ignore_ascii_case(label)))
+ .map(|(id, _)| *id)
+ .collect();
+ match matches.as_slice() {
+ [one] => *one,
+ [] => return Err(format!("ui.focus: no enabled focusable widget labelled {label:?}")),
+ many => return Err(format!("ui.focus: {} widgets are labelled {label:?}; use a widget id", many.len())),
+ }
+ }
+ _ => return Err("ui.focus: pass exactly one widget id or label".into()),
+ };
+ let node = s.nodes.get(&id).ok_or_else(|| format!("ui.focus: no widget with id {} on screen (run ui.inspect again)", id.0))?;
+ if node.is_disabled() || !node.supports_action(Action::Focus) {
+ return Err(format!("ui.focus: widget {} is disabled or not focusable", id.0));
+ }
+ let action = accesskit::ActionRequest { action: Action::Focus, target_tree: accesskit::TreeId::ROOT, target_node: id, data: None };
+ drop(s);
+ let frames = self.inject(vec![vec![egui::Event::AccessKitActionRequest(action)]]);
+ Ok(Handled::AfterFrames(frames, json!({ "focus_requested": id.0.to_string() })))
+ }
}
enum Handled {
@@ -431,6 +505,7 @@ fn widget(id: NodeId, n: &accesskit::Node, depth: usize, focused: bool) -> Value
"depth": depth,
"enabled": !n.is_disabled(),
"clickable": n.supports_action(Action::Click),
+ "focusable": n.supports_action(Action::Focus),
});
let mut more = serde_json::Map::new();
let obj = &mut more;
@@ -461,6 +536,18 @@ fn widget(id: NodeId, n: &accesskit::Node, depth: usize, focused: bool) -> Value
w
}
+fn only_params(p: &Value, method: &str, allowed: &[&str]) -> Result<(), String> {
+ // The JSON-RPC transport represents omitted params as null.
+ if p.is_null() && allowed.is_empty() {
+ return Ok(());
+ }
+ let object = p.as_object().ok_or_else(|| format!("{method}: params must be an object"))?;
+ if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) {
+ return Err(format!("{method}: unknown parameter {key:?}"));
+ }
+ Ok(())
+}
+
fn pointer_button(p: &Value, method: &str) -> Result {
match p.get("button").and_then(Value::as_str).unwrap_or("primary") {
"primary" | "left" => Ok(egui::PointerButton::Primary),
@@ -533,6 +620,14 @@ fn screenshot_png(image: &egui::ColorImage, region: Option, ppp: f32
}
impl Host for crate::PdfCraftApp {
+ fn dock(&mut self, reset: bool, params: &Value) -> Reply {
+ crate::docking::command(self, reset, params)
+ }
+
+ fn quit(&mut self, ctx: &egui::Context) -> bool {
+ self.request_quit(ctx)
+ }
+
fn state(&self) -> Value {
let active = self.active.and_then(|i| self.views.get(i));
let docs: Vec = self
@@ -545,6 +640,7 @@ impl Host for crate::PdfCraftApp {
})
.collect();
json!({
+ "docking": self.docking,
"documents": docs,
"active": active.map(|v| json!({
"doc": v.id.0,
@@ -612,10 +708,11 @@ impl Host for crate::PdfCraftApp {
}
fn commands(&self) -> Value {
- let list: Vec = pdfcraft_engine::commands::COMMANDS
+ let mut list: Vec = pdfcraft_engine::commands::COMMANDS
.iter()
.map(|c| json!({ "id": c.id, "label": c.label, "menu": c.menu, "enabled": self.command_enabled(c), "shortcut": c.shortcut.map(|s| s.label(cfg!(target_os = "macos"))) }))
.collect();
+ list.push(json!({"id": "ui.dock", "label": "Arrange Panel", "enabled": true, "params": "action, or operation/panel; move: target/zone/before; float/moveFloating: rect; resizeSplit: path/size; setStackOpen: open; resizeStack: height"}));
json!({ "commands": list })
}
diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs
new file mode 100644
index 00000000..3d0b6fd3
--- /dev/null
+++ b/crates/ui-egui/src/docking.rs
@@ -0,0 +1,501 @@
+//! Application panel identities and persistence around the shared docking renderer.
+//! Document contents remain owned by their existing view implementations.
+
+use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone};
+use craft_ui::layout::{SplitAxis, SplitSize};
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum Panel {
+ Canvas,
+ Tools,
+ Inspector,
+}
+
+impl Panel {
+ pub const ALL: &[Self] = &[Self::Canvas, Self::Tools, Self::Inspector];
+ pub fn id(self) -> &'static str {
+ match self {
+ Self::Canvas => "canvas",
+ Self::Tools => "tools",
+ Self::Inspector => "inspector",
+ }
+ }
+ fn label(self) -> &'static str {
+ match self {
+ Self::Canvas => "Document",
+ Self::Tools => "Tools",
+ Self::Inspector => "Inspector",
+ }
+ }
+ fn default_side(self) -> Zone {
+ match self {
+ Self::Canvas => Zone::Right,
+ Self::Tools => Zone::Left,
+ Self::Inspector => Zone::Right,
+ }
+ }
+ fn parse(id: &str) -> Result {
+ Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
+ }
+}
+
+fn permissions(panel: &Panel) -> Permissions {
+ if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() }
+}
+
+fn tabs(panel: Panel) -> Node {
+ Node::Tabs { panels: vec![panel], active: 0 }
+}
+
+fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node) -> Node {
+ Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Workspace {
+ pub layout: Layout,
+ /// Closed panels retain their most recent group, side, size or floating rectangle.
+ pub hidden: Vec<(Panel, Location)>,
+}
+
+impl Default for Workspace {
+ fn default() -> Self {
+ Self {
+ layout: Layout {
+ root: Some(split(
+ SplitAxis::Horizontal,
+ SplitSize::FixedFirst(272.0),
+ tabs(Panel::Tools),
+ split(SplitAxis::Horizontal, SplitSize::FixedSecond(330.0), tabs(Panel::Canvas), tabs(Panel::Inspector)),
+ )),
+ floating: Vec::new(),
+ },
+ hidden: Vec::new(),
+ }
+ }
+}
+
+impl Workspace {
+ pub fn validate(&self) -> Result<(), String> {
+ self.layout.validate().map_err(|error| error.to_string())?;
+ if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() {
+ return Err("The panel layout must contain its document view and bounded hidden panels".into());
+ }
+ // Saved settings cannot turn the protected document view into a floating or closable tab.
+ if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) {
+ return Err("The document view cannot float".into());
+ }
+ let mut nodes: Vec<_> = self.layout.root.iter().collect();
+ while let Some(node) = nodes.pop() {
+ match node {
+ Node::Split { first, second, .. } => {
+ nodes.push(first);
+ nodes.push(second);
+ }
+ Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => {
+ return Err("The document view cannot share a tab group".into());
+ }
+ Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => {
+ return Err("The document view cannot be collapsed".into());
+ }
+ _ => {}
+ }
+ }
+ let mut seen = std::collections::HashSet::new();
+ for (panel, location) in &self.hidden {
+ if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) {
+ return Err("A hidden panel has an invalid or duplicate identity".into());
+ }
+ if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) {
+ return Err("A hidden panel cannot restore over the document view".into());
+ }
+ let mut probe = self.layout.clone();
+ if let Err(error) = probe.restore(*panel, location)
+ && error != craft_ui::docking::DockError::MissingTarget
+ {
+ return Err(error.to_string());
+ }
+ }
+ Ok(())
+ }
+
+ pub fn apply(&mut self, action: Action) -> Result<(), String> {
+ self.validate()?;
+ let mut next = self.clone();
+ if let Action::Close { panel } = &action {
+ let location = next.layout.location(panel).map_err(|error| error.to_string())?;
+ next.hidden.retain(|(id, _)| id != panel);
+ next.hidden.push((*panel, location));
+ } else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action {
+ next.hidden.retain(|(id, _)| id != panel);
+ }
+ next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?;
+ next.validate()?;
+ *self = next;
+ Ok(())
+ }
+
+ /// Whether a panel's body is exposed by its selected tab or open stack entry.
+ pub(crate) fn exposed(&self, panel: Panel) -> bool {
+ if self.layout.floating.iter().any(|group| group.panels.get(group.active) == Some(&panel)) {
+ return true;
+ }
+ let mut pending: Vec<_> = self.layout.root.iter().collect();
+ while let Some(node) = pending.pop() {
+ match node {
+ Node::Split { first, second, .. } => {
+ pending.push(first);
+ pending.push(second);
+ }
+ Node::Tabs { panels, active } if panels.get(*active) == Some(&panel) => return true,
+ Node::Stack { entries } if entries.iter().any(|entry| entry.panel == panel && entry.open) => return true,
+ _ => {}
+ }
+ }
+ false
+ }
+
+ pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> {
+ if self.layout.contains(&panel) == visible {
+ return Ok(());
+ }
+ if !visible {
+ return self.apply(Action::Close { panel });
+ }
+ self.validate()?;
+ let mut next = self.clone();
+ let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone());
+ next.hidden.retain(|(id, _)| *id != panel);
+ if let Some(saved) = &mut location
+ && saved.floating.is_some()
+ && saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor)))
+ {
+ // The other members of a floating group may have been closed too.
+ saved.anchor = None;
+ saved.placement = Placement::Tab { before: None };
+ }
+ let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok());
+ if !restored {
+ next.layout
+ .apply_with_permissions(
+ Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) },
+ permissions,
+ )
+ .map_err(|error| error.to_string())?;
+ }
+ next.validate()?;
+ *self = next;
+ Ok(())
+ }
+
+ fn command(&mut self, params: &Value) -> Result<(), String> {
+ self.validate()?;
+ if let Some(action) = params.get("action") {
+ let action: Action = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?;
+ return self.apply(action);
+ }
+ let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
+ if operation == "resizeSplit" {
+ let path: Vec =
+ serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?;
+ let size: SplitSize =
+ serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?;
+ return self.apply(Action::ResizeSplit { path, size });
+ }
+ let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?;
+ let action = match operation {
+ "open" => {
+ self.set_visible(panel, true)?;
+ return self.apply(Action::Activate { panel });
+ }
+ "close" => Action::Close { panel },
+ "activate" => Action::Activate { panel },
+ "setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? },
+ "resizeStack" => {
+ let value = params.get("height").ok_or("height is required")?;
+ let height = if value.is_null() {
+ None
+ } else {
+ Some(
+ value
+ .as_f64()
+ .filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height))
+ .ok_or("height must be finite and bounded")? as f32,
+ )
+ };
+ Action::ResizeStack { panel, height }
+ }
+ "float" | "moveFloating" => {
+ let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?;
+ let mut values = [0.0f32; 4];
+ for (output, value) in values.iter_mut().zip(rect) {
+ *output = value
+ .as_f64()
+ .filter(|number| number.is_finite() && number.abs() <= 1_000_000.0)
+ .ok_or("rect values must be finite and bounded")? as f32;
+ }
+ if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } }
+ }
+ "move" => {
+ let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?;
+ let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") {
+ "center" => Placement::Split(Zone::Center),
+ "left" => Placement::Split(Zone::Left),
+ "right" => Placement::Split(Zone::Right),
+ "top" => Placement::Split(Zone::Top),
+ "bottom" => Placement::Split(Zone::Bottom),
+ _ => return Err("zone must be center, left, right, top or bottom".into()),
+ };
+ let placement = if let Some(before) = params.get("before") {
+ if !matches!(placement, Placement::Split(Zone::Center)) {
+ return Err("before only applies to a center move".into());
+ }
+ Placement::Tab {
+ before: if before.is_null() {
+ None
+ } else {
+ Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?)
+ },
+ }
+ } else {
+ placement
+ };
+ Action::Move { panel, anchor, placement }
+ }
+ _ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()),
+ };
+ self.apply(action)
+ }
+}
+
+fn sync(workspace: &mut Workspace, app: &crate::PdfCraftApp) -> Result<(), String> {
+ workspace.set_visible(Panel::Tools, app.left_open)?;
+ workspace.set_visible(Panel::Inspector, app.right.is_some())
+}
+
+fn drain_reveals(workspace: &mut Workspace, app: &mut crate::PdfCraftApp) {
+ for panel in std::mem::take(&mut app.pending_panel_reveals) {
+ if workspace.layout.contains(&panel) {
+ let _ = workspace.apply(Action::Activate { panel });
+ }
+ }
+}
+
+fn publish_visibility(app: &mut crate::PdfCraftApp, workspace: &Workspace) {
+ app.left_open = workspace.layout.contains(&Panel::Tools);
+ if !workspace.layout.contains(&Panel::Inspector) {
+ if app.right.is_some() {
+ app.choose_right_panel(None);
+ }
+ } else if app.right.is_none() {
+ app.choose_right_panel(Some(crate::RightPanel::Comments));
+ }
+}
+
+pub fn command(app: &mut crate::PdfCraftApp, reset: bool, params: &Value) -> Result {
+ let mut workspace = app.docking.clone();
+ if reset {
+ workspace = Workspace::default();
+ workspace.set_visible(Panel::Inspector, app.right.is_some())?;
+ } else {
+ sync(&mut workspace, app)?;
+ workspace.command(params)?;
+ }
+ publish_visibility(app, &workspace);
+ app.docking = workspace;
+ Ok(json!({"docking": app.docking}))
+}
+
+// Split paths change when a temporarily unavailable pane is pruned. Translate renderer
+// paths back to the saved tree before applying geometry changes.
+fn saved_split_path(saved: &Layout, rendered: &Layout, path: &[bool]) -> Option> {
+ let mut target = rendered.root.as_ref()?;
+ for second in path {
+ let Node::Split { first, second: other, .. } = target else { return None };
+ target = if *second { other } else { first };
+ }
+ let mut pending = vec![(saved.root.as_ref()?, Vec::new())];
+ while let Some((node, original_path)) = pending.pop() {
+ if let Node::Split { first, second, .. } = node {
+ let mut projection = Layout { root: Some(node.clone()), floating: Vec::new() };
+ if projection.contains(&Panel::Inspector) {
+ let _ = projection.apply(Action::Close { panel: Panel::Inspector });
+ }
+ let has_available_panel = |child: &Node| {
+ Layout { root: Some(child.clone()), floating: Vec::new() }.panels().into_iter().any(|panel| *panel != Panel::Inspector)
+ };
+ // An ancestor whose Inspector-only side disappeared projects to its surviving
+ // child. Its divider is absent, so only the child's split may own this resize.
+ if has_available_panel(first) && has_available_panel(second) && projection.root.as_ref() == Some(target) {
+ return Some(original_path);
+ }
+ let mut second_path = original_path.clone();
+ second_path.push(true);
+ pending.push((second, second_path));
+ let mut first_path = original_path;
+ first_path.push(false);
+ pending.push((first, first_path));
+ }
+ }
+ None
+}
+
+pub fn show(app: &mut crate::PdfCraftApp, ui: &mut egui::Ui) {
+ let t = crate::theme::Tokens::get(ui.ctx());
+ let mut workspace = std::mem::take(&mut app.docking);
+ if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) {
+ app.notify(format!("Panel layout could not be restored: {error}"));
+ workspace = Workspace::default();
+ let _ = sync(&mut workspace, app);
+ }
+ let mut style = DockStyle::from_ui(ui);
+ style.min_pane = 96.0;
+ style.float_label = crate::i18n::t("Float panel").into();
+ style.close_label = crate::i18n::t("Close panel").into();
+ style.move_label = crate::i18n::t("Move to group").into();
+ style.panels_label = crate::i18n::t("Panels").into();
+ style.resize_label = crate::i18n::t("Resize panels").into();
+ style.resize_window_label = crate::i18n::t("Resize panel window").into();
+ style.background = t.panel;
+ style.tab_background = t.chrome;
+ style.active_background = t.selected;
+ style.text = t.text;
+ style.inactive_text = t.text_muted;
+ style.border = egui::Stroke::new(1.0, t.divider);
+ style.accent = t.accent;
+ style.font = crate::theme::medium(12.0);
+ drain_reveals(&mut workspace, app);
+ let mut rendered = workspace.layout.clone();
+ if app.active.is_none() && rendered.contains(&Panel::Inspector) {
+ // Availability is a render projection: the saved group, selection and width survive Home.
+ let _ = rendered.apply(Action::Close { panel: Panel::Inspector });
+ }
+ let output = DockArea::new(egui::Id::new("pdfcraft-docking")).show_with_limits(
+ ui,
+ &rendered,
+ &style,
+ |panel| crate::i18n::t(panel.label()).into(),
+ permissions,
+ panel_limits,
+ |ui, panel| match panel {
+ Panel::Canvas => {
+ ui.painter().rect_filled(ui.max_rect(), 0.0, t.pasteboard);
+ match app.active {
+ None if app.combine_showing() => crate::combine_ui::page(app, ui),
+ None => crate::home::show(app, ui),
+ Some(index) => crate::canvas::document_area(app, index, ui),
+ }
+ }
+ Panel::Tools => crate::panels::left_panel(app, ui),
+ Panel::Inspector => {
+ ui.data_mut(|data| data.insert_temp(egui::Id::new("pdfcraft-inspector-body"), ui.max_rect()));
+ crate::panels::right_panel(app, ui);
+ }
+ },
+ );
+ let _ = sync(&mut workspace, app);
+ drain_reveals(&mut workspace, app);
+ publish_visibility(app, &workspace);
+ app.docking = workspace;
+ for mut action in output.actions {
+ if app.active.is_none()
+ && let Action::ResizeSplit { path, .. } = &mut action
+ {
+ let Some(saved_path) = saved_split_path(&app.docking.layout, &rendered, path) else { continue };
+ *path = saved_path;
+ }
+ if let Err(error) =
+ serde_json::to_value(action).map_err(|error| error.to_string()).and_then(|action| command(app, false, &json!({"action": action})))
+ {
+ app.notify(format!("Panel move was not applied: {error}"));
+ }
+ }
+}
+
+fn panel_limits(panel: &Panel) -> PanelLimits {
+ let (min, max_width) = match panel {
+ Panel::Canvas => (egui::vec2(160.0, 160.0), 1_000_000.0),
+ Panel::Tools => (egui::vec2(240.0, 120.0), 520.0),
+ Panel::Inspector => (egui::vec2(260.0, 120.0), 520.0),
+ };
+ PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn exposed_respects_collapsed_stack_and_inactive_floating_tabs() {
+ let mut workspace = Workspace {
+ layout: Layout {
+ root: Some(split(
+ SplitAxis::Horizontal,
+ SplitSize::FixedFirst(272.0),
+ Node::Stack {
+ entries: vec![
+ craft_ui::docking::StackEntry { panel: Panel::Tools, open: true, height: None },
+ craft_ui::docking::StackEntry { panel: Panel::Inspector, open: false, height: None },
+ ],
+ },
+ tabs(Panel::Canvas),
+ )),
+ floating: Vec::new(),
+ },
+ hidden: Vec::new(),
+ };
+ workspace.validate().unwrap();
+ assert!(workspace.exposed(Panel::Tools));
+ assert!(!workspace.exposed(Panel::Inspector));
+ workspace.apply(Action::Activate { panel: Panel::Inspector }).unwrap();
+ assert!(workspace.exposed(Panel::Inspector));
+ workspace.apply(Action::Float { panel: Panel::Tools, rect: [10.0, 20.0, 300.0, 400.0] }).unwrap();
+ workspace.apply(Action::Move { panel: Panel::Inspector, anchor: Panel::Tools, placement: Placement::Split(Zone::Center) }).unwrap();
+ assert!(!workspace.exposed(Panel::Tools));
+ assert!(workspace.exposed(Panel::Inspector));
+ }
+
+ #[test]
+ fn unavailable_inspector_split_resize_targets_saved_tools_split() {
+ let mut workspace = Workspace::default();
+ // Inspector at the outer edge makes the visible Tools split move to the root.
+ workspace.apply(Action::Move { panel: Panel::Inspector, anchor: Panel::Tools, placement: Placement::Split(Zone::Left) }).unwrap();
+ let mut rendered = workspace.layout.clone();
+ rendered.apply(Action::Close { panel: Panel::Inspector }).unwrap();
+ let path = saved_split_path(&workspace.layout, &rendered, &[]).unwrap();
+ workspace.apply(Action::ResizeSplit { path, size: SplitSize::FixedFirst(290.0) }).unwrap();
+ let mut resized = workspace.layout.clone();
+ resized.apply(Action::Close { panel: Panel::Inspector }).unwrap();
+ assert!(matches!(resized.root, Some(Node::Split { size: SplitSize::FixedFirst(290.0), .. })));
+ }
+
+ #[test]
+ fn unavailable_outer_inspector_does_not_capture_its_childs_resize() {
+ let mut workspace = Workspace {
+ layout: Layout {
+ root: Some(split(
+ SplitAxis::Horizontal,
+ SplitSize::FixedSecond(330.0),
+ split(SplitAxis::Horizontal, SplitSize::FixedFirst(272.0), tabs(Panel::Tools), tabs(Panel::Canvas)),
+ tabs(Panel::Inspector),
+ )),
+ floating: Vec::new(),
+ },
+ hidden: Vec::new(),
+ };
+ workspace.validate().unwrap();
+ let mut rendered = workspace.layout.clone();
+ rendered.apply(Action::Close { panel: Panel::Inspector }).unwrap();
+ let path = saved_split_path(&workspace.layout, &rendered, &[]).unwrap();
+ assert_eq!(path, vec![false]);
+ workspace.apply(Action::ResizeSplit { path, size: SplitSize::FixedFirst(290.0) }).unwrap();
+ assert!(matches!(workspace.layout.root, Some(Node::Split { size: SplitSize::FixedSecond(330.0), .. })));
+ let mut resized = workspace.layout.clone();
+ resized.apply(Action::Close { panel: Panel::Inspector }).unwrap();
+ assert!(matches!(resized.root, Some(Node::Split { size: SplitSize::FixedFirst(290.0), .. })));
+ }
+}
diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs
index f1b3166e..f5738180 100644
--- a/crates/ui-egui/src/editing.rs
+++ b/crates/ui-egui/src/editing.rs
@@ -584,6 +584,20 @@ impl PdfCraftApp {
}
}
+ /// Request a normal application exit, preserving every unsaved document until answered.
+ /// Returns false while the existing save/discard/cancel prompt needs a decision.
+ pub(crate) fn request_quit(&mut self, ctx: &egui::Context) -> bool {
+ if self.first_dirty().is_some() {
+ self.close_request = Some(CloseRequest::Quit);
+ ctx.request_repaint();
+ false
+ } else {
+ self.shutdown_recovery();
+ self.quit(ctx);
+ true
+ }
+ }
+
fn quit(&mut self, ctx: &egui::Context) {
self.allow_quit = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
@@ -925,6 +939,39 @@ mod revert_draft_tests {
assert!(app.views[index].forms.committed.is_some() && app.views[index].pending_edit.is_some());
}
+ #[test]
+ fn control_quit_prompts_for_uncommitted_typing_and_cancel_retains_it() {
+ for queued in [false, true] {
+ let mut app = app();
+ let id = app.views[0].id;
+ app.open_bytes("other.pdf", None, include_bytes!("../tests/data/form.pdf").to_vec()).unwrap();
+ if queued {
+ queue_draft(&mut app, 0, "Unsaved typing");
+ } else {
+ app.views[0].forms.focus = Some(focus("Unsaved typing"));
+ }
+ assert!(!app.session.get(id).unwrap().dirty);
+ assert_eq!(app.active, Some(1));
+ let ctx = egui::Context::default();
+ assert!(!app.request_quit(&ctx));
+ assert_eq!(app.close_request, Some(CloseRequest::Quit));
+ assert!(!app.allow_quit);
+ app.resolve_close(&ctx, None);
+ assert!(app.close_request.is_none());
+ assert_eq!(app.views.len(), 2);
+ assert!(app.has_unsaved_work(0));
+ assert_eq!(app.views[0].id, id);
+ assert!(!app.session.get(id).unwrap().dirty);
+ if queued {
+ assert!(
+ matches!(&app.views[0].pending_edit, Some(Edit::SetFieldValue { value: FieldValue::Text(text), .. }) if text == "Unsaved typing")
+ );
+ } else {
+ assert_eq!(app.views[0].forms.focus.as_ref().unwrap().text, "Unsaved typing");
+ }
+ }
+ }
+
#[test]
fn successful_revert_clears_focused_or_queued_drafts_only_on_its_document() {
for queued in [false, true] {
diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv
index 2866522e..01f86029 100644
--- a/crates/ui-egui/src/i18n/ar.tsv
+++ b/crates/ui-egui/src/i18n/ar.tsv
@@ -2163,3 +2163,9 @@ certificate Subject صاحب الشهادة
A selected field has no widget on any page, so the fields can't be edited together. أحد الحقول المحددة ليس له عنصر واجهة في أي صفحة، لذا لا يمكن تحرير الحقول معًا.
Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). التعرف على النص غير مثبت: نماذجه مفقودة (شغّل `cargo xtask models`، أو اضبط PDFCRAFT_MODELS).
({n} at {dpi} dpi, the largest size they can be drawn at) ({n} بدقة {dpi} dpi، وهي أكبر دقة يمكن رسمها بها)
+ Reset panel layout إعادة تعيين تخطيط اللوحات
+ Float panel تعويم اللوحة
+ Move to group نقل إلى مجموعة
+ Panels اللوحات
+ Resize panels تغيير حجم اللوحات
+ Resize panel window تغيير حجم نافذة اللوحة
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
index 138b112c..42088e07 100644
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -2103,3 +2103,22 @@ action wizard Start Старт
The field {name} no longer exists. Полето {name} вече не съществува.
A selected field has no widget on any page, so the fields can't be edited together. Едно от избраните полета няма елемент на нито една страница, затова полетата не могат да се редактират заедно.
({n} at {dpi} dpi, the largest size they can be drawn at) ({n} с {dpi} dpi, най-голямата резолюция, с която могат да бъдат изобразени)
+ Zoom out (⌘−) Намали мащаба (⌘−)
+ Zoom in (⌘+) Увеличи мащаба (⌘+)
+ Rotate view clockwise (⇧⌘+) Завърти изгледа по часовниковата стрелка (⇧⌘+)
+ Open a PDF (⌘O) Отвори PDF файл (⌘O)
+ Print (⌘P) Печат (⌘P)
+ Document properties (⌘D) Свойства на документа (⌘D)
+ New bookmark (⌘B) Нова отметка (⌘B)
+ Previous (⇧⌘G) Предишно (⇧⌘G)
+ Next (⌘G) Следващо (⌘G)
+ Zoom in / out (also pinch or ⌘-scroll) Увеличи / намали (също с щипване или ⌘ + превъртане)
+ Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). Разпознаването на текст не е инсталирано: моделите му липсват (изпълнете `cargo xtask models` или задайте PDFCRAFT_MODELS).
+ Reset panel layout Възстанови оформлението на панелите
+ Float panel Откачи панела
+ Move to group Премести в група
+ Panels Панели
+ Resize panels Преоразмери панелите
+ Resize panel window Преоразмери прозореца на панела
+ Tools Инструменти
+ Inspector Инспектор
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
index 11188391..691861fc 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -145,3 +145,13 @@
Import list… Importovat seznam…
{n} word(s) or phrase(s) Slova nebo fráze: {n}
{name} is too large for a word list (at most 1 MB). {name} je pro seznam slov příliš velký (nejvýše 1 MB).
+ Reset panel layout Obnovit rozložení panelů
+ Float panel Uvolnit panel
+ Move to group Přesunout do skupiny
+ Panels Panely
+ Resize panels Změnit velikost panelů
+ Resize panel window Změnit velikost okna panelu
+ Tools Nástroje
+ Inspector Inspektor
+ Document Dokument
+ Close panel Zavřít panel
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
index 9baf7af6..114a18c5 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -2141,3 +2141,22 @@ action wizard Start Starten
The field {name} no longer exists. Das Feld {name} existiert nicht mehr.
A selected field has no widget on any page, so the fields can't be edited together. Ein ausgewähltes Feld hat auf keiner Seite ein Widget, daher können die Felder nicht gemeinsam bearbeitet werden.
({n} at {dpi} dpi, the largest size they can be drawn at) ({n} mit {dpi} dpi, der größten Auflösung, in der sie gezeichnet werden können)
+ Zoom in / out (also pinch or ⌘-scroll) Vergrößern / Verkleinern (auch per Pinch-Geste oder ⌘ + Scrollen)
+ Open a PDF (⌘O) PDF öffnen (⌘O)
+ Print (⌘P) Drucken (⌘P)
+ Document properties (⌘D) Dokumenteigenschaften (⌘D)
+ Zoom out (⌘−) Verkleinern (⌘−)
+ Zoom in (⌘+) Vergrößern (⌘+)
+ Rotate view clockwise (⇧⌘+) Ansicht im Uhrzeigersinn drehen (⇧⌘+)
+ New bookmark (⌘B) Neues Lesezeichen (⌘B)
+ Previous (⇧⌘G) Vorheriger (⇧⌘G)
+ Next (⌘G) Nächster (⌘G)
+ Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). Die Texterkennung ist nicht installiert, ihre Modelle fehlen (führe `cargo xtask models` aus oder setze PDFCRAFT_MODELS).
+ Reset panel layout Bereichsanordnung zurücksetzen
+ Float panel Bereich frei platzieren
+ Move to group In Gruppe verschieben
+ Panels Bereiche
+ Resize panels Bereichsgrößen ändern
+ Resize panel window Größe des Bereichsfensters ändern
+ Tools Werkzeuge
+ Inspector Inspektor
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index 1c870236..167e08ae 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -2057,3 +2057,22 @@
The field {name} no longer exists. El campo {name} ya no existe.
A selected field has no widget on any page, so the fields can't be edited together. Un campo seleccionado no tiene ningún widget en ninguna página, así que los campos no se pueden editar juntos.
({n} at {dpi} dpi, the largest size they can be drawn at) ({n} a {dpi} ppp, el mayor tamaño al que se pueden dibujar)
+ Zoom in / out (also pinch or ⌘-scroll) Acercar / alejar (también con pellizco o ⌘ + desplazamiento)
+ Open a PDF (⌘O) Abrir un PDF (⌘O)
+ Print (⌘P) Imprimir (⌘P)
+ Document properties (⌘D) Propiedades del documento (⌘D)
+ Zoom out (⌘−) Alejar (⌘−)
+ Zoom in (⌘+) Acercar (⌘+)
+ Rotate view clockwise (⇧⌘+) Girar la vista a la derecha (⇧⌘+)
+ New bookmark (⌘B) Nuevo marcador (⌘B)
+ Previous (⇧⌘G) Anterior (⇧⌘G)
+ Next (⌘G) Siguiente (⌘G)
+ Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). El reconocimiento de texto no está instalado: faltan sus modelos (ejecuta `cargo xtask models` o define PDFCRAFT_MODELS).
+ Reset panel layout Restablecer disposición de paneles
+ Float panel Hacer flotante el panel
+ Move to group Mover al grupo
+ Panels Paneles
+ Resize panels Cambiar tamaño de paneles
+ Resize panel window Cambiar tamaño de la ventana del panel
+ Tools Herramientas
+ Inspector Inspector
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index 88a6117d..e3c5c1c0 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -2141,3 +2141,22 @@ signature pad Type Saisir
The field {name} no longer exists. Le champ {name} n'existe plus.
A selected field has no widget on any page, so the fields can't be edited together. Un champ sélectionné n'a de widget sur aucune page : les champs ne peuvent pas être modifiés ensemble.
({n} at {dpi} dpi, the largest size they can be drawn at) ({n} à {dpi} ppp, la plus grande taille possible pour les dessiner)
+ Zoom in / out (also pinch or ⌘-scroll) Zoom avant / arrière (aussi pincer ou ⌘-défilement)
+ Open a PDF (⌘O) Ouvrir un PDF (⌘O)
+ Print (⌘P) Imprimer (⌘P)
+ Document properties (⌘D) Propriétés du document (⌘D)
+ Zoom out (⌘−) Zoom arrière (⌘−)
+ Zoom in (⌘+) Zoom avant (⌘+)
+ Rotate view clockwise (⇧⌘+) Pivoter l'affichage vers la droite (⇧⌘+)
+ New bookmark (⌘B) Nouveau signet (⌘B)
+ Previous (⇧⌘G) Précédent (⇧⌘G)
+ Next (⌘G) Suivant (⌘G)
+ Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). La reconnaissance de texte n'est pas installée : ses modèles sont absents (exécutez `cargo xtask models`, ou définissez PDFCRAFT_MODELS).
+ Reset panel layout Réinitialiser la disposition des panneaux
+ Float panel Détacher le panneau
+ Move to group Déplacer vers un groupe
+ Panels Panneaux
+ Resize panels Redimensionner les panneaux
+ Resize panel window Redimensionner la fenêtre du panneau
+ Tools Outils
+ Inspector Inspecteur
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
index 3ac27fde..9d5d2313 100644
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -2139,3 +2139,22 @@ action wizard Start Indítás
The field {name} no longer exists. A(z) {name} mező már nem létezik.
A selected field has no widget on any page, so the fields can't be edited together. Az egyik kijelölt mezőnek egyik oldalon sincs widgetje, ezért a mezők nem szerkeszthetők együtt.
({n} at {dpi} dpi, the largest size they can be drawn at) ({n} {dpi} dpi felbontással, a legnagyobb méretben, amelyben megjeleníthetők)
+ Zoom in / out (also pinch or ⌘-scroll) Nagyítás / kicsinyítés (csípéssel vagy ⌘-görgetéssel is)
+ Open a PDF (⌘O) PDF megnyitása (⌘O)
+ Print (⌘P) Nyomtatás (⌘P)
+ Document properties (⌘D) Dokumentum tulajdonságai (⌘D)
+ Zoom out (⌘−) Kicsinyítés (⌘−)
+ Zoom in (⌘+) Nagyítás (⌘+)
+ Rotate view clockwise (⇧⌘+) Nézet forgatása az óramutató járásával megegyezően (⇧⌘+)
+ New bookmark (⌘B) Új könyvjelző (⌘B)
+ Previous (⇧⌘G) Előző (⇧⌘G)
+ Next (⌘G) Következő (⌘G)
+ Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). A szövegfelismerés nincs telepítve: hiányoznak a modelljei (futtasd a `cargo xtask models` parancsot, vagy állítsd be a PDFCRAFT_MODELS változót).
+ Reset panel layout Panelelrendezés visszaállítása
+ Float panel Panel lebegtetése
+ Move to group Áthelyezés csoportba
+ Panels Panelek
+ Resize panels Panelek átméretezése
+ Resize panel window Panelablak átméretezése
+ Tools Eszközök
+ Inspector Tulajdonságvizsgáló
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
index fff7d958..5a756686 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -2150,3 +2150,9 @@ signature pad Type Digita
The field {name} no longer exists. Il campo {name} non esiste più.
A selected field has no widget on any page, so the fields can't be edited together. Un campo selezionato non ha widget in nessuna pagina, quindi i campi non possono essere modificati insieme.
({n} at {dpi} dpi, the largest size they can be drawn at) ({n} a {dpi} dpi, la dimensione massima a cui si possono disegnare)
+ Reset panel layout Ripristina disposizione pannelli
+ Float panel Rendi pannello flottante
+ Move to group Sposta nel gruppo
+ Panels Pannelli
+ Resize panels Ridimensiona pannelli
+ Resize panel window Ridimensiona finestra pannello
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index 0d7525ce..c6763e75 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -2141,3 +2141,21 @@ signature pad Type 入力
The field {name} no longer exists. フィールド {name} はもう存在しません。
A selected field has no widget on any page, so the fields can't be edited together. 選択したフィールドの一つがどのページにもウィジェットを持たないため、まとめて編集できません。
({n} at {dpi} dpi, the largest size they can be drawn at) ({n} 件は描画できる最大サイズの {dpi} dpi で書き出しました)
+ Zoom in / out (also pinch or ⌘-scroll) 拡大/縮小(ピンチ操作や ⌘+スクロールも使用可能)
+ Open a PDF (⌘O) PDF を開く(⌘O)
+ Print (⌘P) 印刷(⌘P)
+ Document properties (⌘D) 文書のプロパティ(⌘D)
+ Zoom out (⌘−) 縮小(⌘−)
+ Zoom in (⌘+) 拡大(⌘+)
+ Rotate view clockwise (⇧⌘+) 表示を時計回りに回転(⇧⌘+)
+ New bookmark (⌘B) 新しいしおり(⌘B)
+ Previous (⇧⌘G) 前へ(⇧⌘G)
+ Next (⌘G) 次へ(⌘G)
+ Reset panel layout パネル配置をリセット
+ Float panel パネルをフロート化
+ Move to group グループに移動
+ Panels パネル
+ Resize panels パネルのサイズを変更
+ Resize panel window パネルウィンドウのサイズを変更
+ Tools ツール
+ Inspector インスペクター
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
index f0912dbd..5499d1d9 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -2142,3 +2142,11 @@ comment menu Edit Editar
The field {name} no longer exists. O campo {name} não existe mais.
A selected field has no widget on any page, so the fields can't be edited together. Um campo selecionado não tem widget em nenhuma página, então os campos não podem ser editados juntos.
({n} at {dpi} dpi, the largest size they can be drawn at) ({n} a {dpi} dpi, o maior tamanho em que podem ser desenhadas)
+ Reset panel layout Redefinir disposição dos painéis
+ Float panel Tornar painel flutuante
+ Move to group Mover para grupo
+ Panels Painéis
+ Resize panels Redimensionar painéis
+ Resize panel window Redimensionar janela do painel
+ Tools Ferramentas
+ Inspector Inspetor
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
index c4cf0a42..c8245361 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -2054,3 +2054,22 @@
The field {name} no longer exists. Поле {name} больше не существует.
A selected field has no widget on any page, so the fields can't be edited together. У одного из выбранных полей нет виджета ни на одной странице, поэтому поля нельзя изменить вместе.
({n} at {dpi} dpi, the largest size they can be drawn at) ({n} — с разрешением {dpi} dpi, максимальным, с каким их можно отрисовать)
+ Zoom in / out (also pinch or ⌘-scroll) Масштаб (также щипок или ⌘-прокрутка)
+ Open a PDF (⌘O) Открыть PDF (⌘O)
+ Print (⌘P) Печать (⌘P)
+ Document properties (⌘D) Свойства документа (⌘D)
+ Zoom out (⌘−) Уменьшить (⌘−)
+ Zoom in (⌘+) Увеличить (⌘+)
+ Rotate view clockwise (⇧⌘+) Повернуть вид по часовой стрелке (⇧⌘+)
+ New bookmark (⌘B) Новая закладка (⌘B)
+ Previous (⇧⌘G) Предыдущее (⇧⌘G)
+ Next (⌘G) Следующее (⌘G)
+ Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). Распознавание текста не установлено: отсутствуют модели (запустите `cargo xtask models` или задайте PDFCRAFT_MODELS).
+ Reset panel layout Сбросить расположение панелей
+ Float panel Сделать панель плавающей
+ Move to group Переместить в группу
+ Panels Панели
+ Resize panels Изменить размеры панелей
+ Resize panel window Изменить размер окна панели
+ Tools Инструменты
+ Inspector Инспектор
diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv
index 69ec3fbd..169dce7d 100644
--- a/crates/ui-egui/src/i18n/te.tsv
+++ b/crates/ui-egui/src/i18n/te.tsv
@@ -2072,3 +2072,22 @@ action wizard Start ప్రారంభించు
The field {name} no longer exists. {name} ఫీల్డ్ ఇకపై లేదు.
A selected field has no widget on any page, so the fields can't be edited together. ఎంచుకున్న ఒక ఫీల్డ్కు ఏ పేజీలోనూ విడ్జెట్ లేదు, కాబట్టి ఫీల్డ్లను కలిపి సవరించలేము.
({n} at {dpi} dpi, the largest size they can be drawn at) ({n} {dpi} dpi వద్ద, అవి గీయగల అతిపెద్ద పరిమాణం)
+ Zoom in / out (also pinch or ⌘-scroll) జూమ్ పెంచు / తగ్గించు (పించ్ లేదా ⌘-స్క్రోల్తో కూడా)
+ Open a PDF (⌘O) PDFను తెరువు (⌘O)
+ Print (⌘P) ముద్రించు (⌘P)
+ Document properties (⌘D) పత్ర లక్షణాలు (⌘D)
+ Zoom out (⌘−) జూమ్ తగ్గించు (⌘−)
+ Zoom in (⌘+) జూమ్ పెంచు (⌘+)
+ Rotate view clockwise (⇧⌘+) వీక్షణను సవ్యదిశలో తిప్పు (⇧⌘+)
+ New bookmark (⌘B) కొత్త బుక్మార్క్ (⌘B)
+ Previous (⇧⌘G) మునుపటి (⇧⌘G)
+ Next (⌘G) తదుపరి (⌘G)
+ Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). టెక్స్ట్ గుర్తింపు ఇన్స్టాల్ కాలేదు: దాని మోడల్లు లేవు (`cargo xtask models` అమలు చేయండి, లేదా PDFCRAFT_MODELS సెట్ చేయండి).
+ Reset panel layout ప్యానెల్ అమరికను రీసెట్ చేయి
+ Float panel ప్యానెల్ను తేలియాడేలా చేయి
+ Move to group సమూహానికి తరలించు
+ Panels ప్యానెల్లు
+ Resize panels ప్యానెల్ల పరిమాణం మార్చు
+ Resize panel window ప్యానెల్ విండో పరిమాణం మార్చు
+ Tools సాధనాలు
+ Inspector పరిశీలకుడు
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
index 82d78fdb..f78d0d99 100644
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -2164,3 +2164,9 @@ certificate Subject Власник сертифіката
The field {name} no longer exists. Поле {name} більше не існує.
A selected field has no widget on any page, so the fields can't be edited together. Одне з вибраних полів не має віджета на жодній сторінці, тому поля не можна редагувати разом.
({n} at {dpi} dpi, the largest size they can be drawn at) ({n} — з роздільністю {dpi} dpi, найбільшою, з якою їх можна відобразити)
+ Reset panel layout Скинути розташування панелей
+ Float panel Відкріпити панель
+ Move to group Перемістити до групи
+ Panels Панелі
+ Resize panels Змінити розмір панелей
+ Resize panel window Змінити розмір вікна панелі
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index 98bf4dd7..b2e0dbb5 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -2105,3 +2105,22 @@
The field {name} no longer exists. 字段 {name} 已不存在。
A selected field has no widget on any page, so the fields can't be edited together. 某个所选字段在任何页面上都没有控件,因此无法一起编辑这些字段。
({n} at {dpi} dpi, the largest size they can be drawn at) ({n} 张以可绘制的最大尺寸 {dpi} dpi 导出)
+ Zoom out (⌘−) 缩小 (⌘−)
+ Zoom in (⌘+) 放大 (⌘+)
+ Rotate view clockwise (⇧⌘+) 顺时针旋转视图 (⇧⌘+)
+ Open a PDF (⌘O) 打开 PDF (⌘O)
+ Print (⌘P) 打印 (⌘P)
+ Document properties (⌘D) 文档属性 (⌘D)
+ New bookmark (⌘B) 新建书签 (⌘B)
+ Previous (⇧⌘G) 上一个 (⇧⌘G)
+ Next (⌘G) 下一个 (⌘G)
+ Zoom in / out (also pinch or ⌘-scroll) 放大/缩小(也可用捏合或 ⌘+滚轮)
+ Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). 尚未安装文本识别功能:缺少模型(请运行 `cargo xtask models` 或设置 PDFCRAFT_MODELS)。
+ Reset panel layout 重置面板布局
+ Float panel 浮动面板
+ Move to group 移到组中
+ Panels 面板
+ Resize panels 调整面板大小
+ Resize panel window 调整面板窗口大小
+ Tools 工具
+ Inspector 检查器
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index 9cefb1c4..01dc9c43 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -2027,3 +2027,21 @@
The field {name} no longer exists. 欄位 {name} 已不存在。
A selected field has no widget on any page, so the fields can't be edited together. 某個所選欄位在任何頁面上都沒有控制項,因此無法一起編輯這些欄位。
({n} at {dpi} dpi, the largest size they can be drawn at) ({n} 張以可繪製的最大尺寸 {dpi} dpi 匯出)
+ Zoom out (⌘−) 縮小(⌘−)
+ Zoom in (⌘+) 放大(⌘+)
+ Rotate view clockwise (⇧⌘+) 順時針旋轉檢視(⇧⌘+)
+ Open a PDF (⌘O) 開啟 PDF(⌘O)
+ Print (⌘P) 列印(⌘P)
+ Document properties (⌘D) 文件內容(⌘D)
+ New bookmark (⌘B) 新建書籤 (⌘B)
+ Previous (⇧⌘G) 上一個 (⇧⌘G)
+ Next (⌘G) 下一個 (⌘G)
+ Zoom in / out (also pinch or ⌘-scroll) 放大/縮小(也可用雙指縮放或 ⌘ 加捲動)
+ Reset panel layout 重設面板配置
+ Float panel 浮動面板
+ Move to group 移至群組
+ Panels 面板
+ Resize panels 調整面板大小
+ Resize panel window 調整面板視窗大小
+ Tools 工具
+ Inspector 檢查器
diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs
index 543af1db..1c6c14a4 100644
--- a/crates/ui-egui/src/icons.rs
+++ b/crates/ui-egui/src/icons.rs
@@ -1,37 +1,32 @@
//! Vector icons: embedded Lucide SVGs, recoloured to white, rasterized by egui_extras at the exact
//! on-screen pixel size, and tinted per use (PhotoCraft lesson: never use Unicode glyphs as icons).
-use std::collections::HashMap;
-use std::sync::{Arc, OnceLock};
+use std::sync::OnceLock;
-use egui::{Color32, Rect, Response, Sense, Vec2};
+use craft_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle};
+use craft_ui::icons::{SvgIconSet, SvgStyle};
+
+use egui::{Color32, Rect, Response, Vec2};
use crate::icon_data::ICONS;
use crate::theme::Tokens;
-fn white_icons() -> &'static HashMap<&'static str, Arc<[u8]>> {
- static MAP: OnceLock>> = OnceLock::new();
- MAP.get_or_init(|| {
- ICONS
- .iter()
- .map(|(name, bytes)| {
- let svg = String::from_utf8_lossy(bytes).replace("currentColor", "#ffffff").replace("stroke-width=\"2\"", "stroke-width=\"1.75\"");
- (*name, Arc::from(svg.into_bytes().into_boxed_slice()))
- })
- .collect()
- })
+fn white_icons() -> &'static SvgIconSet {
+ static MAP: OnceLock = OnceLock::new();
+ MAP.get_or_init(|| SvgIconSet::new(ICONS, SvgStyle { current_color: "#ffffff", stroke_width: Some("1.75") }, "bytes://icons/", "square"))
}
pub fn exists(name: &str) -> bool {
- white_icons().contains_key(name)
+ white_icons().contains(name)
+}
+
+fn source(name: &str) -> egui::ImageSource<'static> {
+ // PdfCraft caches an unknown name under the resolved fallback name.
+ white_icons().source(white_icons().canonical_name(name).unwrap_or("square"))
}
pub fn image(name: &str, size: f32, tint: Color32) -> egui::Image<'static> {
- let (key, bytes) = match white_icons().get(name) {
- Some(b) => (name, b.clone()),
- None => ("square", white_icons()["square"].clone()),
- };
- egui::Image::from_bytes(format!("bytes://icons/{key}.svg"), egui::load::Bytes::Shared(bytes)).fit_to_exact_size(Vec2::splat(size)).tint(tint)
+ egui::Image::new(source(name)).fit_to_exact_size(Vec2::splat(size)).tint(tint)
}
pub fn paint(ui: &egui::Ui, rect: Rect, name: &str, size: f32, tint: Color32) {
@@ -41,24 +36,24 @@ pub fn paint(ui: &egui::Ui, rect: Rect, name: &str, size: f32, tint: Color32) {
/// Square icon button: transparent until hovered; `selected` gets the accent treatment.
pub fn button(ui: &mut egui::Ui, name: &str, box_size: f32, selected: bool, tooltip: &str) -> Response {
let t = Tokens::get(ui.ctx());
- let (rect, resp) = ui.allocate_exact_size(Vec2::splat(box_size), Sense::click());
+ let style = IconButtonStyle {
+ normal: ButtonVisuals { fill: Color32::TRANSPARENT, stroke: egui::Stroke::NONE, icon: t.icon },
+ hovered: ButtonVisuals { fill: t.hover, stroke: egui::Stroke::NONE, icon: t.icon },
+ pressed: Some(ButtonVisuals { fill: t.pressed, stroke: egui::Stroke::NONE, icon: t.icon }),
+ selected: ButtonVisuals { fill: t.accent_soft, stroke: egui::Stroke::NONE, icon: t.accent_text },
+ corner_radius: t.radius.into(),
+ focus_stroke: egui::Stroke::new(1.0, t.accent),
+ };
let label = if tooltip.is_empty() { name } else { tooltip };
- resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), selected, label));
- let hovered = resp.hovered();
- if selected {
- ui.painter().rect_filled(rect, t.radius, t.accent_soft);
- } else if resp.is_pointer_button_down_on() {
- ui.painter().rect_filled(rect, t.radius, t.pressed);
- } else if hovered {
- ui.painter().rect_filled(rect, t.radius, t.hover);
- }
- let tint = if selected { t.accent_text } else { t.icon };
- paint(ui, rect, name, (box_size * 0.5).round(), tint);
+ let resp = IconButton::new(label, Vec2::splat(box_size), &style).selected(selected).show(ui, |ui, rect, tint| {
+ paint(ui, rect, name, (box_size * 0.5).round(), tint);
+ });
if tooltip.is_empty() { resp } else { resp.on_hover_text(tooltip) }
}
#[cfg(test)]
mod tests {
+ use super::*;
#[test]
fn every_catalog_icon_exists() {
for g in pdfcraft_engine::catalog::TOOL_GROUPS {
@@ -77,4 +72,96 @@ mod tests {
assert!(super::exists(c.icon), "missing icon {} ({})", c.icon, c.id);
}
}
+
+ #[test]
+ fn shared_catalog_preserves_existing_icons_and_fallback_uri() {
+ assert_eq!(ICONS.len(), 184);
+ for (name, original) in ICONS {
+ let expected =
+ String::from_utf8_lossy(original).replace("currentColor", "#ffffff").replace("stroke-width=\"2\"", "stroke-width=\"1.75\"");
+ let egui::ImageSource::Bytes { uri, bytes } = source(name) else { panic!("expected SVG bytes") };
+ assert_eq!(uri.as_ref(), format!("bytes://icons/{name}.svg"));
+ assert_eq!(bytes.as_ref(), expected.as_bytes(), "{name}");
+ }
+ let egui::ImageSource::Bytes { uri, bytes } = source("missing-icon") else { panic!("expected SVG bytes") };
+ let egui::ImageSource::Bytes { bytes: fallback, .. } = source("square") else { panic!("expected SVG bytes") };
+ assert_eq!(uri.as_ref(), "bytes://icons/square.svg");
+ assert_eq!(bytes.as_ref(), fallback.as_ref());
+ assert!(!exists("missing-icon"));
+ }
+
+ #[test]
+ fn shared_button_preserves_theme_painting_and_pointer_responses() {
+ // Freeze the original paint path across both themes. The added keyboard-focus
+ // outline is covered independently in craft-ui.
+ fn frame(
+ ctx: &egui::Context,
+ events: Vec,
+ selected: bool,
+ enabled: bool,
+ legacy: bool,
+ ) -> (egui::Response, Vec) {
+ let mut response = None;
+ let mut output = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
+ response = Some(
+ ui.add_enabled_ui(enabled, |ui| {
+ if legacy {
+ let t = Tokens::get(ui.ctx());
+ let (rect, response) = ui.allocate_exact_size(Vec2::splat(28.0), egui::Sense::click());
+ if selected {
+ ui.painter().rect_filled(rect, t.radius, t.accent_soft);
+ } else if response.is_pointer_button_down_on() {
+ ui.painter().rect_filled(rect, t.radius, t.pressed);
+ } else if response.hovered() {
+ ui.painter().rect_filled(rect, t.radius, t.hover);
+ }
+ let tint = if selected { t.accent_text } else { t.icon };
+ paint(ui, rect, "square", (28.0_f32 * 0.5).round(), tint);
+ response
+ } else {
+ button(ui, "square", 28.0, selected, "")
+ }
+ })
+ .inner,
+ );
+ });
+ output.textures_delta.clear();
+ (response.unwrap(), output.shapes)
+ }
+ for theme in [crate::theme::ThemeKind::Dark, crate::theme::ThemeKind::Light] {
+ for selected in [false, true] {
+ for enabled in [false, true] {
+ let old = egui::Context::default();
+ let new = egui::Context::default();
+ for ctx in [&old, &new] {
+ crate::theme::apply(ctx, theme);
+ egui_extras::install_image_loaders(ctx);
+ }
+ let mut center = egui::Pos2::ZERO;
+ for step in 0..7 {
+ let events = match step {
+ 2 => vec![egui::Event::PointerMoved(center)],
+ 3 | 4 => vec![egui::Event::PointerButton {
+ pos: center,
+ button: egui::PointerButton::Primary,
+ pressed: step == 3,
+ modifiers: egui::Modifiers::NONE,
+ }],
+ 5 => vec![egui::Event::PointerMoved(egui::pos2(300.0, 200.0))],
+ _ => vec![],
+ };
+ let (a, before) = frame(&old, events.clone(), selected, enabled, true);
+ let (b, after) = frame(&new, events, selected, enabled, false);
+ center = a.rect.center();
+ assert_eq!(a.rect, b.rect);
+ assert_eq!(
+ (a.clicked(), a.hovered(), a.is_pointer_button_down_on()),
+ (b.clicked(), b.hovered(), b.is_pointer_button_down_on())
+ );
+ assert_eq!(before, after, "{theme:?}, selected={selected}, enabled={enabled}, step={step}");
+ }
+ }
+ }
+ }
+ }
}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index cef3d242..446b019a 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -27,6 +27,7 @@ mod actions_ui;
pub mod canvas;
mod chrome;
mod combine_ui;
+pub mod docking;
pub use combine_ui::{Columns as CombineColumns, Lock as CombineLock, SortKey};
mod commands;
mod comment_props;
@@ -154,7 +155,7 @@ pub enum LeftPanel {
}
/// Right-hand panels, opened from the rail.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum RightPanel {
Comments,
Bookmarks,
@@ -360,6 +361,8 @@ pub struct RecentFile {
}
pub struct PdfCraftApp {
+ pub docking: docking::Workspace,
+ pub(crate) pending_panel_reveals: Vec,
pub session: Session,
pub views: Vec,
/// `None` shows the Home tab.
@@ -660,6 +663,8 @@ impl PdfCraftApp {
date_format_draft: None,
mode_override: None,
left: LeftPanel::AllTools,
+ docking: docking::Workspace::default(),
+ pending_panel_reveals: Vec::new(),
left_open: true,
right: None,
comments_panel_closed: false,
@@ -1255,10 +1260,15 @@ impl PdfCraftApp {
date_text(&self.session, fmt)
}
+ pub(crate) fn reveal_tools(&mut self) {
+ self.left_open = true;
+ self.pending_panel_reveals.push(docking::Panel::Tools);
+ }
+
/// Select the workspace and its matching tool panel, just like the mode bar.
pub(crate) fn select_mode(&mut self, mode: Mode) {
self.mode = mode;
- self.left_open = true;
+ self.reveal_tools();
self.left = match mode {
Mode::Edit => LeftPanel::Tool("edit"),
Mode::Convert => LeftPanel::Tool("export"),
@@ -1276,12 +1286,18 @@ impl PdfCraftApp {
self.comments_panel_closed = true;
}
self.right = panel;
+ if panel.is_some() {
+ self.pending_panel_reveals.push(docking::Panel::Inspector);
+ }
}
/// Serialize the user's persistent state (recent files, theme). Local only.
pub fn persist(&self) -> String {
let trusted: Vec = self.session.trusted_certificates().iter().map(pdfcraft_engine::sign::x509::to_pem).collect();
serde_json::json!({
+ "docking": self.docking,
+ "docking_left_open": self.left_open,
+ "docking_right": self.right,
"recent": self.recent,
"reopen_last_session": self.reopen_last_session,
// Kept only while the preference is on.
@@ -1331,6 +1347,15 @@ impl PdfCraftApp {
/// Restore state written by `persist`. Unknown or malformed data is ignored.
pub fn restore(&mut self, json: &str) {
let Ok(v) = serde_json::from_str::(json) else { return };
+ if let Some(saved) = v.get("docking")
+ && let Ok(workspace) = serde_json::from_value::(saved.clone())
+ && workspace.validate().is_ok()
+ {
+ self.docking = workspace;
+ self.left_open =
+ v.get("docking_left_open").and_then(serde_json::Value::as_bool).unwrap_or(self.docking.layout.contains(&docking::Panel::Tools));
+ self.right = v.get("docking_right").and_then(|value| serde_json::from_value(value.clone()).ok()).flatten();
+ }
self.combine_columns = combine_ui::Columns::from_json(&v["combine_columns"]);
if let Ok(r) = serde_json::from_value::>(v["recent"].clone()) {
// Only keep entries whose files still exist.
@@ -1491,7 +1516,7 @@ impl PdfCraftApp {
("tool", _) => {
let g = pdfcraft_engine::catalog::group(value).ok_or_else(|| format!("unknown tool {value}"))?;
self.left = LeftPanel::Tool(g.id);
- self.left_open = true;
+ self.reveal_tools();
}
("left", _) => self.left_open = value != "closed",
("home", _) => self.active = None,
@@ -1672,6 +1697,12 @@ impl PdfCraftApp {
}
(other, _) => return Err(format!("unknown option {other}")),
}
+ if key == "panel" && self.right.is_some() {
+ self.pending_panel_reveals.push(docking::Panel::Inspector);
+ }
+ if key == "left" && self.left_open {
+ self.pending_panel_reveals.push(docking::Panel::Tools);
+ }
Ok(())
}
@@ -1937,18 +1968,18 @@ impl eframe::App for PdfCraftApp {
chrome::mode_bar(self, ui);
if self.active.is_some() {
chrome::right_rail(self, ui);
- if self.right.is_some() && self.mode != Mode::Read {
- panels::right_panel(self, ui);
- }
- }
- if self.left_open && self.mode != Mode::Read {
- panels::left_panel(self, ui);
}
let t = theme::Tokens::get(&ctx);
- egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| match self.active {
- None if self.combine_showing() => combine_ui::page(self, ui),
- None => home::show(self, ui),
- Some(i) => canvas::document_area(self, i, ui),
+ egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| {
+ if self.mode == Mode::Read {
+ match self.active {
+ None if self.combine_showing() => combine_ui::page(self, ui),
+ None => home::show(self, ui),
+ Some(index) => canvas::document_area(self, index, ui),
+ }
+ } else {
+ docking::show(self, ui);
+ }
});
self.process_pending_edits();
palette::show(self, &ctx);
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
index 64dd98a5..88b537ab 100644
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -16,22 +16,16 @@ fn hue(g: &ToolGroup) -> Color32 {
pub fn left_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
- egui::Panel::left("tool_panel")
- .resizable(false)
- .exact_size(272.0)
- .frame(
- egui::Frame::NONE
- .fill(t.panel)
- .inner_margin(egui::Margin { left: 14, right: 10, top: 12, bottom: 10 })
- .stroke(Stroke::new(1.0, t.divider)),
- )
- .show(ui, |ui| match app.left {
+ egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 14, right: 10, top: 12, bottom: 10 }).stroke(Stroke::new(1.0, t.divider)).show(
+ ui,
+ |ui| match app.left {
LeftPanel::AllTools => all_tools(app, ui, &t),
LeftPanel::Tool(id) => match catalog::group(id) {
Some(g) => tool_detail(app, ui, &t, g),
None => app.left = LeftPanel::AllTools,
},
- });
+ },
+ );
}
fn panel_header(ui: &mut egui::Ui, t: &Tokens, title: &str, back: bool) -> (bool, bool) {
@@ -414,16 +408,10 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let comment_allowed = doc.allows_annotation();
// A dialog or the palette owns the keyboard: the panel leaves Escape to it.
let modal = app.dialog.is_some() || app.palette_open;
- egui::Panel::right("right_panel")
- .resizable(true)
- .default_size(330.0)
- .size_range(260.0..=520.0)
- .frame(
- egui::Frame::NONE
- .fill(t.panel)
- .inner_margin(egui::Margin { left: 14, right: 12, top: 12, bottom: 8 })
- .stroke(Stroke::new(1.0, t.divider)),
- )
+ egui::Frame::NONE
+ .fill(t.panel)
+ .inner_margin(egui::Margin { left: 14, right: 12, top: 12, bottom: 8 })
+ .stroke(Stroke::new(1.0, t.divider))
.show(ui, |ui| {
let (title, count) = match panel {
RightPanel::Comments => ("Comments", Some(crate::comments_panel::count(info))),
diff --git a/crates/ui-egui/tests/bookmarks.rs b/crates/ui-egui/tests/bookmarks.rs
index ff02b155..846ba59f 100644
--- a/crates/ui-egui/tests/bookmarks.rs
+++ b/crates/ui-egui/tests/bookmarks.rs
@@ -100,7 +100,7 @@ fn new_rename_reorder_indent_delete_and_undo() {
/// The side panel's width as egui keeps it from one frame to the next.
fn panel_width(h: &Harness<'static, PdfCraftApp>) -> f32 {
- egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("right_panel")).expect("the side panel is shown").size().x
+ h.ctx.data(|data| data.get_temp::(egui::Id::new("pdfcraft-inspector-body"))).expect("the inspector body is shown").width()
}
#[test]
diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs
index ae5eb1c7..b2ba694c 100644
--- a/crates/ui-egui/tests/comments.rs
+++ b/crates/ui-egui/tests/comments.rs
@@ -266,7 +266,7 @@ fn the_panel_posts_comments_and_replies() {
/// The side panel's width as egui keeps it from one frame to the next.
fn panel_width(h: &Harness<'static, PdfCraftApp>) -> f32 {
- egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("right_panel")).expect("the side panel is shown").size().x
+ h.ctx.data(|data| data.get_temp::(egui::Id::new("pdfcraft-inspector-body"))).expect("the inspector body is shown").width()
}
#[test]
diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs
index e4833f62..3ca3cb61 100644
--- a/crates/ui-egui/tests/control.rs
+++ b/crates/ui-egui/tests/control.rs
@@ -4,7 +4,7 @@
use std::sync::{Arc, Mutex};
use egui_kittest::Harness;
-use egui_kittest::kittest::Queryable;
+use egui_kittest::kittest::{NodeT, Queryable};
use pdfcraft_ui_egui::PdfCraftApp;
use pdfcraft_ui_egui::control::{ControlClient, Reply};
use serde_json::{Value, json};
@@ -39,9 +39,13 @@ fn harness() -> (Harness<'static, PdfCraftApp>, ControlClient) {
}
fn harness_pages(pages: usize) -> (Harness<'static, PdfCraftApp>, ControlClient) {
+ harness_pages_at(pages, 1400.0, 1.0)
+}
+
+fn harness_pages_at(pages: usize, width: f32, scale: f32) -> (Harness<'static, PdfCraftApp>, ControlClient) {
let slot: Arc>> = Arc::default();
let s = slot.clone();
- let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |cc| {
+ let mut h = Harness::builder().with_size(egui::vec2(width, 900.0)).with_pixels_per_point(scale).build_eframe(move |cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
*s.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx));
@@ -69,6 +73,155 @@ fn ok(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, pa
call(h, c, method, params).unwrap_or_else(|e| panic!("{method}: {e}"))
}
+#[test]
+fn control_resize_reports_observed_dimensions_and_rejects_invalid_sizes() {
+ let (mut h, c) = harness();
+ for params in [
+ json!({}),
+ json!({"width": 800}),
+ json!({"width": "800", "height": 640}),
+ json!({"width": -1, "height": 640}),
+ json!({"width": 0, "height": 640}),
+ json!({"width": 800, "height": 319}),
+ json!({"width": 8193, "height": 640}),
+ json!({"width": 1e300, "height": 640}),
+ json!({"width": null, "height": 640}),
+ json!({"width": 800, "height": 640, "force": true}),
+ json!([]),
+ ] {
+ assert!(call(&mut h, &c, "ui.resize", params).is_err());
+ let window = ok(&mut h, &c, "ui.state", json!({}))["window"].clone();
+ assert_eq!(window["width"], 1400.0);
+ assert_eq!(window["height"], 900.0);
+ }
+ let requested = ok(&mut h, &c, "ui.resize", json!({"width": 800, "height": 640}));
+ assert_eq!(requested["requested"], json!([800.0, 640.0]));
+ let state = ok(&mut h, &c, "ui.state", json!({}));
+ assert_eq!(state["window"]["width"], 800.0);
+ assert_eq!(state["window"]["height"], 640.0);
+ assert_eq!(state["documents"][0]["pages"], 5);
+ assert_eq!(state["documents"][0]["dirty"], false);
+}
+
+#[test]
+fn control_focus_traverses_real_widgets_and_keyboard_activates() {
+ let (mut h, c) = harness();
+ ok(&mut h, &c, "ui.focus", json!({"label": "Read"}));
+ let read = ok(&mut h, &c, "ui.inspect", json!({"query": "Read", "role": "Button"}));
+ assert_eq!(read["widgets"][0]["focused"], true, "{read}");
+ assert_eq!(read["widgets"][0]["focusable"], true);
+ ok(&mut h, &c, "ui.key", json!({"key": "Tab"}));
+ let edit = ok(&mut h, &c, "ui.inspect", json!({"query": "Edit", "role": "Button"}));
+ assert!(edit["widgets"].as_array().unwrap().iter().any(|w| w["label"] == "Edit" && w["focused"] == true), "{edit}");
+ ok(&mut h, &c, "ui.key", json!({"key": "Tab", "modifiers": ["shift"]}));
+ let read = ok(&mut h, &c, "ui.inspect", json!({"query": "Read", "role": "Button"}));
+ assert_eq!(read["widgets"][0]["focused"], true);
+ ok(&mut h, &c, "ui.key", json!({"key": "Enter"}));
+ let state = ok(&mut h, &c, "ui.state", json!({}));
+ assert_eq!(state["mode"], "Read");
+ assert_eq!(state["documents"][0]["dirty"], false);
+}
+
+#[test]
+fn control_button_focus_preserves_document_undo_redo_shortcuts() {
+ let (mut h, c) = harness_pages(2);
+ ok(&mut h, &c, "ui.focus", json!({"label": "Read"}));
+ ok(&mut h, &c, "ui.key", json!({"key": "Enter"}));
+ ok(&mut h, &c, "ui.click", json!({"label": "All tools"}));
+ assert!(h.ctx.egui_wants_keyboard_input());
+ assert!(!h.ctx.text_edit_focused());
+ ok(&mut h, &c, "ui.command", json!({"id": "page.insert_blank"}));
+ assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["pages"], 3);
+ ok(&mut h, &c, "ui.key", json!({"key": "Z", "modifiers": ["command"]}));
+ assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["pages"], 2);
+ ok(&mut h, &c, "ui.key", json!({"key": "Z", "modifiers": ["command", "shift"]}));
+ assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["pages"], 3);
+}
+
+#[test]
+fn control_text_focus_retains_text_undo_ownership() {
+ let (mut h, c) = harness_pages(2);
+ h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index: 0, title: "Target".into(), page: 0 });
+ ok(&mut h, &c, "ui.command", json!({"id": "page.insert_blank"}));
+ ok(&mut h, &c, "ui.set", json!({"key": "panel", "value": "bookmarks"}));
+ let widgets = ok(&mut h, &c, "ui.inspect", json!({"query": "Search", "role": "TextInput"}));
+ ok(&mut h, &c, "ui.focus", json!({"id": widgets["widgets"][0]["id"]}));
+ assert!(h.ctx.text_edit_focused());
+ ok(&mut h, &c, "ui.type", json!({"text": "target"}));
+ let search_id = egui::Id::new(("bookmark_search", h.state().views[0].id.0));
+ assert_eq!(h.ctx.data(|d| d.get_temp::(search_id)).as_deref(), Some("target"));
+ ok(&mut h, &c, "ui.key", json!({"key": "Z", "modifiers": ["command"]}));
+ assert_eq!(h.ctx.data(|d| d.get_temp::(search_id)).as_deref(), Some(""));
+ assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["pages"], 3);
+}
+
+#[test]
+fn control_focus_rejects_missing_ambiguous_disabled_and_nonfocusable_targets() {
+ let (mut h, c) = harness();
+ for params in [
+ json!({}),
+ json!({"label": "does not exist"}),
+ json!({"id": "not a number"}),
+ json!({"id": 0}),
+ json!({"id": 1, "label": "Read"}),
+ json!({"label": false}),
+ json!({"label": "Read", "force": true}),
+ json!([]),
+ ] {
+ assert!(call(&mut h, &c, "ui.focus", params).is_err());
+ }
+ let widgets = ok(&mut h, &c, "ui.inspect", json!({}));
+ let nonfocusable = widgets["widgets"].as_array().unwrap().iter().find(|w| w["focusable"] == false).unwrap();
+ assert!(call(&mut h, &c, "ui.focus", json!({"id": nonfocusable["id"]})).is_err());
+ let read = widgets["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "Read").unwrap();
+ ok(&mut h, &c, "ui.focus", json!({"id": read["id"]}));
+ assert_eq!(ok(&mut h, &c, "ui.inspect", json!({"query": "Read", "role": "Button"}))["widgets"][0]["focused"], true);
+ // Home has no active document, so its document-only toolbar controls are disabled.
+ ok(&mut h, &c, "ui.click", json!({"label": "Home"}));
+ assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["home"], true);
+ let widgets = ok(&mut h, &c, "ui.inspect", json!({}));
+ let disabled = widgets["widgets"].as_array().unwrap().iter().find(|w| w["enabled"] == false).unwrap();
+ assert!(call(&mut h, &c, "ui.focus", json!({"id": disabled["id"]})).is_err());
+}
+
+fn close_requested(h: &Harness<'static, PdfCraftApp>) -> bool {
+ h.output().viewport_output.values().any(|v| v.commands.iter().any(|c| matches!(c, egui::ViewportCommand::Close)))
+}
+
+#[test]
+fn control_quit_preserves_dirty_documents_and_cancel_keeps_them_open() {
+ let (mut h, c) = harness();
+ ok(&mut h, &c, "ui.command", json!({"id": "page.insert_blank"}));
+ for params in [json!({"force": true}), json!({"discard": true}), json!([])] {
+ assert!(call(&mut h, &c, "ui.quit", params).is_err());
+ assert!(!close_requested(&h));
+ }
+ assert_eq!(ok(&mut h, &c, "ui.quit", json!({})), json!({"quitting": false, "needs_confirmation": true}));
+ assert!(!close_requested(&h));
+ assert_eq!(ok(&mut h, &c, "ui.quit", Value::Null), json!({"quitting": false, "needs_confirmation": true}));
+ assert!(!close_requested(&h));
+ let state = ok(&mut h, &c, "ui.state", json!({}));
+ assert_eq!(state["close_prompt"], true);
+ assert_eq!(state["documents"][0]["dirty"], true);
+ assert_eq!(state["documents"][0]["pages"], 6);
+ ok(&mut h, &c, "ui.click", json!({"label": "Cancel"}));
+ let state = ok(&mut h, &c, "ui.state", json!({}));
+ assert_eq!(state["close_prompt"], false);
+ assert_eq!(state["documents"].as_array().unwrap().len(), 1);
+ assert_eq!(state["documents"][0]["dirty"], true);
+ assert_eq!(state["documents"][0]["pages"], 6);
+ assert!(!close_requested(&h));
+}
+
+#[test]
+fn control_quit_clean_document_requests_normal_window_close() {
+ for params in [json!({}), Value::Null] {
+ let (mut h, c) = harness();
+ assert_eq!(ok(&mut h, &c, "ui.quit", params), json!({"quitting": true, "needs_confirmation": false}));
+ assert!(close_requested(&h));
+ }
+}
+
#[test]
fn bookmark_titles_can_be_searched_over_control() {
let (mut h, c) = harness();
@@ -1025,3 +1178,236 @@ fn measurement_tools_draw_live_calibrate_save_and_export() {
assert!((h.state().views[0].measure.drawing_points - 100.0).abs() < 0.01);
let _ = std::fs::remove_dir_all(dir);
}
+
+#[test]
+fn docking_pointer_group_float_close_restore_and_document_commands() {
+ use pdfcraft_ui_egui::docking::Panel;
+ let (mut h, c) = harness();
+ ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"}));
+ h.run_steps(4);
+ let from = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").rect().center();
+ let to = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").rect().center();
+ h.event(egui::Event::PointerMoved(from));
+ h.run_steps(2);
+ h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
+ h.run_steps(2);
+ for step in 1..=8 {
+ h.event(egui::Event::PointerMoved(from.lerp(to, step as f32 / 8.0)));
+ h.run_steps(1);
+ }
+ h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
+ h.run_steps(4);
+ assert_eq!(h.state().docking.layout.location(&Panel::Inspector).unwrap().anchor, Some(Panel::Tools));
+ h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").click_button(egui::PointerButton::Secondary);
+ h.run_steps(3);
+ h.get_by_label("Float panel").click();
+ h.run_steps(5);
+ let rect = h.state().docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Inspector)).unwrap().rect;
+ h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").click_button(egui::PointerButton::Secondary);
+ h.run_steps(3);
+ // Scope to the open context menu; the Tools body also has a close icon.
+ h.get_by_label("Float panel").parent().unwrap().get_by_label("Close panel").click();
+ h.run_steps(4);
+ assert!(!h.state().docking.layout.contains(&Panel::Inspector));
+ ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"}));
+ h.run_steps(4);
+ assert_eq!(h.state().docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Inspector)).unwrap().rect, rect);
+ let saved = h.state().persist();
+ let expected = serde_json::to_value(&h.state().docking).unwrap();
+ let (mut reloaded, client) = harness();
+ reloaded.state_mut().restore(&saved);
+ reloaded.run_steps(5);
+ assert_eq!(serde_json::to_value(&reloaded.state().docking).unwrap(), expected);
+ ok(&mut reloaded, &client, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"}));
+ reloaded.run_steps(4);
+ ok(&mut reloaded, &client, "ui.command", json!({"id":"page.insert_blank"}));
+ assert_eq!(ok(&mut reloaded, &client, "ui.state", json!({}))["documents"][0]["pages"], 6);
+ ok(&mut reloaded, &client, "ui.command", json!({"id":"edit.undo"}));
+ assert_eq!(ok(&mut reloaded, &client, "ui.state", json!({}))["documents"][0]["pages"], 5);
+}
+
+#[test]
+fn invalid_docking_requests_are_atomic_and_do_not_change_documents() {
+ let (mut h, c) = harness();
+ let before = serde_json::to_value(&h.state().docking).unwrap();
+ for params in [
+ json!({"operation":"close", "panel":"canvas"}),
+ json!({"operation":"move", "panel":"tools", "target":"canvas", "zone":"center"}),
+ json!({"operation":"float", "panel":"tools", "rect":[0,0,-1,100]}),
+ json!({"operation":"move", "panel":"unknown", "target":"tools", "zone":"center"}),
+ ] {
+ assert!(call(&mut h, &c, "ui.dock", params).is_err());
+ assert_eq!(serde_json::to_value(&h.state().docking).unwrap(), before);
+ let state = ok(&mut h, &c, "ui.state", json!({}));
+ assert_eq!(state["documents"][0]["pages"], 5);
+ assert_eq!(state["documents"][0]["dirty"], false);
+ }
+}
+
+#[test]
+fn docking_renders_real_pdf_default_floating_and_redocked() {
+ for width in [900.0, 1400.0] {
+ for scale in [1.0, 2.0] {
+ let (mut h, c) = harness_pages_at(5, width, scale);
+ ok(&mut h, &c, "ui.resize", json!({"width":width,"height":900}));
+ ok(&mut h, &c, "ui.set", json!({"key":"panel","value":"bookmarks"}));
+ h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index: 0, title: "Overview".into(), page: 0 });
+ h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index: 1, title: "Details".into(), page: 2 });
+ for state in ["default", "floating", "redocked"] {
+ if state == "floating" {
+ ok(&mut h, &c, "ui.dock", json!({"operation":"float", "panel":"inspector", "rect":[160,100,330,480]}));
+ } else if state == "redocked" {
+ ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"}));
+ }
+ h.run_steps(5);
+ h.state().docking.validate().unwrap();
+ if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") {
+ std::fs::create_dir_all(&directory).unwrap();
+ h.render().unwrap().save(format!("{directory}/pdf-{width}-{scale}-{state}.png")).unwrap();
+ }
+ }
+ }
+ }
+}
+
+#[test]
+fn explicit_panel_requests_reveal_inactive_group_members() {
+ let (mut h, c) = harness();
+ ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"}));
+ ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"}));
+ ok(&mut h, &c, "ui.dock", json!({"operation":"activate", "panel":"tools"}));
+ ok(&mut h, &c, "ui.command", json!({"id":"form.fields"}));
+ h.run_steps(4);
+ h.get_by_label("Fields");
+ h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").click();
+ h.run_steps(4);
+ // Ordinary frames must preserve the user's selected tab.
+ h.get_by_label("View more");
+ ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"}));
+ h.run_steps(4);
+ h.get_by_label("This document has no bookmarks.");
+ ok(&mut h, &c, "ui.command", json!({"id":"edit.edit_text"}));
+ h.run_steps(4);
+ h.get_by_label("Edit text & images");
+}
+
+#[test]
+fn home_and_combine_suppress_inspector_without_forgetting_layout_or_choice() {
+ use pdfcraft_ui_egui::docking::Panel;
+ let (mut h, c) = harness();
+ ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"}));
+ ok(&mut h, &c, "ui.dock", json!({"operation":"float", "panel":"inspector", "rect":[170,120,330,470]}));
+ let before = serde_json::to_value(&h.state().docking).unwrap();
+ for combine in [false, true] {
+ if combine {
+ h.state_mut().open_combine_tab();
+ } else {
+ h.state_mut().active = None;
+ }
+ h.run_steps(4);
+ assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").is_none());
+ if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") {
+ std::fs::create_dir_all(&directory).unwrap();
+ let state = if combine { "combine" } else { "home" };
+ h.render().unwrap().save(format!("{directory}/availability-{state}.png")).unwrap();
+ }
+ assert!(h.state().docking.layout.contains(&Panel::Inspector));
+ assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks));
+ assert_eq!(serde_json::to_value(&h.state().docking).unwrap(), before);
+ h.state_mut().active = Some(0);
+ h.state_mut().combine_tab.focused = false;
+ h.run_steps(4);
+ h.get_by_label("This document has no bookmarks.");
+ if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") {
+ h.render().unwrap().save(format!("{directory}/availability-return.png")).unwrap();
+ }
+ assert_eq!(serde_json::to_value(&h.state().docking).unwrap(), before);
+ }
+}
+
+#[test]
+fn malformed_docking_settings_preserve_other_preferences() {
+ let mut app = PdfCraftApp::new();
+ app.restore(r#"{"docking":{"layout":{"root":null,"floating":[]},"hidden":[]},"default_mode":"edit","flatten_fill_sign":true}"#);
+ assert_eq!(app.default_mode, pdfcraft_ui_egui::Mode::Edit);
+ app.docking.validate().unwrap();
+ let saved: Value = serde_json::from_str(&app.persist()).unwrap();
+ assert_eq!(saved["default_mode"], "edit");
+ assert_eq!(saved["flatten_fill_sign"], true);
+}
+
+#[test]
+fn rail_reveals_its_remembered_inactive_inspector_then_closes_the_visible_panel() {
+ use pdfcraft_ui_egui::docking::Panel;
+ let (mut h, c) = harness();
+ ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"}));
+ ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"}));
+ ok(&mut h, &c, "ui.dock", json!({"operation":"activate", "panel":"tools"}));
+ h.run_steps(3);
+ let document = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone();
+ let mut preferences: Value = serde_json::from_str(&h.state().persist()).unwrap();
+ preferences.as_object_mut().unwrap().remove("docking");
+ assert!(h.query_by_label("This document has no bookmarks.").is_none());
+ h.get_by_role_and_label(egui::accesskit::Role::Button, "Bookmarks").click();
+ h.run_steps(4);
+ h.get_by_label("This document has no bookmarks.");
+ let mut after: Value = serde_json::from_str(&h.state().persist()).unwrap();
+ after.as_object_mut().unwrap().remove("docking");
+ assert_eq!(after, preferences, "revealing a group member only changes layout activation");
+ assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"], document);
+ h.get_by_role_and_label(egui::accesskit::Role::Button, "Bookmarks").click();
+ h.run_steps(4);
+ assert_eq!(h.state().right, None);
+ assert!(!h.state().docking.layout.contains(&Panel::Inspector));
+ assert!(h.query_by_label("This document has no bookmarks.").is_none());
+ assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"], document);
+}
+
+#[test]
+fn control_quit_retains_inactive_fill_sign_typing_after_cancel() {
+ let (mut h, c) = harness();
+ h.state_mut().views[0].fill_text =
+ Some(pdfcraft_ui_egui::fill_sign::TypeBox { page: 0, at: [40.0, 200.0], text: "Unsaved signature note".into(), focus: false });
+ h.state_mut().active = None;
+ assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
+ assert_eq!(ok(&mut h, &c, "ui.quit", json!({})), json!({"quitting":false,"needs_confirmation":true}));
+ assert!(!close_requested(&h));
+ h.run_steps(3);
+ ok(&mut h, &c, "ui.click", json!({"label":"Cancel"}));
+ assert_eq!(h.state().views[0].fill_text.as_ref().unwrap().text, "Unsaved signature note");
+ assert_eq!(h.state().views.len(), 1);
+ assert!(!close_requested(&h));
+}
+
+#[test]
+fn control_quit_retains_blocked_content_typing_after_cancel() {
+ let (mut h, c) = harness();
+ ok(&mut h, &c, "ui.command", json!({"id":"edit.text"}));
+ h.run_steps(3);
+ let point = h.state().views[0].page_screen_rect(0).unwrap().center();
+ ok(&mut h, &c, "ui.click", json!({"x":point.x,"y":point.y}));
+ h.event(egui::Event::Text("Keep 世界".into()));
+ h.run_steps(3);
+ assert_eq!(h.state().views[0].content.draft.as_ref().unwrap().text, "Keep 世界");
+ h.state_mut().active = None;
+ assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
+ assert_eq!(ok(&mut h, &c, "ui.quit", json!({})), json!({"quitting":false,"needs_confirmation":true}));
+ h.run_steps(3);
+ ok(&mut h, &c, "ui.click", json!({"label":"Cancel"}));
+ assert_eq!(h.state().views[0].content.draft.as_ref().unwrap().text, "Keep 世界");
+ assert_eq!(h.state().views.len(), 1);
+ assert!(!close_requested(&h));
+}
+
+#[test]
+fn read_mode_retains_existing_rail_click_to_close_policy() {
+ let (mut h, c) = harness();
+ ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"}));
+ ok(&mut h, &c, "ui.set", json!({"key":"mode", "value":"read"}));
+ h.run_steps(3);
+ assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks));
+ h.get_by_role_and_label(egui::accesskit::Role::Button, "Bookmarks").click();
+ h.run_steps(3);
+ assert_eq!(h.state().right, None);
+ assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Read);
+}
diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs
index 760245bc..5dd4371f 100644
--- a/crates/ui-egui/tests/ui.rs
+++ b/crates/ui-egui/tests/ui.rs
@@ -1,7 +1,7 @@
//! Headless UI tests (egui_kittest + AccessKit). They drive the real app shell without a window.
use egui_kittest::Harness;
-use egui_kittest::kittest::Queryable;
+use egui_kittest::kittest::{NodeT, Queryable};
use pdfcraft_ui_egui::PdfCraftApp;
/// A tiny PDF with two pages, two bookmarks and one sticky note.
@@ -706,7 +706,7 @@ fn three_tab_harness() -> Harness<'static, PdfCraftApp> {
fn close_named_tab(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
// The close button has no separate accessible label. Its centre is defined by
// chrome::tab relative to the actual accessible tab rectangle, not a viewport guess.
- let pos = h.get_by_label(name).rect().right_center() - egui::vec2(16.0, 0.0);
+ let pos = h.get_by_role_and_label(egui::accesskit::Role::Tab, name).rect().right_center() - egui::vec2(16.0, 0.0);
h.event(egui::Event::PointerMoved(pos));
h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.run_steps(1);
@@ -785,3 +785,72 @@ fn cancelling_then_discarding_an_earlier_dirty_tab_preserves_the_selected_docume
assert!(!doc.dirty);
}
}
+
+#[test]
+fn document_tabs_expose_selection_and_activate_from_keyboard() {
+ use egui::accesskit::Role;
+ for key in [egui::Key::Enter, egui::Key::Space] {
+ let mut h = three_tab_harness();
+ let first = h.get_by_role_and_label(Role::Tab, "first.pdf");
+ assert_eq!(first.accesskit_node().is_selected(), Some(false));
+ assert_eq!(h.get_by_role_and_label(Role::Tab, "last.pdf").accesskit_node().is_selected(), Some(true));
+ first.focus();
+ h.run_steps(2);
+ assert!(h.get_by_role_and_label(Role::Tab, "first.pdf").is_focused());
+ h.key_press(key);
+ h.run_steps(3);
+ assert_eq!(h.state().active, Some(0));
+ assert_eq!(h.get_by_role_and_label(Role::Tab, "first.pdf").accesskit_node().is_selected(), Some(true));
+ assert_eq!(h.get_by_role_and_label(Role::Tab, "last.pdf").accesskit_node().is_selected(), Some(false));
+ assert_eq!(h.state().views.len(), 3, "activation does not hit the close button");
+ for doc in h.state().session.docs() {
+ assert_eq!(doc.bytes.as_slice(), FIXTURE);
+ assert!(!doc.dirty);
+ }
+ if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") {
+ settle(&mut h);
+ h.render().unwrap().save(format!("{dir}/focused-tab-{key:?}.png")).unwrap();
+ }
+ }
+}
+
+#[test]
+fn document_tab_identity_survives_dirty_labels_and_removing_an_earlier_document() {
+ use egui::accesskit::Role;
+ let mut h = three_tab_harness();
+ h.get_by_role_and_label(Role::Tab, "middle.pdf").click();
+ h.run_steps(3);
+ h.get_by_role_and_label(Role::Tab, "middle.pdf").focus();
+ h.run_steps(2);
+ let middle_id = h.get_by_role_and_label(Role::Tab, "middle.pdf").accesskit_node().id();
+ assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetInfo { key: "Title".into(), value: "Unsaved title".into() }));
+ h.run_steps(3);
+ let dirty = h.get_by_role_and_label(Role::Tab, "middle.pdf (edited)");
+ assert_eq!(dirty.accesskit_node().id(), middle_id);
+ assert!(dirty.is_focused());
+ close_named_tab(&mut h, "first.pdf");
+ assert_eq!(h.get_by_role_and_label(Role::Tab, "middle.pdf (edited)").accesskit_node().id(), middle_id);
+ assert_eq!(h.state().active, Some(0));
+ assert_eq!(h.state().views.len(), 2);
+ assert!(h.state().session.get(h.state().views[0].id).unwrap().dirty);
+}
+
+#[test]
+fn combine_tab_uses_tab_interaction_without_changing_close_or_document_state() {
+ use egui::accesskit::Role;
+ let mut h = three_tab_harness();
+ let document_ids: Vec<_> = h.state().views.iter().map(|v| v.id).collect();
+ h.state_mut().execute("page.combine");
+ h.run_steps(3);
+ assert_eq!(h.get_by_role_and_label(Role::Tab, "Combine files").accesskit_node().is_selected(), Some(true));
+ h.get_by_role_and_label(Role::Tab, "first.pdf").click();
+ h.run_steps(3);
+ h.get_by_role_and_label(Role::Tab, "Combine files").focus();
+ h.run_steps(2);
+ h.key_press(egui::Key::Enter);
+ h.run_steps(3);
+ assert_eq!(h.get_by_role_and_label(Role::Tab, "Combine files").accesskit_node().is_selected(), Some(true));
+ close_named_tab(&mut h, "Combine files");
+ assert!(h.query_by_role_and_label(Role::Tab, "Combine files").is_none());
+ assert_eq!(h.state().views.iter().map(|v| v.id).collect::>(), document_ids);
+}
diff --git a/deny.toml b/deny.toml
index 0c2dedce..cb39d680 100644
--- a/deny.toml
+++ b/deny.toml
@@ -32,6 +32,8 @@ allow-wildcard-paths = true
[sources]
unknown-registry = "deny"
unknown-git = "deny"
+# Shared UI components; Cargo.toml pins the immutable revision.
+allow-git = ["https://github.com/storytold/craft-libs"]
[advisories]
yanked = "warn"
diff --git a/docs/development.md b/docs/development.md
index 23565c55..168197ab 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -3,6 +3,9 @@
Working instructions for agents and contributors are in `AGENTS.md` and `CLAUDE.md`. This page
collects what the desktop app reads from its environment and where it writes its diagnostics.
+The UI crate and complete desktop/web builds require Rust 1.95 or later, matching the pinned
+`craft-ui` dependency. The workspace's core-crate minimum is unchanged.
+
## Logs
The desktop app writes its `log` records to standard error and to `logs/pdfcraft.log` in its
diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs
index 5cb33439..72a199c1 100644
--- a/xtask/src/layers.rs
+++ b/xtask/src/layers.rs
@@ -106,7 +106,7 @@ pub const SIDEWAYS: &[(&str, &str)] = &[
];
/// External crates that constitute a UI toolkit / windowing dependency (prefix match with `*`).
-pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "egui_extras", "egui_dock", "egui_tiles", "rfd", "wgpu*"];
+pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "craft-ui", "egui_extras", "egui_dock", "egui_tiles", "rfd", "wgpu*"];
pub const UI_MIN_LAYER: u8 = 7;
pub fn short_name(pkg: &str) -> &str {
@@ -305,11 +305,12 @@ mod tests {
#[test]
fn ui_crates_below_l7_flagged() {
- for dep in ["egui", "eframe", "winit", "rfd", "wgpu", "wgpu-core", "egui_dock"] {
+ for dep in ["egui", "eframe", "winit", "rfd", "wgpu", "wgpu-core", "egui_dock", "craft-ui"] {
let v = check(&[c("pdfcraft-engine", &[(dep, Normal, false)])]);
assert!(matches!(v[..], [Violation::UiBelowL7 { layer: 6, .. }]), "{dep}");
}
assert!(check(&[c("pdfcraft-platform", &[("winit", Normal, false)])]).is_empty());
+ assert!(check(&[c("pdfcraft-ui-egui", &[("craft-ui", Normal, false)])]).is_empty());
}
#[test]