Merge pull request #966 from skylartaylor/codex/ui-suite-integration-20261010
Adopt shared panel docking with document-aware visibility
This commit is contained in:
commit
ae4f5d44f3
|
|
@ -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 = "b568edd80dfce809d1e8fbdad9f2c30e1fabc04d631a06dc154fd8e799ede3e0"
|
||||
sha256 = "500ba36d4a37162d935a1b5e2b16eff5b2011c6aa9f9285a87d46807acdb97e6"
|
||||
|
||||
[[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 = "cbaf398cd35b4667ceffe4f93df014289da9ea5711eb936f0315b6029bf93681"
|
||||
sha256 = "57c35909b2f19f16ff0497d110abbf1c79a5627d2a98f95706e726c59348d09e"
|
||||
|
||||
[[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 = "8e38ce2add4bc43b972b83d78046fe0fe1c9d2eb85d0092a73867eea86ad2691"
|
||||
sha256 = "abd8d0c409be846fcf48cbc1338def7f77f2d301bd84d863e4ad66434e5f821f"
|
||||
|
||||
[[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 = "0c9fc284430ebf75561ec92783b63f5a9cc5d8eaa077e9b31c65d429eb40b69f"
|
||||
sha256 = "130059ef7014f1dfb59ec8dc2435657d5158abca2d1825959c6366288d28ea92"
|
||||
|
||||
[[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 = "9e15919fd57382776ffd2a09d0d2985ceeba526a3ef28fd58568c32aed43fc53"
|
||||
sha256 = "c67b49f2caa527fe5a241b1046f582b83f324ac86945319fd489971c038acd9b"
|
||||
|
||||
[[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 = "df37b79a0c7e3d6ed171264103c90cd3fd48ee34e8a1fe9b1dd132947e709bd1"
|
||||
sha256 = "4e8e7f71dd896c58ad2bcad8d8c57ae8c9933611cff412985eb32845821f8899"
|
||||
|
||||
[[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 = "3923ec15c37e091793174b3546fce68bd5560cb7206a993d72f82ff878f66c07"
|
||||
sha256 = "09db52c316a7e9312a30d80ce4e385f2c957096563c509b7b6ed2bcc95bf39d8"
|
||||
|
||||
[[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 = "cf915289e47679e6a778c7924863cbed25fcf00c1a8190379e1afeb10f411fbd"
|
||||
sha256 = "21767432503afac87196a87557637692dcec76c0eba02a1dcf6bd1294926107a"
|
||||
|
||||
[[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 = "b7ec9e05598242f3b95a5edf4680f4faa437ea099297809a693452e7094d41c8"
|
||||
sha256 = "58f9514a06c74a7cdf90c6f7fb86ebb092af39857633f0779b44f0867a1c96e0"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pl.tsv"
|
||||
|
|
@ -129,7 +129,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 = "9db76f9f7f22a3f93b213f3931ba30036a2a60ac9c0b65c2fddc6c0a6dd4751c"
|
||||
sha256 = "e780ffd93f9606c1a762862a961c3a82bde948a8a5f116b46e6f78ba84ac6ff4"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
|
|
@ -140,7 +140,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 = "846ade5369c59ce68cd3be1722645d82de21131125606f20aeec134b2ef1412e"
|
||||
sha256 = "ebbd6cd7bc586ca253047f7aa3b5c34cd9ecb29645afc3d9d2097c35277ffddc"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
|
|
@ -151,7 +151,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 = "2737dcd01e375b7d207c92473a4adc97eef23ebf1bbc15d69357f89a072a366f"
|
||||
sha256 = "b760879b720c1d6de0dc9286ab044bebe4360962dece93060d7041b8955be7b2"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
|
|
@ -162,7 +162,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 = "0313526d0ba0c1b28f9d79bdae6c85279df65fd0beb9a53522e847316225339d"
|
||||
sha256 = "b07366d224bd5a394f705c17fa5d26e4f45d8903927e5dc18ed12e7713751d0a"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/uk.tsv"
|
||||
|
|
@ -173,7 +173,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 = "25527729c4119d520b5ae1d3201283d8baca17bcc14706a26adf82479a0499af"
|
||||
sha256 = "97c79be066c982347b48556a19b71e0db67a7f46932d1ce342b2ba5d29bf4582"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ar.tsv"
|
||||
|
|
@ -184,7 +184,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 = "7de1152206c40c411002bbb444533dfef3ee2e5418219a6e3059c2a53a4bf7d6"
|
||||
sha256 = "f5e369934bf2ceca49224bf57f72289f0c82fc4c81b7c6363676d39362750d17"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -3910,6 +3919,7 @@ dependencies = [
|
|||
"arboard",
|
||||
"base64 0.22.1",
|
||||
"bytemuck",
|
||||
"craft-ui",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
|
|
|
|||
|
|
@ -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.6.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.6.0" }
|
||||
|
|
|
|||
28
README.md
28
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
|
||||
|
|
@ -536,3 +553,14 @@ Forks and modified versions must remove them.
|
|||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
||||
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.
|
||||
|
|
|
|||
10
ROADMAP.md
10
ROADMAP.md
|
|
@ -169,9 +169,14 @@ Newest first: the date, what moved, and the new overall estimate.
|
|||
- **2026-10-09 (M2, JPX sample normalization):** JPEG 2000 rescaling uses bounded integer arithmetic for supported 1–32-bit samples, including the 32-bit boundary that previously shifted past the integer width. Zero-sized rows are refused before scaling. Small contributor-original codestreams exercise decoding and rendering; endpoint and standard-depth regressions cover the same helper. Overall estimate unchanged (about 30–35%).
|
||||
- **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%).
|
||||
- **2026-10-09 (M2, bounded page labels):** Inspector number trees have shared work, depth, and cycle limits; prefixes are bounded before decoding and complete custom labels have per-label and aggregate UTF-8 byte caps. A refused tree leaves physical page numbers intact and records one warning, with custom labels applied only after every range succeeds. Pure COS Roman and repeated-letter formatters are shared with Organize; label queries and edits return errors for unsupported output before changing the stored tree, while ordinary labels and prefix-only ranges remain supported. Synthetic tests cover safe just-over-limit cases, large first numbers, aliases, cycles, wide/deep trees, UTF-16/UTF-8 boundaries, aggregate output, and unchanged refused edits. Milestone and overall effort estimates unchanged (about 30–35%).
|
||||
|
|
@ -303,6 +308,9 @@ Newest first: the date, what moved, and the new overall estimate.
|
|||
- Overall ≈ 3–4%.
|
||||
- **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.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|
|
|
|||
|
|
@ -209,6 +209,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"),
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<f32> {
|
|||
#[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);
|
||||
|
|
|
|||
|
|
@ -142,6 +142,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.
|
||||
|
|
@ -253,7 +258,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)
|
||||
|
|
@ -269,7 +274,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 });
|
||||
}
|
||||
|
|
@ -359,7 +364,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" => {
|
||||
|
|
@ -367,7 +372,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(),
|
||||
|
|
@ -377,7 +382,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" => {
|
||||
|
|
@ -391,7 +396,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)
|
||||
|
|
@ -442,7 +447,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),
|
||||
|
|
@ -458,9 +463,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
|
||||
|
|
@ -494,8 +499,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())
|
||||
|
|
@ -527,7 +532,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;
|
||||
}
|
||||
|
|
@ -594,7 +599,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.)
|
||||
|
|
|
|||
|
|
@ -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<Handled, String> = (|| 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<f32, String> {
|
||||
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<f32, String> {
|
||||
|
|
@ -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<Handled, String> {
|
||||
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<NodeId> = 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<egui::PointerButton, String> {
|
||||
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<egui::Rect>, 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<Value> = 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,
|
||||
|
|
@ -624,10 +720,11 @@ impl Host for crate::PdfCraftApp {
|
|||
}
|
||||
|
||||
fn commands(&self) -> Value {
|
||||
let list: Vec<Value> = pdfcraft_engine::commands::COMMANDS
|
||||
let mut list: Vec<Value> = 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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, String> {
|
||||
Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn permissions(panel: &Panel) -> Permissions {
|
||||
if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() }
|
||||
}
|
||||
|
||||
fn tabs(panel: Panel) -> Node<Panel> {
|
||||
Node::Tabs { panels: vec![panel], active: 0 }
|
||||
}
|
||||
|
||||
fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Panel>) -> Node<Panel> {
|
||||
Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Workspace {
|
||||
pub layout: Layout<Panel>,
|
||||
/// Closed panels retain their most recent group, side, size or floating rectangle.
|
||||
pub hidden: Vec<(Panel, Location<Panel>)>,
|
||||
}
|
||||
|
||||
impl Default for Workspace {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
layout: Layout {
|
||||
root: Some(split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedFirst(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<Panel>) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
let mut next = self.clone();
|
||||
if let Action::Close { panel } = &action {
|
||||
let location = next.layout.location(panel).map_err(|error| error.to_string())?;
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
next.hidden.push((*panel, location));
|
||||
} else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action {
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
}
|
||||
next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?;
|
||||
next.validate()?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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<Panel> = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?;
|
||||
return self.apply(action);
|
||||
}
|
||||
let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
|
||||
if operation == "resizeSplit" {
|
||||
let path: Vec<bool> =
|
||||
serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?;
|
||||
let size: SplitSize =
|
||||
serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?;
|
||||
return self.apply(Action::ResizeSplit { path, size });
|
||||
}
|
||||
let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?;
|
||||
let action = match operation {
|
||||
"open" => {
|
||||
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<Value, String> {
|
||||
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<Panel>, rendered: &Layout<Panel>, path: &[bool]) -> Option<Vec<bool>> {
|
||||
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<Panel>| {
|
||||
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), .. })));
|
||||
}
|
||||
}
|
||||
|
|
@ -602,6 +602,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);
|
||||
|
|
@ -950,6 +964,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 unsaved_flag_follows_dirty_state() {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
|
|
|||
|
|
@ -2206,3 +2206,11 @@ key Space مسافة
|
|||
Collapse all طي الكل
|
||||
Remove page إزالة الصفحة
|
||||
Remove {n} pages إزالة {n} صفحات
|
||||
|
||||
# Shared panel docking
|
||||
Reset panel layout إعادة تعيين تخطيط اللوحات
|
||||
Float panel تعويم اللوحة
|
||||
Move to group نقل إلى مجموعة
|
||||
Panels اللوحات
|
||||
Resize panels تغيير حجم اللوحات
|
||||
Resize panel window تغيير حجم نافذة اللوحة
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -2146,3 +2146,13 @@ key Space Интервал
|
|||
Collapse all Свий всички
|
||||
Remove page Премахни страницата
|
||||
Remove {n} pages Премахни {n} страници
|
||||
|
||||
# Shared panel docking
|
||||
Reset panel layout Възстанови оформлението на панелите
|
||||
Float panel Откачи панела
|
||||
Move to group Премести в група
|
||||
Panels Панели
|
||||
Resize panels Преоразмери панелите
|
||||
Resize panel window Преоразмери прозореца на панела
|
||||
Tools Инструменти
|
||||
Inspector Инспектор
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
|
|
@ -172,3 +172,13 @@ key Esc Esc
|
|||
key Home Home
|
||||
key End End
|
||||
key Space Mezerník
|
||||
|
||||
# Shared panel docking
|
||||
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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 7 and column 81.
|
|
|
@ -2184,3 +2184,13 @@ key Space Leertaste
|
|||
Collapse all Alle einklappen
|
||||
Remove page Seite entfernen
|
||||
Remove {n} pages {n} Seiten entfernen
|
||||
|
||||
# Shared panel docking
|
||||
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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
|
|
@ -2100,3 +2100,13 @@ key Space Espacio
|
|||
Collapse all Contraer todo
|
||||
Remove page Quitar página
|
||||
Remove {n} pages Quitar {n} páginas
|
||||
|
||||
# Shared panel docking
|
||||
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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
|
|
@ -2184,3 +2184,13 @@ key Space Espace
|
|||
Collapse all Tout réduire
|
||||
Remove page Retirer la page
|
||||
Remove {n} pages Retirer {n} pages
|
||||
|
||||
# Shared panel docking
|
||||
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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
|
|
@ -2182,3 +2182,13 @@ key Space Szóköz
|
|||
Collapse all Összes összecsukása
|
||||
Remove page Oldal eltávolítása
|
||||
Remove {n} pages {n} oldal eltávolítása
|
||||
|
||||
# Shared panel docking
|
||||
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ó
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
|
|
@ -2193,3 +2193,11 @@ key Space Spazio
|
|||
Collapse all Comprimi tutto
|
||||
Remove page Rimuovi pagina
|
||||
Remove {n} pages Rimuovi {n} pagine
|
||||
|
||||
# Shared panel docking
|
||||
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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -2184,3 +2184,13 @@ key Space スペース
|
|||
Collapse all すべて折りたたむ
|
||||
Remove page ページを削除
|
||||
Remove {n} pages {n} ページを削除
|
||||
|
||||
# Shared panel docking
|
||||
Reset panel layout パネル配置をリセット
|
||||
Float panel パネルをフロート化
|
||||
Move to group グループに移動
|
||||
Panels パネル
|
||||
Resize panels パネルのサイズを変更
|
||||
Resize panel window パネルウィンドウのサイズを変更
|
||||
Tools ツール
|
||||
Inspector インスペクター
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -2185,3 +2185,13 @@ key Space Espaço
|
|||
Collapse all Recolher tudo
|
||||
Remove page Remover página
|
||||
Remove {n} pages Remover {n} páginas
|
||||
|
||||
# Shared panel docking
|
||||
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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
|
|
@ -2097,3 +2097,13 @@ key Space Пробел
|
|||
Collapse all Свернуть все
|
||||
Remove page Удалить страницу
|
||||
Remove {n} pages Удалить страницы: {n}
|
||||
|
||||
# Shared panel docking
|
||||
Reset panel layout Сбросить расположение панелей
|
||||
Float panel Сделать панель плавающей
|
||||
Move to group Переместить в группу
|
||||
Panels Панели
|
||||
Resize panels Изменить размеры панелей
|
||||
Resize panel window Изменить размер окна панели
|
||||
Tools Инструменты
|
||||
Inspector Инспектор
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
|
|
@ -2114,3 +2114,13 @@ key Space స్పేస్
|
|||
Collapse all అన్నీ ముడుచు
|
||||
Remove page పేజీని తీసివేయి
|
||||
Remove {n} pages {n} పేజీలను తీసివేయి
|
||||
|
||||
# Shared panel docking
|
||||
Reset panel layout ప్యానెల్ అమరికను రీసెట్ చేయి
|
||||
Float panel ప్యానెల్ను తేలియాడేలా చేయి
|
||||
Move to group సమూహానికి తరలించు
|
||||
Panels ప్యానెల్లు
|
||||
Resize panels ప్యానెల్ల పరిమాణం మార్చు
|
||||
Resize panel window ప్యానెల్ విండో పరిమాణం మార్చు
|
||||
Tools సాధనాలు
|
||||
Inspector పరిశీలకుడు
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
|
|
@ -2207,3 +2207,11 @@ key Space Пробіл
|
|||
Collapse all Згорнути всі
|
||||
Remove page Видалити сторінку
|
||||
Remove {n} pages Видалити сторінки: {n}
|
||||
|
||||
# Shared panel docking
|
||||
Reset panel layout Скинути розташування панелей
|
||||
Float panel Відкріпити панель
|
||||
Move to group Перемістити до групи
|
||||
Panels Панелі
|
||||
Resize panels Змінити розмір панелей
|
||||
Resize panel window Змінити розмір вікна панелі
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -2148,3 +2148,13 @@ key Space 空格
|
|||
Collapse all 全部折叠
|
||||
Remove page 删除页面
|
||||
Remove {n} pages 删除 {n} 页
|
||||
|
||||
# Shared panel docking
|
||||
Reset panel layout 重置面板布局
|
||||
Float panel 浮动面板
|
||||
Move to group 移到组中
|
||||
Panels 面板
|
||||
Resize panels 调整面板大小
|
||||
Resize panel window 调整面板窗口大小
|
||||
Tools 工具
|
||||
Inspector 检查器
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
|
|
@ -2070,3 +2070,13 @@ key Space 空格
|
|||
Collapse all 全部摺疊
|
||||
Remove page 刪除頁面
|
||||
Remove {n} pages 刪除 {n} 頁
|
||||
|
||||
# Shared panel docking
|
||||
Reset panel layout 重設面板配置
|
||||
Float panel 浮動面板
|
||||
Move to group 移至群組
|
||||
Panels 面板
|
||||
Resize panels 調整面板大小
|
||||
Resize panel window 調整面板視窗大小
|
||||
Tools 工具
|
||||
Inspector 檢查器
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
|
|
@ -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<HashMap<&'static str, Arc<[u8]>>> = 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<SvgIconSet> = 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,97 @@ 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(), 185);
|
||||
assert!(exists("minimize-2"));
|
||||
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<egui::Event>,
|
||||
selected: bool,
|
||||
enabled: bool,
|
||||
legacy: bool,
|
||||
) -> (egui::Response, Vec<egui::epaint::ClippedShape>) {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ mod chrome;
|
|||
mod combine_grid;
|
||||
pub use combine_grid::ThumbState as CombineThumb;
|
||||
mod combine_ui;
|
||||
pub mod docking;
|
||||
pub use combine_ui::{Columns as CombineColumns, CombineView, Lock as CombineLock, SortKey};
|
||||
mod commands;
|
||||
mod comment_props;
|
||||
|
|
@ -158,7 +159,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,
|
||||
|
|
@ -378,6 +379,8 @@ pub struct RecentFile {
|
|||
}
|
||||
|
||||
pub struct PdfCraftApp {
|
||||
pub docking: docking::Workspace,
|
||||
pub(crate) pending_panel_reveals: Vec<docking::Panel>,
|
||||
pub session: Session,
|
||||
pub views: Vec<DocView>,
|
||||
/// `None` shows the Home tab.
|
||||
|
|
@ -707,6 +710,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,
|
||||
|
|
@ -1335,10 +1340,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"),
|
||||
|
|
@ -1356,12 +1366,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<String> = 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.
|
||||
|
|
@ -1415,6 +1431,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::<serde_json::Value>(json) else { return };
|
||||
if let Some(saved) = v.get("docking")
|
||||
&& let Ok(workspace) = serde_json::from_value::<docking::Workspace>(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 Some(view) = v["combine_view"].as_str().and_then(combine_ui::CombineView::parse) {
|
||||
self.combine_view = view;
|
||||
|
|
@ -1583,7 +1608,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,
|
||||
|
|
@ -1778,6 +1803,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(())
|
||||
}
|
||||
|
||||
|
|
@ -2057,18 +2088,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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -424,16 +418,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))),
|
||||
|
|
|
|||
|
|
@ -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::Rect>(egui::Id::new("pdfcraft-inspector-body"))).expect("the inspector body is shown").width()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -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::Rect>(egui::Id::new("pdfcraft-inspector-body"))).expect("the inspector body is shown").width()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -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<Mutex<Option<ControlClient>>> = 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::<String>(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::<String>(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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -707,7 +707,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);
|
||||
|
|
@ -786,3 +786,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::<Vec<_>>(), document_ids);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5250,8 +5250,7 @@ milestone = "M5"
|
|||
status = "shipped"
|
||||
commands = ["sign.fill.signature", "sign.fill.initials", "sign.fill.signature.change", "sign.fill.initials.change", "sign.fill.signature.remove", "sign.fill.initials.remove"]
|
||||
tools = ["fill_sign_add", "comment_edit", "comment_image_preview"]
|
||||
evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them"]
|
||||
evidence = ["crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them"]
|
||||
evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field"]
|
||||
notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 4 megapixels and 4096 pixels per side"
|
||||
|
||||
[[feature]]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue