From d764a8ebca2dc289841b34ea3048f613a9869bbb Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Sun, 25 Oct 2026 12:06:35 -0400
Subject: [PATCH] refactor(ui): side panels on craft-ui task panes and the
shared rail (#4606)
* refactor(ui): side panels on craft-ui task panes and the shared rail
The Tools pane and the open side panel are docked craft-ui task panes
(craft_ui::panes::PaneHost, Columns policy, no title bars, not movable),
and the rail is craft_ui::panes::Rail with the page navigation cluster
as its foot. On the right, outermost first: the rail, Tools when
Preferences > Tools panel side is Right, the side panel, the page.
Read mode uses the same panes without Tools; All tools full view still
covers them. Floating, tab groups and drag-in are gone with docking.rs.
- Widths are resizable within the old limits and saved under a new
side_panels preference (version 1) with the Tools side and open state.
The old docking value is read once as plain JSON and dropped on save.
- ui.dock becomes ui.panel {panel: tools|side, open, width};
ui.dock.reset resets the side panels ("Reset side panels").
- F4 shows or hides the side panel (reopening the last one) and
Shift+F4 the Tools panel, as View menu commands.
- craft-ui pinned to e9630ae7 (craft-libs#309).
* fix(ui): side panels clippy lint and the dragged-width test's grab offset
* chore(deps): pin craft-ui at 7c42807, whose pane resize edges work over the page
* chore(deps): pin craft-ui at craft-libs main ea4c823, with panes::Rail
---
Cargo.lock | 8 +-
Cargo.toml | 2 +-
README.md | 27 +-
crates/engine/src/commands.rs | 18 +-
crates/ui-egui/src/canvas.rs | 3 +-
crates/ui-egui/src/chrome.rs | 305 ++++++------
crates/ui-egui/src/commands.rs | 4 +-
crates/ui-egui/src/control.rs | 16 +-
crates/ui-egui/src/docking.rs | 547 ---------------------
crates/ui-egui/src/icons.rs | 2 +-
crates/ui-egui/src/js_ui.rs | 17 +-
crates/ui-egui/src/lib.rs | 86 ++--
crates/ui-egui/src/side_panels.rs | 522 ++++++++++++++++++++
crates/ui-egui/tests/it/control.rs | 203 +++-----
crates/ui-egui/tests/it/main.rs | 1 +
crates/ui-egui/tests/it/panel_snapshots.rs | 10 +-
crates/ui-egui/tests/it/pointer.rs | 23 +-
crates/ui-egui/tests/it/side_panels.rs | 133 +++++
crates/ui-egui/tests/it/ui.rs | 6 +-
19 files changed, 974 insertions(+), 959 deletions(-)
delete mode 100644 crates/ui-egui/src/docking.rs
create mode 100644 crates/ui-egui/src/side_panels.rs
create mode 100644 crates/ui-egui/tests/it/side_panels.rs
diff --git a/Cargo.lock b/Cargo.lock
index 37f2487f8..07b8e4ef0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1174,9 +1174,13 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
+source = "git+https://github.com/storytold/craft-libs?rev=ea4c823abb46fa9d21361604ed1c6d21c54e9ca8#ea4c823abb46fa9d21361604ed1c6d21c54e9ca8"
dependencies = [
"egui",
+ "log",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
"serde",
]
@@ -6463,7 +6467,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.3.4",
+ "getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.60.2",
diff --git a/Cargo.toml b/Cargo.toml
index 78c7a78c6..6a91547f7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,7 +13,7 @@ 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"] }
+craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "ea4c823abb46fa9d21361604ed1c6d21c54e9ca8", features = ["serde"] }
# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" }
# Shared system spooler; document composition remains in pdfcraft-print.
diff --git a/README.md b/README.md
index d39bec5aa..f319ffead 100644
--- a/README.md
+++ b/README.md
@@ -332,15 +332,13 @@ 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.
+`ui.panel` opens, closes or sizes a side panel: `{"panel":"tools"|"side","open":true|false,"width":320}`.
+`tools` is the Tools panel and `side` the panel the rail opens (reopening it shows the one open last).
+`width` is clamped to the panel's limits (Tools 240–520, side panel 260–520 points). `ui.dock.reset`
+resets the side panels: Tools open on the left, default widths. The control channel accepts these
+methods directly; `ui.command` also accepts `{"id":"ui.panel","params":{...}}`. Saved preferences keep
+the Tools side, the open panels and their widths. Invalid requests return an error without changing
+the document or the panels.
`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.
@@ -595,13 +593,4 @@ Forks and modified versions must remove them.
Made by the ArtCraft team and community.
-Tools and Inspector panels can be dragged into tab groups, split beside the PDF, or floated within the app window. Read and full-screen modes keep their existing focused document view. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
-
-Advanced `ui.dock` operations use the same validated transaction as pointer gestures:
-`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and
-`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`),
-`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null.
-A center `move` can include `before` (panel ID or null to append) for tab order.
-The equivalent `{"action": ...}` form accepts a serialized shared docking action;
-this is also the form emitted by the renderer. Protected-canvas and app-specific panel
-rules apply equally to both forms.
+The Tools panel sits on the left, or on the right with Preferences ▸ Tools panel side. The rail stays at the right edge and opens one side panel beside it. Drag a panel's inner edge to resize it; widths are remembered. F4 shows or hides the side panel and Shift+F4 the Tools panel (also in the View menu); View ▸ Reset side panels restores the default arrangement.
diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs
index 4ebfecafc..1f25c23c9 100644
--- a/crates/engine/src/commands.rs
+++ b/crates/engine/src/commands.rs
@@ -231,7 +231,23 @@ pub const COMMANDS: &[CommandSpec] = &[
c("view.previous_view", "Previous view", None, Some(Shortcut::cmd("[")), Document, "chevron-left"),
c("view.next_view", "Next view", None, Some(Shortcut::cmd("]")), Document, "chevron-right"),
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.side_panel",
+ "Show or hide the side panel",
+ VIEW,
+ Some(Shortcut { command: false, shift: false, mac_ctrl: false, key: "F4" }),
+ Nothing,
+ "panel-right",
+ ),
+ c(
+ "view.tools_pane",
+ "Show or hide the Tools panel",
+ VIEW,
+ Some(Shortcut { command: false, shift: true, mac_ctrl: false, key: "F4" }),
+ Nothing,
+ "panel-left",
+ ),
+ c("ui.dock.reset", "Reset side panels", VIEW, None, Nothing, "settings"),
c("view.theme", "Switch light / dark theme", None, None, Nothing, "moon"),
c("view.theme.system", "Use system setting", None, None, Nothing, "settings"),
c("view.theme.light", "Light gray", None, None, Nothing, "sun"),
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 02bc000ed..fc0ee07c6 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -2701,8 +2701,7 @@ const SUPPRESS_SECS: f64 = 0.25;
/// Short rolling extension while egui still has an unprocessed wheel tail.
const SUPPRESS_TAIL_SECS: f64 = 0.05;
-// The viewer moves between docking and the central panel as modes change; keep its scroll
-// identity tied to the document instead of either parent UI.
+// Keep the viewer's scroll identity tied to the document instead of its parent UI.
fn page_scroll(
ui: &mut egui::Ui,
scroll: egui::ScrollArea,
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index 2723d16c2..b7dd6b3ed 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -1,5 +1,6 @@
//! Window chrome: tab strip (with the integrated macOS title bar), mode bar, right rail.
+use craft_ui::panes::{Edge, Rail, RailItem, RailRequest};
use egui::{Align, Align2, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2};
use crate::canvas::{DocView, Fit, PageLayout};
@@ -651,166 +652,164 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
);
let page_count = doc.info.pages.len();
let labels: Vec = doc.info.pages.iter().map(|p| p.label.clone()).collect();
- egui::Panel::right("rail")
- .resizable(false)
- .exact_size(48.0)
+ let item = |panel: RightPanel, icon: &str, label: &str, marker: Option<&str>| {
+ let item = RailItem::new(panel, icons::source(icon), tl!(label)).marker(marker.is_some());
+ match marker {
+ Some(marker) => item.marker_label(tl!(marker)),
+ None => item,
+ }
+ };
+ let has = |has: bool, label: &'static str| has.then_some(label);
+ let mut items = vec![
+ item(RightPanel::Comments, "message-square-text", "Comments", has(has_comments, "Has comments")),
+ item(RightPanel::Bookmarks, "bookmark", "Bookmarks", has(has_outline, "Has bookmarks")),
+ item(RightPanel::Pages, "files", "Page thumbnails", None),
+ item(RightPanel::Fields, "text-cursor-input", "Form fields", has(has_fields, "Has form fields")),
+ item(RightPanel::Layers, "layers", "Layers", has(has_layers, "Has layers")),
+ item(RightPanel::Attachments, "paperclip", "Attachments", has(has_files, "Has attachments")),
+ item(RightPanel::Signatures, "signature", "Signatures", has(has_signatures, "Signed")),
+ ];
+ if has_check {
+ items.push(item(RightPanel::Accessibility, "accessibility", "Accessibility Checker", None));
+ }
+ items.push(item(RightPanel::Tags, "tag", "Tags", None));
+ let view = &mut app.views[index];
+ let mut page_display = None;
+ let rail = Rail::new("rail")
+ .edge(Edge::End)
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(6, 8)).stroke(Stroke::new(1.0, t.divider)))
- .show(ui, |ui| {
- // Page navigation cluster at the bottom (as in Acrobat's rail). It is a panel of its own so
- // that in a short window the buttons above scroll instead of running into it (#3282).
- let view = &mut app.views[index];
- let mut page_display = None;
- egui::Panel::bottom("rail-nav").frame(egui::Frame::NONE).show_separator_line(false).resizable(false).show(ui, |ui| {
- ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
- ui.spacing_mut().item_spacing.y = 2.0;
- let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT);
- if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() {
- view.zoom_step(false);
+ .show(ui, &items, app.right.as_ref(), |ui| {
+ // Page navigation cluster at the bottom, a panel of its own so that in a short
+ // window the buttons above scroll instead of running into it (#3282).
+ ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
+ ui.spacing_mut().item_spacing.y = 2.0;
+ let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT);
+ if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() {
+ view.zoom_step(false);
+ }
+ let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN);
+ if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() {
+ view.zoom_step(true);
+ }
+ // Between rotate and zoom, as in Acrobat. Its command runs once `view` is free.
+ let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]);
+ let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip);
+ page_display = egui::Popup::menu(&resp)
+ .show(|ui| {
+ ui.set_min_width(220.0);
+ rail_view_menu(ui, view)
+ })
+ .and_then(|r| r.inner);
+ let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW);
+ if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() {
+ view.rotate_view(true);
+ }
+ // Not `columns-2` while fitting the page: that is the two-page view's icon.
+ let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" };
+ if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() {
+ view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width };
+ view.goto = Some((view.current, 0.0));
+ }
+ let id = egui::Id::new("zoom-input");
+ let draft = id.with("draft");
+ let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0));
+ let edit = egui::TextEdit::singleline(&mut typed)
+ .id(id)
+ .desired_width(40.0)
+ .horizontal_align(Align::Center)
+ .font(theme::medium(11.0))
+ .text_color(t.text_muted)
+ .frame(egui::Frame::NONE)
+ .margin(vec2(2.0, 2.0));
+ let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter"));
+ ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom")));
+ if r.gained_focus() {
+ let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
+ let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count()));
+ state.cursor.set_char_range(Some(all));
+ state.store(ui.ctx(), id);
+ }
+ if r.has_focus() {
+ ui.data_mut(|d| d.insert_temp(draft, typed.clone()));
+ } else {
+ ui.data_mut(|d| d.remove::(draft));
+ if r.lost_focus()
+ && ui.input(|i| i.key_pressed(egui::Key::Enter))
+ && let Some(zoom) = DocView::parse_zoom_percent(&typed)
+ {
+ view.set_zoom(zoom);
}
- let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN);
- if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() {
- view.zoom_step(true);
+ }
+ ui.add_space(6.0);
+ if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() {
+ view.step_page(true);
+ }
+ if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() {
+ view.step_page(false);
+ }
+ // History is distinct from stepping to the adjacent page, and belongs to this tab.
+ for (forward, icon, label, shortcut, enabled) in [
+ (true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()),
+ (false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()),
+ ] {
+ let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut));
+ if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() {
+ view.view_history(forward);
}
- // Between rotate and zoom, as in Acrobat. Its command runs once `view` is free.
- let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]);
- let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip);
- page_display = egui::Popup::menu(&resp)
- .show(|ui| {
- ui.set_min_width(220.0);
- rail_view_menu(ui, view)
- })
- .and_then(|r| r.inner);
- let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW);
- if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() {
- view.rotate_view(true);
+ }
+ let font = theme::regular(11.0);
+ let mut position = page_position(view.current, page_count, labels.get(view.current).map_or("", String::as_str));
+ if widgets::text_width(ui, &position, &font) > ui.available_width() {
+ position = page_count.to_string();
+ }
+ // Out of range (an empty document), like `page_position`: "Page 0 of 0", never past the end.
+ let shown = if view.current < page_count { view.current + 1 } else { page_count };
+ let of = crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &shown.to_string()), ("n", &page_count.to_string())]);
+ ui.label(egui::RichText::new(position).font(font).color(t.text_muted)).on_hover_text(of);
+ let page_input_id = view.page_input_id();
+ // Consume replacement selection once, including when the editor has lost focus.
+ let reselect = std::mem::take(&mut view.page_input_reselect);
+ // Navigation updates the idle display, never the text being edited.
+ if !ui.memory(|m| m.has_focus(page_input_id)) {
+ view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str));
+ } else if reselect {
+ // The replacement changed the label while egui retained this editor's cursor.
+ let mut state = egui::TextEdit::load_state(ui.ctx(), page_input_id).unwrap_or_default();
+ let end = egui::text::CCursor::new(view.page_input.chars().count());
+ state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end)));
+ state.store(ui.ctx(), page_input_id);
+ }
+ let edit = egui::TextEdit::singleline(&mut view.page_input)
+ .id(page_input_id)
+ .desired_width(34.0)
+ .horizontal_align(Align::Center)
+ .font(theme::medium(12.0))
+ .margin(vec2(2.0, 4.0));
+ let r = egui::Frame::NONE
+ .fill(t.field)
+ .stroke(Stroke::new(1.0, t.border))
+ .corner_radius(CornerRadius::same(5))
+ .show(ui, |ui| ui.add(edit))
+ .inner
+ .on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter"));
+ if r.lost_focus() {
+ if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
+ // A page label first (logical page numbers, as Acrobat), then a number.
+ let typed = view.page_input.clone();
+ view.go_to_typed(&typed, &labels);
}
- // Not `columns-2` while fitting the page: that is the two-page view's icon.
- let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" };
- if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() {
- view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width };
- view.goto = Some((view.current, 0.0));
- }
- let id = egui::Id::new("zoom-input");
- let draft = id.with("draft");
- let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0));
- let edit = egui::TextEdit::singleline(&mut typed)
- .id(id)
- .desired_width(40.0)
- .horizontal_align(Align::Center)
- .font(theme::medium(11.0))
- .text_color(t.text_muted)
- .frame(egui::Frame::NONE)
- .margin(vec2(2.0, 2.0));
- let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter"));
- ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom")));
- if r.gained_focus() {
- let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
- let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count()));
- state.cursor.set_char_range(Some(all));
- state.store(ui.ctx(), id);
- }
- if r.has_focus() {
- ui.data_mut(|d| d.insert_temp(draft, typed.clone()));
- } else {
- ui.data_mut(|d| d.remove::(draft));
- if r.lost_focus()
- && ui.input(|i| i.key_pressed(egui::Key::Enter))
- && let Some(zoom) = DocView::parse_zoom_percent(&typed)
- {
- view.set_zoom(zoom);
- }
- }
- ui.add_space(6.0);
- if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() {
- view.step_page(true);
- }
- if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() {
- view.step_page(false);
- }
- // History is distinct from stepping to the adjacent page, and belongs to this tab.
- for (forward, icon, label, shortcut, enabled) in [
- (true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()),
- (false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()),
- ] {
- let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut));
- if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() {
- view.view_history(forward);
- }
- }
- let font = theme::regular(11.0);
- let mut position = page_position(view.current, page_count, labels.get(view.current).map_or("", String::as_str));
- if widgets::text_width(ui, &position, &font) > ui.available_width() {
- position = page_count.to_string();
- }
- // Out of range (an empty document), like `page_position`: "Page 0 of 0", never past the end.
- let shown = if view.current < page_count { view.current + 1 } else { page_count };
- let of = crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &shown.to_string()), ("n", &page_count.to_string())]);
- ui.label(egui::RichText::new(position).font(font).color(t.text_muted)).on_hover_text(of);
- let page_input_id = view.page_input_id();
- // Consume replacement selection once, including when the editor has lost focus.
- let reselect = std::mem::take(&mut view.page_input_reselect);
- // Navigation updates the idle display, never the text being edited.
- if !ui.memory(|m| m.has_focus(page_input_id)) {
- view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str));
- } else if reselect {
- // The replacement changed the label while egui retained this editor's cursor.
- let mut state = egui::TextEdit::load_state(ui.ctx(), page_input_id).unwrap_or_default();
- let end = egui::text::CCursor::new(view.page_input.chars().count());
- state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end)));
- state.store(ui.ctx(), page_input_id);
- }
- let edit = egui::TextEdit::singleline(&mut view.page_input)
- .id(page_input_id)
- .desired_width(34.0)
- .horizontal_align(Align::Center)
- .font(theme::medium(12.0))
- .margin(vec2(2.0, 4.0));
- let r = egui::Frame::NONE
- .fill(t.field)
- .stroke(Stroke::new(1.0, t.border))
- .corner_radius(CornerRadius::same(5))
- .show(ui, |ui| ui.add(edit))
- .inner
- .on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter"));
- if r.lost_focus() {
- if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
- // A page label first (logical page numbers, as Acrobat), then a number.
- let typed = view.page_input.clone();
- view.go_to_typed(&typed, &labels);
- }
- view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str));
- }
- });
- });
- egui::ScrollArea::vertical().id_salt("rail-panels").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) && (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);
- ui.painter().circle_filled(c, 3.0, t.accent);
- }
- if r.clicked() {
- app.choose_right_panel(if selected { None } else { Some(panel) });
- }
- };
- rail_button(ui, RightPanel::Comments, "message-square-text", "Comments", has_comments);
- rail_button(ui, RightPanel::Bookmarks, "bookmark", "Bookmarks", has_outline);
- rail_button(ui, RightPanel::Pages, "files", "Page thumbnails", false);
- rail_button(ui, RightPanel::Fields, "text-cursor-input", "Form fields", has_fields);
- rail_button(ui, RightPanel::Layers, "layers", "Layers", has_layers);
- rail_button(ui, RightPanel::Attachments, "paperclip", "Attachments", has_files);
- rail_button(ui, RightPanel::Signatures, "signature", "Signatures", has_signatures);
- if has_check {
- rail_button(ui, RightPanel::Accessibility, "accessibility", "Accessibility Checker", false);
+ view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str));
}
- rail_button(ui, RightPanel::Tags, "tag", "Tags", false);
});
- if let Some(id) = page_display {
- app.execute(id);
- }
});
+ match rail.request {
+ Some(RailRequest::Open(panel)) => app.choose_right_panel(Some(panel)),
+ Some(RailRequest::Close(_)) => app.choose_right_panel(None),
+ _ => {}
+ }
+ if let Some(id) = page_display {
+ app.execute(id);
+ }
}
/// What the rail shows under the page box: the page count, or, when the current page's label is
diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs
index c0e12d5d0..a27a25ff8 100644
--- a/crates/ui-egui/src/commands.rs
+++ b/crates/ui-egui/src/commands.rs
@@ -157,10 +157,12 @@ impl PdfCraftApp {
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) {
+ if let Err(error) = crate::side_panels::command(self, true, &serde_json::Value::Null) {
self.notify(error);
}
}
+ "view.side_panel" => self.toggle_side_panel(),
+ "view.tools_pane" => self.left_open = !self.left_open,
"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.
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index 47c5b200a..444afd73d 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -184,7 +184,7 @@ 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 panel(&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;
@@ -286,8 +286,8 @@ impl Control {
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.panel" | "ui.dock.reset" => Ok(Handled::Now(host.panel(method == "ui.dock.reset", p))),
+ "ui.command" if str_param("id")? == "ui.panel" => Ok(Handled::Now(host.panel(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") {
@@ -647,8 +647,8 @@ fn screenshot_png(image: &egui::ColorImage, region: Option, ppp: f32
}
impl Host for crate::PdfCraftApp {
- fn dock(&mut self, reset: bool, params: &Value) -> Reply {
- crate::docking::command(self, reset, params)
+ fn panel(&mut self, reset: bool, params: &Value) -> Reply {
+ crate::side_panels::command(self, reset, params)
}
fn quit(&mut self, ctx: &egui::Context) -> bool {
@@ -672,7 +672,7 @@ impl Host for crate::PdfCraftApp {
#[cfg(target_arch = "wasm32")]
let pending_opens: Vec = Vec::new();
json!({
- "docking": self.docking,
+ "side_panels": crate::side_panels::state(self),
"documents": docs,
"pending_opens": pending_opens,
"active": active.map(|v| json!({
@@ -735,7 +735,7 @@ impl Host for crate::PdfCraftApp {
"default_mode": self.default_mode,
"comment_tool_pinned": self.comment_prefs.pinned,
"left_panel": if self.left_open { json!(format!("{:?}", self.left)) } else { Value::Null },
- "tools_side": if self.docking.tools_on_right() { "right" } else { "left" },
+ "tools_side": if self.side_panels.tools_on_right() { "right" } else { "left" },
"right_panel": self.right.map(|r| format!("{r:?}")),
"dialog": self.dialog.map(|d| format!("{d:?}")),
"palette_open": self.palette_open,
@@ -763,7 +763,7 @@ impl Host for crate::PdfCraftApp {
.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"}));
+ list.push(json!({"id": "ui.panel", "label": "Side panel", "enabled": true, "params": "panel: tools or side; open: boolean; width: number (clamped to the panel's limits)"}));
json!({ "commands": list })
}
diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs
deleted file mode 100644
index 11eb732be..000000000
--- a/crates/ui-egui/src/docking.rs
+++ /dev/null
@@ -1,547 +0,0 @@
-//! Application panel identities and persistence around the shared docking renderer.
-//! Document contents remain owned by their existing view implementations.
-
-use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone};
-use craft_ui::layout::{SplitAxis, SplitSize};
-use serde::{Deserialize, Serialize};
-use serde_json::{Value, json};
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub enum Panel {
- Canvas,
- Tools,
- Inspector,
-}
-
-impl Panel {
- pub const ALL: &[Self] = &[Self::Canvas, Self::Tools, Self::Inspector];
- pub fn id(self) -> &'static str {
- match self {
- Self::Canvas => "canvas",
- Self::Tools => "tools",
- Self::Inspector => "inspector",
- }
- }
- fn label(self) -> &'static str {
- match self {
- Self::Canvas => "Document",
- Self::Tools => "Tools",
- Self::Inspector => "Inspector",
- }
- }
- fn default_side(self) -> Zone {
- match self {
- Self::Canvas => Zone::Right,
- Self::Tools => Zone::Left,
- Self::Inspector => Zone::Right,
- }
- }
- fn parse(id: &str) -> Result {
- Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
- }
-}
-
-fn permissions(panel: &Panel) -> Permissions {
- if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() }
-}
-
-fn tabs(panel: Panel) -> Node {
- Node::Tabs { panels: vec![panel], active: 0 }
-}
-
-fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node) -> Node {
- Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
-}
-
-#[derive(Clone, Debug, Serialize, Deserialize)]
-#[serde(default)]
-pub struct Workspace {
- pub layout: Layout,
- /// Closed panels retain their most recent group, side, size or floating rectangle.
- pub hidden: Vec<(Panel, Location)>,
-}
-
-impl Default for Workspace {
- fn default() -> Self {
- Self {
- layout: Layout {
- root: Some(split(
- SplitAxis::Horizontal,
- SplitSize::FixedFirst(272.0),
- tabs(Panel::Tools),
- split(SplitAxis::Horizontal, SplitSize::FixedSecond(330.0), tabs(Panel::Canvas), tabs(Panel::Inspector)),
- )),
- floating: Vec::new(),
- },
- hidden: Vec::new(),
- }
- }
-}
-
-impl Workspace {
- /// Side of the Tools panel, including its saved position while closed.
- pub fn tools_on_right(&self) -> bool {
- let location = self
- .layout
- .location(&Panel::Tools)
- .ok()
- .or_else(|| self.hidden.iter().find(|(panel, _)| *panel == Panel::Tools).map(|(_, location)| location.clone()));
- location.is_some_and(|location| matches!(location.placement, Placement::Split(Zone::Right)))
- }
-
- /// Place Tools at the outer left or right edge while preserving its open state.
- pub fn set_tools_on_right(&mut self, right: bool) -> Result<(), String> {
- let was_open = self.layout.contains(&Panel::Tools);
- self.set_visible(Panel::Tools, true)?;
- let anchor = if right && self.layout.contains(&Panel::Inspector) { Panel::Inspector } else { Panel::Canvas };
- self.apply(Action::Move { panel: Panel::Tools, anchor, placement: Placement::Split(if right { Zone::Right } else { Zone::Left }) })?;
- if !was_open {
- self.set_visible(Panel::Tools, false)?;
- }
- Ok(())
- }
-
- pub fn validate(&self) -> Result<(), String> {
- self.layout.validate().map_err(|error| error.to_string())?;
- if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() {
- return Err("The panel layout must contain its document view and bounded hidden panels".into());
- }
- // Saved settings cannot turn the protected document view into a floating or closable tab.
- if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) {
- return Err("The document view cannot float".into());
- }
- let mut nodes: Vec<_> = self.layout.root.iter().collect();
- while let Some(node) = nodes.pop() {
- match node {
- Node::Split { first, second, .. } => {
- nodes.push(first);
- nodes.push(second);
- }
- Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => {
- return Err("The document view cannot share a tab group".into());
- }
- Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => {
- return Err("The document view cannot be collapsed".into());
- }
- _ => {}
- }
- }
- let mut seen = std::collections::HashSet::new();
- for (panel, location) in &self.hidden {
- if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) {
- return Err("A hidden panel has an invalid or duplicate identity".into());
- }
- if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) {
- return Err("A hidden panel cannot restore over the document view".into());
- }
- let mut probe = self.layout.clone();
- if let Err(error) = probe.restore(*panel, location)
- && error != craft_ui::docking::DockError::MissingTarget
- {
- return Err(error.to_string());
- }
- }
- Ok(())
- }
-
- pub fn apply(&mut self, action: Action) -> Result<(), String> {
- self.validate()?;
- let mut next = self.clone();
- if let Action::Close { panel } = &action {
- let location = next.layout.location(panel).map_err(|error| error.to_string())?;
- next.hidden.retain(|(id, _)| id != panel);
- next.hidden.push((*panel, location));
- } else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action {
- next.hidden.retain(|(id, _)| id != panel);
- }
- next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?;
- next.validate()?;
- *self = next;
- Ok(())
- }
-
- /// Whether a panel's body is exposed by its selected tab or open stack entry.
- pub(crate) fn exposed(&self, panel: Panel) -> bool {
- if self.layout.floating.iter().any(|group| group.panels.get(group.active) == Some(&panel)) {
- return true;
- }
- let mut pending: Vec<_> = self.layout.root.iter().collect();
- while let Some(node) = pending.pop() {
- match node {
- Node::Split { first, second, .. } => {
- pending.push(first);
- pending.push(second);
- }
- Node::Tabs { panels, active } if panels.get(*active) == Some(&panel) => return true,
- Node::Stack { entries } if entries.iter().any(|entry| entry.panel == panel && entry.open) => return true,
- _ => {}
- }
- }
- false
- }
-
- pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> {
- if self.layout.contains(&panel) == visible {
- return Ok(());
- }
- if !visible {
- return self.apply(Action::Close { panel });
- }
- self.validate()?;
- let mut next = self.clone();
- let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone());
- next.hidden.retain(|(id, _)| *id != panel);
- if let Some(saved) = &mut location
- && saved.floating.is_some()
- && saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor)))
- {
- // The other members of a floating group may have been closed too.
- saved.anchor = None;
- saved.placement = Placement::Tab { before: None };
- }
- let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok());
- if !restored {
- next.layout
- .apply_with_permissions(
- Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) },
- permissions,
- )
- .map_err(|error| error.to_string())?;
- }
- next.validate()?;
- *self = next;
- Ok(())
- }
-
- fn command(&mut self, params: &Value) -> Result<(), String> {
- self.validate()?;
- if let Some(action) = params.get("action") {
- let action: Action = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?;
- return self.apply(action);
- }
- let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
- if operation == "resizeSplit" {
- let path: Vec =
- serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?;
- let size: SplitSize =
- serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?;
- return self.apply(Action::ResizeSplit { path, size });
- }
- let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?;
- let action = match operation {
- "open" => {
- self.set_visible(panel, true)?;
- return self.apply(Action::Activate { panel });
- }
- "close" => Action::Close { panel },
- "activate" => Action::Activate { panel },
- "setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? },
- "resizeStack" => {
- let value = params.get("height").ok_or("height is required")?;
- let height = if value.is_null() {
- None
- } else {
- Some(
- value
- .as_f64()
- .filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height))
- .ok_or("height must be finite and bounded")? as f32,
- )
- };
- Action::ResizeStack { panel, height }
- }
- "float" | "moveFloating" => {
- let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?;
- let mut values = [0.0f32; 4];
- for (output, value) in values.iter_mut().zip(rect) {
- *output = value
- .as_f64()
- .filter(|number| number.is_finite() && number.abs() <= 1_000_000.0)
- .ok_or("rect values must be finite and bounded")? as f32;
- }
- if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } }
- }
- "move" => {
- let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?;
- let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") {
- "center" => Placement::Split(Zone::Center),
- "left" => Placement::Split(Zone::Left),
- "right" => Placement::Split(Zone::Right),
- "top" => Placement::Split(Zone::Top),
- "bottom" => Placement::Split(Zone::Bottom),
- _ => return Err("zone must be center, left, right, top or bottom".into()),
- };
- let placement = if let Some(before) = params.get("before") {
- if !matches!(placement, Placement::Split(Zone::Center)) {
- return Err("before only applies to a center move".into());
- }
- Placement::Tab {
- before: if before.is_null() {
- None
- } else {
- Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?)
- },
- }
- } else {
- placement
- };
- Action::Move { panel, anchor, placement }
- }
- _ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()),
- };
- self.apply(action)
- }
-}
-
-fn sync(workspace: &mut Workspace, app: &crate::PdfCraftApp) -> Result<(), String> {
- workspace.set_visible(Panel::Tools, app.left_open)?;
- workspace.set_visible(Panel::Inspector, app.right.is_some())
-}
-
-fn drain_reveals(workspace: &mut Workspace, app: &mut crate::PdfCraftApp) {
- for panel in std::mem::take(&mut app.pending_panel_reveals) {
- if workspace.layout.contains(&panel) {
- let _ = workspace.apply(Action::Activate { panel });
- }
- }
-}
-
-fn publish_visibility(app: &mut crate::PdfCraftApp, workspace: &Workspace) {
- app.left_open = workspace.layout.contains(&Panel::Tools);
- if !workspace.layout.contains(&Panel::Inspector) {
- if app.right.is_some() {
- app.choose_right_panel(None);
- }
- } else if app.right.is_none() {
- app.choose_right_panel(Some(crate::RightPanel::Comments));
- }
-}
-
-pub fn command(app: &mut crate::PdfCraftApp, reset: bool, params: &Value) -> Result {
- let mut workspace = app.docking.clone();
- if reset {
- workspace = Workspace::default();
- workspace.set_visible(Panel::Inspector, app.right.is_some())?;
- } else {
- sync(&mut workspace, app)?;
- workspace.command(params)?;
- }
- publish_visibility(app, &workspace);
- app.docking = workspace;
- Ok(json!({"docking": app.docking}))
-}
-
-// Split paths change when a temporarily unavailable pane is pruned. Translate renderer
-// paths back to the saved tree before applying geometry changes.
-fn saved_split_path(saved: &Layout, rendered: &Layout, path: &[bool]) -> Option> {
- let mut target = rendered.root.as_ref()?;
- for second in path {
- let Node::Split { first, second: other, .. } = target else { return None };
- target = if *second { other } else { first };
- }
- let mut pending = vec![(saved.root.as_ref()?, Vec::new())];
- while let Some((node, original_path)) = pending.pop() {
- if let Node::Split { first, second, .. } = node {
- let mut projection = Layout { root: Some(node.clone()), floating: Vec::new() };
- if projection.contains(&Panel::Inspector) {
- let _ = projection.apply(Action::Close { panel: Panel::Inspector });
- }
- let has_available_panel = |child: &Node| {
- Layout { root: Some(child.clone()), floating: Vec::new() }.panels().into_iter().any(|panel| *panel != Panel::Inspector)
- };
- // An ancestor whose Inspector-only side disappeared projects to its surviving
- // child. Its divider is absent, so only the child's split may own this resize.
- if has_available_panel(first) && has_available_panel(second) && projection.root.as_ref() == Some(target) {
- return Some(original_path);
- }
- let mut second_path = original_path.clone();
- second_path.push(true);
- pending.push((second, second_path));
- let mut first_path = original_path;
- first_path.push(false);
- pending.push((first, first_path));
- }
- }
- None
-}
-
-pub fn show(app: &mut crate::PdfCraftApp, ui: &mut egui::Ui) {
- let t = crate::theme::Tokens::get(ui.ctx());
- let mut workspace = std::mem::take(&mut app.docking);
- if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) {
- app.notify_fmt("Panel layout could not be restored: {error}", &[("error", &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 });
- }
- // Tools docked on its own is drawn bare, without a tab strip, like Acrobat's tool pane. Grouped
- // with another panel, or floating, it keeps its tab so it can be dragged away again; moving it
- // to the other side stays a preference and a command.
- let bare_tools = rendered.location(&Panel::Tools).is_ok_and(|l| l.floating.is_none() && matches!(l.placement, Placement::Split(_)));
- let render_permissions = move |panel: &Panel| if bare_tools && *panel == Panel::Tools { Permissions::PROTECTED } else { permissions(panel) };
- let output = DockArea::new(egui::Id::new("pdfcraft-docking")).show_with_limits(
- ui,
- &rendered,
- &style,
- |panel| crate::i18n::t(panel.label()).into(),
- render_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) => match crate::compare_ui::side_by_side(app, index) {
- Some((old, new)) => crate::compare_ui::compare_area(app, old, new, ui),
- None => 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_fmt("Panel move was not applied: {error}", &[("error", &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 tools_side_moves_to_both_edges_and_survives_closed_restore() {
- let mut workspace = Workspace::default();
- assert!(!workspace.tools_on_right());
- workspace.set_tools_on_right(true).unwrap();
- assert!(workspace.tools_on_right());
- workspace.set_visible(Panel::Tools, false).unwrap();
- assert!(workspace.tools_on_right());
- workspace.set_tools_on_right(false).unwrap();
- assert!(!workspace.tools_on_right());
- assert!(!workspace.layout.contains(&Panel::Tools));
- workspace.set_visible(Panel::Tools, true).unwrap();
- assert!(!workspace.tools_on_right());
- workspace.validate().unwrap();
- }
-
- #[test]
- fn exposed_respects_collapsed_stack_and_inactive_floating_tabs() {
- let mut workspace = Workspace {
- layout: Layout {
- root: Some(split(
- SplitAxis::Horizontal,
- SplitSize::FixedFirst(272.0),
- Node::Stack {
- entries: vec![
- craft_ui::docking::StackEntry { panel: Panel::Tools, open: true, height: None },
- craft_ui::docking::StackEntry { panel: Panel::Inspector, open: false, height: None },
- ],
- },
- tabs(Panel::Canvas),
- )),
- floating: Vec::new(),
- },
- hidden: Vec::new(),
- };
- workspace.validate().unwrap();
- assert!(workspace.exposed(Panel::Tools));
- assert!(!workspace.exposed(Panel::Inspector));
- workspace.apply(Action::Activate { panel: Panel::Inspector }).unwrap();
- assert!(workspace.exposed(Panel::Inspector));
- workspace.apply(Action::Float { panel: Panel::Tools, rect: [10.0, 20.0, 300.0, 400.0] }).unwrap();
- workspace.apply(Action::Move { panel: Panel::Inspector, anchor: Panel::Tools, placement: Placement::Split(Zone::Center) }).unwrap();
- assert!(!workspace.exposed(Panel::Tools));
- assert!(workspace.exposed(Panel::Inspector));
- }
-
- #[test]
- fn unavailable_inspector_split_resize_targets_saved_tools_split() {
- let mut workspace = Workspace::default();
- // Inspector at the outer edge makes the visible Tools split move to the root.
- workspace.apply(Action::Move { panel: Panel::Inspector, anchor: Panel::Tools, placement: Placement::Split(Zone::Left) }).unwrap();
- let mut rendered = workspace.layout.clone();
- rendered.apply(Action::Close { panel: Panel::Inspector }).unwrap();
- let path = saved_split_path(&workspace.layout, &rendered, &[]).unwrap();
- workspace.apply(Action::ResizeSplit { path, size: SplitSize::FixedFirst(290.0) }).unwrap();
- let mut resized = workspace.layout.clone();
- resized.apply(Action::Close { panel: Panel::Inspector }).unwrap();
- assert!(matches!(resized.root, Some(Node::Split { size: SplitSize::FixedFirst(290.0), .. })));
- }
-
- #[test]
- fn unavailable_outer_inspector_does_not_capture_its_childs_resize() {
- let mut workspace = Workspace {
- layout: Layout {
- root: Some(split(
- SplitAxis::Horizontal,
- SplitSize::FixedSecond(330.0),
- split(SplitAxis::Horizontal, SplitSize::FixedFirst(272.0), tabs(Panel::Tools), tabs(Panel::Canvas)),
- tabs(Panel::Inspector),
- )),
- floating: Vec::new(),
- },
- hidden: Vec::new(),
- };
- workspace.validate().unwrap();
- let mut rendered = workspace.layout.clone();
- rendered.apply(Action::Close { panel: Panel::Inspector }).unwrap();
- let path = saved_split_path(&workspace.layout, &rendered, &[]).unwrap();
- assert_eq!(path, vec![false]);
- workspace.apply(Action::ResizeSplit { path, size: SplitSize::FixedFirst(290.0) }).unwrap();
- assert!(matches!(workspace.layout.root, Some(Node::Split { size: SplitSize::FixedSecond(330.0), .. })));
- let mut resized = workspace.layout.clone();
- resized.apply(Action::Close { panel: Panel::Inspector }).unwrap();
- assert!(matches!(resized.root, Some(Node::Split { size: SplitSize::FixedFirst(290.0), .. })));
- }
-}
diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs
index 79254afa2..5ad5425e8 100644
--- a/crates/ui-egui/src/icons.rs
+++ b/crates/ui-egui/src/icons.rs
@@ -20,7 +20,7 @@ pub fn exists(name: &str) -> bool {
white_icons().contains(name)
}
-fn source(name: &str) -> egui::ImageSource<'static> {
+pub(crate) 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"))
}
diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs
index 49611293a..bbc2712d8 100644
--- a/crates/ui-egui/src/js_ui.rs
+++ b/crates/ui-egui/src/js_ui.rs
@@ -77,9 +77,6 @@ impl PdfCraftApp {
};
// Show/Hide: a panel already showing closes.
self.right = (self.right != Some(panel)).then_some(panel);
- if self.right.is_some() {
- self.pending_panel_reveals.push(crate::docking::Panel::Inspector);
- }
}
Request::Focus(_) | Request::Beep | Request::Reset(_) => {}
}
@@ -340,16 +337,12 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok
);
ui.horizontal(|ui| {
ui.label(tl!("Tools panel side"));
- let right = app.docking.tools_on_right();
- if ui.selectable_label(!right, tl!("Left")).clicked()
- && let Err(error) = app.docking.set_tools_on_right(false)
- {
- app.notify(error);
+ let right = app.side_panels.tools_on_right();
+ if ui.selectable_label(!right, tl!("Left")).clicked() {
+ app.side_panels.tools_side = crate::side_panels::ToolsSide::Left;
}
- if ui.selectable_label(right, tl!("Right")).clicked()
- && let Err(error) = app.docking.set_tools_on_right(true)
- {
- app.notify(error);
+ if ui.selectable_label(right, tl!("Right")).clicked() {
+ app.side_panels.tools_side = crate::side_panels::ToolsSide::Right;
}
});
ui.add_space(8.0);
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 49b7bb81b..6aed5dd85 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -42,7 +42,6 @@ mod image_pick;
mod tags_ui;
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 camera_ui;
mod commands;
@@ -65,6 +64,7 @@ mod measure_ui;
mod ocr_ui;
mod optimize_ui;
mod search_ui;
+pub mod side_panels;
mod sign_ui;
mod stamps_ui;
mod standards_ui;
@@ -202,7 +202,7 @@ pub enum LeftPanel {
}
/// Right-hand panels, opened from the rail.
-#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum RightPanel {
Comments,
Bookmarks,
@@ -623,8 +623,8 @@ pub struct RecentFile {
}
pub struct PdfCraftApp {
- pub docking: docking::Workspace,
- pub(crate) pending_panel_reveals: Vec,
+ /// Tools side and pane widths; which panes are open is `left_open` and `right`.
+ pub side_panels: side_panels::SidePanels,
pub session: Session,
pub views: Vec,
pub(crate) closed_tabs: Vec,
@@ -647,6 +647,8 @@ pub struct PdfCraftApp {
pub left: LeftPanel,
pub left_open: bool,
pub right: Option,
+ /// The side panel F4 reopens: the one open last.
+ pub(crate) last_right: RightPanel,
/// The right panel that was open when the user switched to Read mode, which starts with the
/// panes closed; leaving Read mode brings it back unless the user opened or closed a pane in
/// Read mode meanwhile.
@@ -1103,10 +1105,10 @@ impl PdfCraftApp {
date_format_draft: None,
mode_override: None,
left: LeftPanel::AllTools,
- docking: docking::Workspace::default(),
- pending_panel_reveals: Vec::new(),
+ side_panels: Default::default(),
left_open: true,
right: None,
+ last_right: RightPanel::Comments,
right_before_read: None,
comments_panel_closed: false,
right_panel_closed: false,
@@ -2471,7 +2473,6 @@ impl PdfCraftApp {
pub(crate) fn reveal_tools(&mut self) {
self.left_open = true;
- self.pending_panel_reveals.push(docking::Panel::Tools);
}
/// Enter a tool from the catalogue, Home, palette or control channel. Organize is a
@@ -2580,9 +2581,6 @@ impl PdfCraftApp {
if self.right.is_none() {
self.right = before;
}
- if self.right.is_some() {
- self.pending_panel_reveals.push(docking::Panel::Inspector);
- }
}
}
@@ -2604,17 +2602,21 @@ impl PdfCraftApp {
self.right_panel_closed = true;
}
self.right = panel;
- if panel.is_some() {
- self.pending_panel_reveals.push(docking::Panel::Inspector);
+ if let Some(panel) = panel {
+ self.last_right = panel;
}
}
+ /// F4: closes the side panel, or reopens the one open last.
+ pub(crate) fn toggle_side_panel(&mut self) {
+ self.choose_right_panel(if self.right.is_some() { None } else { Some(self.last_right) });
+ }
+
/// Serialize the user's persistent state (recent files, theme). Local only.
pub fn persist(&self) -> String {
let trusted: Vec = self.session.trusted_certificates().iter().map(pdfcraft_engine::sign::x509::to_pem).collect();
let mut v = serde_json::json!({
- "docking": self.docking,
- "docking_left_open": self.left_open,
+ "side_panels": self.side_panels.to_json(self.left_open),
"docking_right": self.right,
"recent": self.recent,
"reopen_last_session": self.reopen_last_session,
@@ -2691,14 +2693,13 @@ impl PdfCraftApp {
/// Restore state written by `persist`. Unknown or malformed data is ignored.
pub fn restore(&mut self, json: &str) {
let Ok(v) = serde_json::from_str::(json) else { return };
- if let Some(saved) = v.get("docking")
- && let Ok(workspace) = serde_json::from_value::(saved.clone())
- && workspace.validate().is_ok()
- {
- self.docking = workspace;
- self.left_open =
- v.get("docking_left_open").and_then(serde_json::Value::as_bool).unwrap_or(self.docking.layout.contains(&docking::Panel::Tools));
- self.right = v.get("docking_right").and_then(|value| serde_json::from_value(value.clone()).ok()).flatten();
+ let panels = side_panels::restore(&v);
+ self.side_panels = panels.panels;
+ if let Some(open) = panels.tools_open {
+ self.left_open = open;
+ }
+ if let Some(right) = v.get("docking_right") {
+ self.right = serde_json::from_value(right.clone()).ok().flatten().filter(|_| !panels.side_closed);
}
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) {
@@ -3062,11 +3063,13 @@ impl PdfCraftApp {
_ => return Err("always-default-view must be on or off".into()),
};
}
- ("tools-side", _) => self.docking.set_tools_on_right(match value {
- "left" => false,
- "right" => true,
- _ => return Err("tools-side must be left or right".into()),
- })?,
+ ("tools-side", _) => {
+ self.side_panels.tools_side = match value {
+ "left" => side_panels::ToolsSide::Left,
+ "right" => side_panels::ToolsSide::Right,
+ _ => return Err("tools-side must be left or right".into()),
+ }
+ }
("organize", Some(v)) => {
if value == "off" {
v.organize = false;
@@ -3254,12 +3257,6 @@ 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(())
}
@@ -3699,27 +3696,16 @@ impl eframe::App for PdfCraftApp {
}
chrome::tab_strip(self, ui);
chrome::mode_bar(self, ui);
- if self.active.is_some() {
- chrome::right_rail(self, ui);
- // Read mode hides the tools, not the navigation panes: the rail stays visible
- // there, so its buttons (Bookmarks, Page thumbnails, ...) must open their panels.
- // Outside Read mode the docking workspace draws the inspector.
- if self.mode == Mode::Read && self.right.is_some() {
- egui::Panel::right("read_right_panel")
- .resizable(true)
- .default_size(330.0)
- .size_range(260.0..=520.0)
- .frame(egui::Frame::NONE)
- .show(ui, |ui| panels::right_panel(self, ui));
- }
- }
// Whichever way another tab came to the front, it shows instead of the expanded view.
if self.all_tools_full && self.all_tools_tab != self.shown_tab() {
self.all_tools_full = false;
}
+ side_panels::show(self, ui);
let t = theme::Tokens::get(&ctx);
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| {
- if self.mode == Mode::Read {
+ if self.mode == Mode::AllTools && self.all_tools_full {
+ all_tools_ui::show(self, ui);
+ } else {
match self.active {
None if self.combine_showing() => combine_ui::page(self, ui),
None => home::show(self, ui),
@@ -3728,10 +3714,6 @@ impl eframe::App for PdfCraftApp {
None => canvas::document_area(self, index, ui),
},
}
- } else if self.mode == Mode::AllTools && self.all_tools_full {
- all_tools_ui::show(self, ui);
- } else {
- docking::show(self, ui);
}
});
self.process_pending_edits();
diff --git a/crates/ui-egui/src/side_panels.rs b/crates/ui-egui/src/side_panels.rs
new file mode 100644
index 000000000..743882e61
--- /dev/null
+++ b/crates/ui-egui/src/side_panels.rs
@@ -0,0 +1,522 @@
+//! The side panels around the page: the Tools pane, the rail and the open side panel
+//! ([`crate::RightPanel`]), drawn with craft-ui task panes ([`craft_ui::panes`]).
+//!
+//! On the right, outermost first: the rail, the Tools pane when Preferences ▸ Tools panel side is
+//! Right, then the side panel, then the page. Panes are docked only; their widths are resizable
+//! and saved with the preferences.
+
+use std::ops::RangeInclusive;
+
+use craft_ui::panes::{Edge, PaneContent, PaneHost, PaneLabels, PaneRequest, PaneState, SidePolicy};
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+
+use crate::{Mode, PdfCraftApp};
+
+/// Default width of the Tools pane.
+pub const TOOLS_WIDTH: f32 = 272.0;
+/// Default width of the side panel.
+pub const SIDE_WIDTH: f32 = 330.0;
+const TOOLS_RANGE: RangeInclusive = 240.0..=520.0;
+const SIDE_RANGE: RangeInclusive = 260.0..=520.0;
+/// Version of the saved `side_panels` value.
+const VERSION: u64 = 1;
+/// Deepest split tree the old layout migration walks; the old renderer allowed far fewer.
+const MAX_DEPTH: usize = 32;
+
+/// The two panes [`PaneHost`] draws.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub enum Pane {
+ Tools,
+ /// The open [`crate::RightPanel`].
+ Side,
+}
+
+impl Pane {
+ pub fn parse(id: &str) -> Option {
+ match id {
+ "tools" => Some(Self::Tools),
+ "side" => Some(Self::Side),
+ _ => None,
+ }
+ }
+
+ fn range(self) -> RangeInclusive {
+ match self {
+ Self::Tools => TOOLS_RANGE,
+ Self::Side => SIDE_RANGE,
+ }
+ }
+}
+
+/// Preferences ▸ Tools panel side.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum ToolsSide {
+ #[default]
+ Left,
+ Right,
+}
+
+/// The saved side panel layout. Which side panel is open stays in `PdfCraftApp::right` and the
+/// Tools pane's open state in `PdfCraftApp::left_open`.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SidePanels {
+ /// The one setting that places the Tools pane; [`SidePanels::tools_edge`] is its only reader
+ /// in the layout.
+ pub tools_side: ToolsSide,
+ pub tools_width: f32,
+ pub side_width: f32,
+}
+
+impl Default for SidePanels {
+ fn default() -> Self {
+ Self { tools_side: ToolsSide::Left, tools_width: TOOLS_WIDTH, side_width: SIDE_WIDTH }
+ }
+}
+
+/// A width from settings or automation: finite, within the pane's limits.
+fn clamp_width(pane: Pane, width: f64) -> Option {
+ let range = pane.range();
+ width.is_finite().then(|| (width as f32).clamp(*range.start(), *range.end()))
+}
+
+/// What [`restore`] read from the saved preferences.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Restored {
+ pub panels: SidePanels,
+ /// `None`: not saved; keep the default.
+ pub tools_open: Option,
+ /// An old layout had the side panel closed.
+ pub side_closed: bool,
+}
+
+impl SidePanels {
+ pub fn tools_on_right(&self) -> bool {
+ self.tools_side == ToolsSide::Right
+ }
+
+ fn tools_edge(&self) -> Edge {
+ match self.tools_side {
+ ToolsSide::Left => Edge::Start,
+ ToolsSide::Right => Edge::End,
+ }
+ }
+
+ pub fn width(&self, pane: Pane) -> f32 {
+ match pane {
+ Pane::Tools => self.tools_width,
+ Pane::Side => self.side_width,
+ }
+ }
+
+ fn set_width(&mut self, pane: Pane, width: f32) {
+ let Some(width) = clamp_width(pane, f64::from(width)) else { return };
+ match pane {
+ Pane::Tools => self.tools_width = width,
+ Pane::Side => self.side_width = width,
+ }
+ }
+
+ /// The saved value (`side_panels` in the preferences).
+ pub fn to_json(&self, tools_open: bool) -> Value {
+ json!({
+ "version": VERSION,
+ "tools_side": self.tools_side,
+ "tools_open": tools_open,
+ "tools_width": self.tools_width,
+ "side_width": self.side_width,
+ })
+ }
+}
+
+/// Reads the side panel layout from saved preferences: `side_panels`, else the old `docking`
+/// value, else the defaults. Nothing in it is an error: a malformed or unknown value gives the
+/// defaults.
+pub fn restore(saved: &Value) -> Restored {
+ let defaults = Restored { panels: SidePanels::default(), tools_open: None, side_closed: false };
+ if let Some(value) = saved.get("side_panels") {
+ if value.get("version").and_then(Value::as_u64) != Some(VERSION) {
+ return defaults;
+ }
+ let mut panels = SidePanels::default();
+ if let Some(side) = value.get("tools_side").and_then(|v| serde_json::from_value::(v.clone()).ok()) {
+ panels.tools_side = side;
+ }
+ if let Some(width) = value.get("tools_width").and_then(Value::as_f64).and_then(|w| clamp_width(Pane::Tools, w)) {
+ panels.tools_width = width;
+ }
+ if let Some(width) = value.get("side_width").and_then(Value::as_f64).and_then(|w| clamp_width(Pane::Side, w)) {
+ panels.side_width = width;
+ }
+ return Restored { panels, tools_open: value.get("tools_open").and_then(Value::as_bool), side_closed: false };
+ }
+ let Some(mut restored) = saved.get("docking").and_then(migrate) else { return defaults };
+ if let Some(open) = saved.get("docking_left_open").and_then(Value::as_bool) {
+ restored.tools_open = Some(open);
+ }
+ restored
+}
+
+/// Where the old docking tree held a panel.
+struct Found {
+ /// The panel's group held only it: a tab group or stack of its own.
+ alone: bool,
+ /// The innermost split around the panel's own group.
+ split: Option,
+}
+
+struct Split {
+ horizontal: bool,
+ /// The panel is in the split's second (right or bottom) child.
+ second: bool,
+ /// The split's fixed size, when it is the panel's side.
+ size: Option,
+}
+
+/// The pixel size of `size` (`{"FixedFirst": w}` or `{"FixedSecond": w}`) when it fixes the side
+/// the panel is on.
+fn fixed_size(size: Option<&Value>, second: bool) -> Option {
+ let key = if second { "FixedSecond" } else { "FixedFirst" };
+ size?.get(key)?.as_f64()
+}
+
+fn group_ids(node: &Value) -> Option> {
+ if let Some(tabs) = node.get("Tabs") {
+ return Some(tabs.get("panels")?.as_array()?.iter().filter_map(Value::as_str).collect());
+ }
+ let entries = node.get("Stack")?.get("entries")?.as_array()?;
+ Some(entries.iter().filter_map(|entry| entry.get("panel")?.as_str()).collect())
+}
+
+fn find(node: &Value, panel: &str, depth: usize) -> Option {
+ if depth > MAX_DEPTH {
+ return None;
+ }
+ if let Some(ids) = group_ids(node) {
+ return ids.contains(&panel).then(|| Found { alone: ids.iter().all(|id| *id == panel), split: None });
+ }
+ let split = node.get("Split")?;
+ for (key, second) in [("first", false), ("second", true)] {
+ if let Some(mut found) = split.get(key).and_then(|child| find(child, panel, depth + 1)) {
+ // A panel sharing its group was placed by its group, not by a split.
+ if found.alone && found.split.is_none() {
+ let horizontal = split.get("axis").and_then(Value::as_str) == Some("Horizontal");
+ found.split = Some(Split { horizontal, second, size: fixed_size(split.get("size"), second) });
+ }
+ return Some(found);
+ }
+ }
+ None
+}
+
+/// The old layout's saved place of a closed panel: whether it was on the right and its width.
+fn hidden_place<'a>(old: &'a Value, panel: &str) -> Option<&'a Value> {
+ old.get("hidden")?.as_array()?.iter().find_map(|entry| {
+ let pair = entry.as_array()?;
+ (pair.first()?.as_str()? == panel).then(|| pair.get(1)).flatten()
+ })
+}
+
+fn floating_contains(old: &Value, panel: &str) -> bool {
+ old.get("layout").and_then(|l| l.get("floating")).and_then(Value::as_array).is_some_and(|groups| {
+ groups.iter().any(|group| group.get("panels").and_then(Value::as_array).is_some_and(|ids| ids.iter().any(|id| id.as_str() == Some(panel))))
+ })
+}
+
+/// A panel of the old layout: on the right edge, open, and its width where the layout stated it
+/// in points. A floating or tabbed panel counts as on the left, as the old Tools side reading did.
+fn old_panel(old: &Value, panel: &str) -> (bool, bool, Option) {
+ let root = old.get("layout").and_then(|l| l.get("root"));
+ if let Some(found) = root.and_then(|root| find(root, panel, 0)) {
+ return match found.split {
+ Some(split) if split.horizontal => (split.second, true, split.size),
+ _ => (false, true, None),
+ };
+ }
+ if floating_contains(old, panel) {
+ return (false, true, None);
+ }
+ let Some(place) = hidden_place(old, panel) else { return (false, false, None) };
+ let right = place.get("placement").and_then(|p| p.get("Split")).and_then(Value::as_str) == Some("Right");
+ let left = place.get("placement").and_then(|p| p.get("Split")).and_then(Value::as_str) == Some("Left");
+ let size = if right || left { fixed_size(place.get("size"), right) } else { None };
+ (right, false, size)
+}
+
+/// Reads the old docking layout (craft-ui's `Workspace`) as plain JSON. `None` when it has no
+/// document view, which the old layout always held.
+fn migrate(old: &Value) -> Option {
+ find(old.get("layout")?.get("root")?, "canvas", 0)?;
+ let mut panels = SidePanels::default();
+ let (tools_right, tools_open, tools_width) = old_panel(old, "tools");
+ let (_, side_open, side_width) = old_panel(old, "inspector");
+ if tools_right {
+ panels.tools_side = ToolsSide::Right;
+ }
+ if let Some(width) = tools_width.and_then(|w| clamp_width(Pane::Tools, w)) {
+ panels.tools_width = width;
+ }
+ if let Some(width) = side_width.and_then(|w| clamp_width(Pane::Side, w)) {
+ panels.side_width = width;
+ }
+ Some(Restored { panels, tools_open: Some(tools_open), side_closed: !side_open })
+}
+
+/// What `ui.panel` and `ui.state` report.
+pub fn state(app: &PdfCraftApp) -> Value {
+ let panels = &app.side_panels;
+ json!({
+ "tools_side": panels.tools_side,
+ "tools_open": app.left_open,
+ "tools_width": panels.tools_width,
+ "side_open": app.right.is_some(),
+ "side_panel": app.right.map(|r| format!("{r:?}")),
+ "side_width": panels.side_width,
+ })
+}
+
+/// `ui.panel {panel, open?, width?}` and `ui.dock.reset` (`reset`).
+pub fn command(app: &mut PdfCraftApp, reset: bool, params: &Value) -> Result {
+ if reset {
+ app.side_panels = SidePanels::default();
+ app.left_open = true;
+ return Ok(json!({ "side_panels": state(app) }));
+ }
+ let pane = Pane::parse(params.get("panel").and_then(Value::as_str).ok_or("panel must be \"tools\" or \"side\"")?)
+ .ok_or("panel must be \"tools\" or \"side\"")?;
+ let open = match params.get("open") {
+ None | Some(Value::Null) => None,
+ Some(value) => Some(value.as_bool().ok_or("open must be a boolean")?),
+ };
+ let width = match params.get("width") {
+ None | Some(Value::Null) => None,
+ Some(value) => Some(value.as_f64().and_then(|w| clamp_width(pane, w)).ok_or("width must be a finite number")?),
+ };
+ if let Some(width) = width {
+ app.side_panels.set_width(pane, width);
+ }
+ match (pane, open) {
+ (Pane::Tools, Some(open)) => app.left_open = open,
+ (Pane::Side, Some(true)) if app.right.is_none() => app.toggle_side_panel(),
+ (Pane::Side, Some(false)) if app.right.is_some() => app.choose_right_panel(None),
+ _ => {}
+ }
+ Ok(json!({ "side_panels": state(app) }))
+}
+
+struct Content<'a> {
+ app: &'a mut PdfCraftApp,
+}
+
+impl PaneContent for Content<'_> {
+ fn title(&self, pane: &Pane) -> String {
+ crate::i18n::t(match pane {
+ Pane::Tools => "Tools",
+ Pane::Side => "Inspector",
+ })
+ .to_owned()
+ }
+
+ fn body(&mut self, ui: &mut egui::Ui, pane: &Pane) {
+ match pane {
+ Pane::Tools => crate::panels::left_panel(self.app, ui),
+ Pane::Side => {
+ ui.data_mut(|data| data.insert_temp(egui::Id::new("pdfcraft-inspector-body"), ui.max_rect()));
+ crate::panels::right_panel(self.app, ui);
+ }
+ }
+ }
+
+ fn default_state(&self, pane: &Pane) -> PaneState {
+ state_of(&self.app.side_panels, *pane)
+ }
+
+ fn size_range(&self, pane: &Pane) -> RangeInclusive {
+ pane.range()
+ }
+
+ fn movable(&self, _pane: &Pane) -> bool {
+ false
+ }
+
+ fn title_bar(&self, _pane: &Pane) -> bool {
+ // Both panels draw their own header and Close.
+ false
+ }
+}
+
+fn state_of(panels: &SidePanels, pane: Pane) -> PaneState {
+ let edge = match pane {
+ Pane::Tools => panels.tools_edge(),
+ Pane::Side => Edge::End,
+ };
+ PaneState::docked(edge, panels.width(pane))
+}
+
+/// The panes that show this frame, outermost first on a shared edge.
+fn open_panes(app: &PdfCraftApp) -> Vec {
+ let mut open = Vec::new();
+ // Read mode hides the tools, not the navigation panes.
+ if app.left_open && app.mode != Mode::Read {
+ open.push(Pane::Tools);
+ }
+ // Home and Combine files have no side panel; the choice is kept for the next document.
+ if app.active.is_some() && app.right.is_some() {
+ open.push(Pane::Side);
+ }
+ open
+}
+
+/// Draws the rail and the open panes. Call after the top bars and before the central panel.
+pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
+ if let Some(panel) = app.right {
+ app.last_right = panel;
+ }
+ // The rail first: it is the outermost panel on the right.
+ if app.active.is_some() {
+ crate::chrome::right_rail(app, ui);
+ }
+ // The expanded All tools view covers the panes.
+ if app.mode == Mode::AllTools && app.all_tools_full {
+ return;
+ }
+ let open = open_panes(app);
+ if open.is_empty() {
+ return;
+ }
+ let t = crate::theme::Tokens::get(ui.ctx());
+ let labels = PaneLabels { resize: crate::i18n::t("Resize panels").to_owned(), ..PaneLabels::default() };
+ let panels = app.side_panels;
+ // Columns: both panes may share the right edge, Tools outermost, as `open` lists them.
+ let output = PaneHost::new("pdfcraft-side-panels")
+ .policy(SidePolicy::Columns)
+ .f6(false)
+ .labels(labels)
+ .frame(egui::Frame::NONE.fill(t.panel))
+ .body_margin(egui::Margin::ZERO)
+ .show(ui, &open, |pane| Some(state_of(&panels, *pane)), &mut Content { app });
+ for request in output.requests {
+ match request {
+ PaneRequest::Resize(pane, width) => app.side_panels.set_width(pane, width),
+ PaneRequest::Close(Pane::Tools) => app.left_open = false,
+ PaneRequest::Close(Pane::Side) => app.choose_right_panel(None),
+ _ => {}
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn tabs(panels: &[&str]) -> Value {
+ json!({"Tabs": {"panels": panels, "active": 0}})
+ }
+
+ fn split(size: Value, first: Value, second: Value) -> Value {
+ json!({"Split": {"axis": "Horizontal", "size": size, "first": first, "second": second}})
+ }
+
+ fn workspace(root: Value, floating: Value, hidden: Value) -> Value {
+ json!({"docking": {"layout": {"root": root, "floating": floating}, "hidden": hidden}})
+ }
+
+ /// The old default layout, as craft-ui 5c874d5 serialized it.
+ fn docked(tools: f64, inspector: f64) -> Value {
+ workspace(
+ split(json!({"FixedFirst": tools}), tabs(&["tools"]), split(json!({"FixedSecond": inspector}), tabs(&["canvas"]), tabs(&["inspector"]))),
+ json!([]),
+ json!([]),
+ )
+ }
+
+ #[test]
+ fn docked_default_keeps_widths() {
+ let restored = restore(&docked(300.0, 400.0));
+ assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Left, tools_width: 300.0, side_width: 400.0 });
+ assert_eq!((restored.tools_open, restored.side_closed), (Some(true), false));
+ }
+
+ #[test]
+ fn tools_on_the_right_moves_right() {
+ // `set_tools_on_right` put Tools to the right of the Inspector.
+ let old = workspace(
+ split(json!({"FixedSecond": 330.0}), tabs(&["canvas"]), split(json!({"FixedSecond": 290.0}), tabs(&["inspector"]), tabs(&["tools"]))),
+ json!([]),
+ json!([]),
+ );
+ let restored = restore(&old);
+ assert_eq!(restored.panels.tools_side, ToolsSide::Right);
+ assert_eq!(restored.panels.tools_width, 290.0);
+ // The Inspector's own split fixes Tools' width, not its.
+ assert_eq!(restored.panels.side_width, SIDE_WIDTH);
+ }
+
+ #[test]
+ fn floated_tools_docks_left_and_stays_open() {
+ let old = workspace(
+ split(json!({"FixedSecond": 360.0}), tabs(&["canvas"]), tabs(&["inspector"])),
+ json!([{"panels": ["tools"], "active": 0, "rect": [10.0, 20.0, 300.0, 400.0]}]),
+ json!([]),
+ );
+ let restored = restore(&old);
+ assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Left, tools_width: TOOLS_WIDTH, side_width: 360.0 });
+ assert_eq!((restored.tools_open, restored.side_closed), (Some(true), false));
+ }
+
+ #[test]
+ fn tools_tabbed_with_the_inspector_docks_both() {
+ let old = workspace(split(json!({"FixedSecond": 350.0}), tabs(&["canvas"]), tabs(&["inspector", "tools"])), json!([]), json!([]));
+ let restored = restore(&old);
+ assert_eq!(restored.panels.tools_side, ToolsSide::Left);
+ assert_eq!((restored.panels.tools_width, restored.panels.side_width), (TOOLS_WIDTH, SIDE_WIDTH));
+ assert_eq!((restored.tools_open, restored.side_closed), (Some(true), false));
+ }
+
+ #[test]
+ fn a_hidden_inspector_stays_closed_and_hidden_tools_keeps_its_side() {
+ let old = workspace(
+ tabs(&["canvas"]),
+ json!([]),
+ json!([
+ ["inspector", {"floating": null, "anchor": "canvas", "placement": {"Split": "Right"}, "size": {"FixedSecond": 380.0}, "stack": null, "sibling": ["canvas"]}],
+ ["tools", {"floating": null, "anchor": "canvas", "placement": {"Split": "Right"}, "size": {"FixedSecond": 250.0}, "stack": null, "sibling": ["canvas"]}]
+ ]),
+ );
+ let restored = restore(&old);
+ assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Right, tools_width: 250.0, side_width: 380.0 });
+ assert_eq!((restored.tools_open, restored.side_closed), (Some(false), true));
+ }
+
+ #[test]
+ fn malformed_values_give_the_defaults() {
+ let defaults = Restored { panels: SidePanels::default(), tools_open: None, side_closed: false };
+ for saved in [
+ json!({"docking": "not a layout"}),
+ json!({"docking": {"layout": [1, 2]}}),
+ json!({"docking": {"layout": {"root": {"Split": {"axis": 3, "size": {"FixedFirst": "wide"}, "first": null}}}}}),
+ json!({"side_panels": {"version": 2, "tools_side": "right"}}),
+ json!({"side_panels": "right"}),
+ ] {
+ let restored = restore(&saved);
+ assert_eq!(restored, defaults, "{saved}");
+ }
+ // Non-finite and out-of-range widths fall back or clamp; an unknown side keeps Left.
+ let restored = restore(&json!({"side_panels": {"version": 1, "tools_side": "up", "tools_width": 9000.0, "side_width": "x"}}));
+ assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Left, tools_width: 520.0, side_width: SIDE_WIDTH });
+ // Deep trees are cut off rather than walked.
+ let mut deep = tabs(&["tools"]);
+ for _ in 0..200 {
+ deep = split(json!({"Ratio": 0.5}), tabs(&["canvas"]), deep);
+ }
+ assert_eq!(restore(&workspace(deep, json!([]), json!([]))).panels.tools_side, ToolsSide::Left);
+ }
+
+ #[test]
+ fn saved_value_round_trips() {
+ let panels = SidePanels { tools_side: ToolsSide::Right, tools_width: 300.0, side_width: 410.0 };
+ let restored = restore(&json!({ "side_panels": panels.to_json(false) }));
+ assert_eq!(restored, Restored { panels, tools_open: Some(false), side_closed: false });
+ }
+}
diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs
index 7099ece40..3ad7953b3 100644
--- a/crates/ui-egui/tests/it/control.rs
+++ b/crates/ui-egui/tests/it/control.rs
@@ -4,7 +4,7 @@
use std::sync::{Arc, Mutex};
use egui_kittest::Harness;
-use egui_kittest::kittest::{NodeT, Queryable};
+use egui_kittest::kittest::Queryable;
use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind};
use pdfcraft_ui_egui::PdfCraftApp;
use pdfcraft_ui_egui::control::{ControlClient, Reply};
@@ -420,16 +420,16 @@ fn lithuanian_inspector_title_updates_through_the_control_channel() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({ "key": "panel", "value": "comments" }));
h.run_steps(2);
- h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector");
+ h.get_by_label("Inspector");
let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone();
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "lt" }));
h.run_steps(2);
- h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspektorius");
- assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").is_none());
+ h.get_by_label("Inspektorius");
+ assert!(h.query_by_label("Inspector").is_none());
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"], documents);
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "en" }));
h.run_steps(2);
- h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector");
+ h.get_by_label("Inspector");
}
/// Switching the interface language changes labels only: documents, their dirty state and the
@@ -1497,135 +1497,72 @@ fn a_plan_scale_picked_or_typed_applies_at_once() {
}
#[test]
-fn docking_pointer_group_float_close_restore_and_document_commands() {
- use pdfcraft_ui_egui::docking::Panel;
+fn ui_panel_opens_closes_and_sizes_panes_and_survives_restore() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"}));
- h.run_steps(4);
- // Tools docked alone has no tab strip (as Acrobat's tool pane), so it is grouped by command.
- assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Tools").is_none());
- ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"}));
- h.run_steps(4);
- assert_eq!(h.state().docking.layout.location(&Panel::Inspector).unwrap().anchor, Some(Panel::Tools));
- // Grouped, Tools has its tab again.
- h.get_by_role_and_label(egui::accesskit::Role::Tab, "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);
+ let state = ok(&mut h, &c, "ui.panel", json!({"panel":"side", "width": 401.6}));
+ assert_eq!(state["side_panels"]["side_width"], 401.6f32 as f64);
+ // Widths are clamped to the panel's limits.
+ let state = ok(&mut h, &c, "ui.panel", json!({"panel":"tools", "width": 9000}));
+ assert_eq!(state["side_panels"]["tools_width"], 520.0);
+ ok(&mut h, &c, "ui.panel", json!({"panel":"tools", "width": 300, "open": false}));
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);
+ assert!(!h.state().left_open);
+ ok(&mut h, &c, "ui.panel", json!({"panel":"side", "open": false}));
+ h.run_steps(3);
+ assert_eq!(h.state().right, None);
+ assert!(h.query_by_label("This document has no bookmarks.").is_none());
+ // Reopening the side panel brings back the one open last.
+ ok(&mut h, &c, "ui.panel", json!({"panel":"side", "open": true}));
+ h.run_steps(3);
+ h.get_by_label("This document has no bookmarks.");
+ assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["side_panels"]["side_panel"], "Bookmarks");
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"}));
+ let state = ok(&mut reloaded, &client, "ui.state", json!({}))["side_panels"].clone();
+ assert_eq!(
+ state,
+ json!({"tools_side":"left", "tools_open":false, "tools_width":300.0, "side_open":true, "side_panel":"Bookmarks", "side_width":401.6f32 as f64})
+ );
+ // The ui.command form and the reset command.
+ ok(&mut reloaded, &client, "ui.command", json!({"id":"ui.panel", "params":{"panel":"tools", "open":true}}));
+ assert!(reloaded.state().left_open);
+ ok(&mut reloaded, &client, "ui.panel", json!({"panel":"tools", "width": 300}));
+ ok(&mut reloaded, &client, "ui.set", json!({"key":"tools-side", "value":"right"}));
+ ok(&mut reloaded, &client, "ui.command", json!({"id":"ui.dock.reset"}));
+ let state = ok(&mut reloaded, &client, "ui.state", json!({}))["side_panels"].clone();
+ assert_eq!((state["tools_side"].clone(), state["tools_width"].clone(), state["side_width"].clone()), (json!("left"), json!(272.0), json!(330.0)));
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() {
+fn invalid_panel_requests_change_nothing() {
let (mut h, c) = harness();
- let before = serde_json::to_value(&h.state().docking).unwrap();
+ let before = ok(&mut h, &c, "ui.state", json!({}))["side_panels"].clone();
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"}),
+ json!({"panel":"canvas"}),
+ json!({"panel":"inspector", "open": true}),
+ json!({}),
+ json!({"panel":"tools", "open":"yes"}),
+ json!({"panel":"tools", "width":"wide"}),
+ json!({"panel":"side", "width": 300, "open": 1}),
] {
- assert!(call(&mut h, &c, "ui.dock", params).is_err());
- assert_eq!(serde_json::to_value(&h.state().docking).unwrap(), before);
+ assert!(call(&mut h, &c, "ui.panel", params).is_err());
let state = ok(&mut h, &c, "ui.state", json!({}));
+ assert_eq!(state["side_panels"], before);
assert_eq!(state["documents"][0]["pages"], 5);
assert_eq!(state["documents"][0]["dirty"], false);
}
}
+/// #3580 review: a panel a script opens (`app.execMenuItem("ShowHideBookmarks")`) is shown.
#[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,
- left: None,
- top: None,
- });
- h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark {
- parent: vec![],
- index: 1,
- title: "Details".into(),
- page: 2,
- left: None,
- top: None,
- });
- 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") {
- let _gpu = crate::gpu();
- 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);
- // The Edit panel's own row: the tool's name is on the floating toolbar too while it is on.
- h.get_by_label("Header and footer");
-}
-
-/// #3580 review: a panel a script opens (`app.execMenuItem("ShowHideBookmarks")`) is shown even
-/// when the inspector is a background tab of a custom docking layout.
-#[test]
-fn a_script_opened_panel_reveals_an_inactive_inspector() {
+fn a_script_opened_panel_shows() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"pages"}));
- 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);
assert!(h.query_by_label("This document has no bookmarks.").is_none());
let id = h.state().active_ids().unwrap().1;
@@ -1635,12 +1572,11 @@ fn a_script_opened_panel_reveals_an_inactive_inspector() {
}
#[test]
-fn home_and_combine_suppress_inspector_without_forgetting_layout_or_choice() {
- use pdfcraft_ui_egui::docking::Panel;
+fn home_and_combine_hide_the_side_panel_without_forgetting_it() {
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();
+ ok(&mut h, &c, "ui.panel", json!({"panel":"side", "width": 380}));
+ let before = ok(&mut h, &c, "ui.state", json!({}))["side_panels"].clone();
for combine in [false, true] {
if combine {
h.state_mut().open_combine_tab();
@@ -1648,62 +1584,41 @@ fn home_and_combine_suppress_inspector_without_forgetting_layout_or_choice() {
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") {
- let _gpu = crate::gpu();
- 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!(h.query_by_label("This document has no bookmarks.").is_none());
assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks));
- assert_eq!(serde_json::to_value(&h.state().docking).unwrap(), before);
+ assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["side_panels"], 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") {
- let _gpu = crate::gpu();
- h.render().unwrap().save(format!("{directory}/availability-return.png")).unwrap();
- }
- assert_eq!(serde_json::to_value(&h.state().docking).unwrap(), before);
+ assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["side_panels"], before);
}
}
#[test]
-fn malformed_docking_settings_preserve_other_preferences() {
+fn malformed_panel_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();
+ assert_eq!(app.side_panels, pdfcraft_ui_egui::side_panels::SidePanels::default());
let saved: Value = serde_json::from_str(&app.persist()).unwrap();
assert_eq!(saved["default_mode"], "edit");
assert_eq!(saved["flatten_fill_sign"], true);
+ // The old value is dropped on the next save.
+ assert!(saved.get("docking").is_none());
+ assert_eq!(saved["side_panels"]["version"], 1);
}
#[test]
-fn rail_reveals_its_remembered_inactive_inspector_then_closes_the_visible_panel() {
- use pdfcraft_ui_egui::docking::Panel;
+fn rail_opens_a_panel_then_closes_the_open_one() {
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);
}
diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs
index 12fc0cd92..a5c319235 100644
--- a/crates/ui-egui/tests/it/main.rs
+++ b/crates/ui-egui/tests/it/main.rs
@@ -68,6 +68,7 @@ mod reading_colors;
mod recovery;
mod redact;
mod reload;
+mod side_panels;
mod signing;
mod stamps;
mod text_select;
diff --git a/crates/ui-egui/tests/it/panel_snapshots.rs b/crates/ui-egui/tests/it/panel_snapshots.rs
index c9cc1d590..97fba8777 100644
--- a/crates/ui-egui/tests/it/panel_snapshots.rs
+++ b/crates/ui-egui/tests/it/panel_snapshots.rs
@@ -87,12 +87,12 @@ fn harness(panel: &'static str) -> Harness<'static, PdfCraftApp> {
h
}
-/// The side panel's contents: the dock node after the Inspector tab.
+/// The side panel's contents: the node after the pane's own focus stop, named "Inspector".
fn panel<'h>(h: &'h Harness<'static, PdfCraftApp>) -> Node<'h> {
- let tab = h.get_by(|n| n.role() == Role::Tab && n.label().as_deref() == Some("Inspector"));
- let dock = tab.parent().expect("the Inspector tab sits in a dock node");
- let mut after = dock.children().skip_while(|n| n.accesskit_node().id() != tab.accesskit_node().id()).skip(1);
- after.next().expect("the panel follows its tab")
+ let stop = h.get_by(|n| n.role() != Role::Label && n.label().as_deref() == Some("Inspector"));
+ let pane = stop.parent().expect("the side panel's focus stop sits in its pane");
+ let mut after = pane.children().skip_while(|n| n.accesskit_node().id() != stop.accesskit_node().id()).skip(1);
+ after.next().expect("the panel follows its focus stop")
}
/// One line per node that says something: layout containers are skipped (their children move up a
diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs
index 145446cc1..78a8b0b3b 100644
--- a/crates/ui-egui/tests/it/pointer.rs
+++ b/crates/ui-egui/tests/it/pointer.rs
@@ -339,10 +339,10 @@ fn link_tip_stays_off_while_the_palette_is_open() {
h.get_by_label("https://example.org/");
}
-/// A panel's tab carried over a link shows neither its tip nor its cursor, and the link answers
-/// again once the tab is let go.
+/// A panel's resize edge dragged over a link shows neither the link's tip nor its cursor, and the
+/// link answers again once the edge is let go.
#[test]
-fn a_dragged_panel_tab_passes_over_a_link_without_its_tip() {
+fn a_dragged_panel_edge_passes_over_a_link_without_its_tip() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut h = open(link_pdf(), "150", false);
@@ -354,10 +354,17 @@ fn a_dragged_panel_tab_passes_over_a_link_without_its_tip() {
assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand);
h.run_steps(2);
assert!(tip_shown(&h), "the link's tip shows over an uncovered page");
- // Away from the link, then the Inspector's tab is picked up and carried over it.
+ // Away from the link, then the side panel's resize edge (the rightmost one) is picked up and
+ // carried over it.
h.hover_at(at(&h, 150.0, 100.0));
h.run_steps(4);
- let tab = h.get_by(|n| n.role() == Role::Tab && n.label().as_deref() == Some("Inspector")).rect().center();
+ let tab = h
+ .get_all_by(|n| n.role() == Role::Splitter && n.label().as_deref() == Some("Resize panels"))
+ .map(|n| n.rect().center())
+ .max_by(|a, b| a.x.total_cmp(&b.x))
+ .expect("the side panel has a resize edge")
+ // The pane's half of the edge: the page, drawn after the panes, takes the other half.
+ + egui::vec2(2.0, 0.0);
h.hover_at(tab);
h.run_steps(1);
h.drag_at(tab);
@@ -368,11 +375,11 @@ fn a_dragged_panel_tab_passes_over_a_link_without_its_tip() {
}
for _ in 0..4 {
h.run_steps(2);
- assert!(h.ctx.dragged_id().is_some(), "the tab is still being dragged");
- assert!(!tip_shown(&h), "no link tip under a dragged panel tab");
+ assert!(h.ctx.dragged_id().is_some(), "the edge is still being dragged");
+ assert!(!tip_shown(&h), "no link tip under a dragged panel edge");
assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand);
}
- // Back to the tab and let go there, which leaves the layout as it was.
+ // Back to the start and let go there, which leaves the width as it was.
for i in 1..=8 {
h.hover_at(on_link + (tab - on_link) * (i as f32 / 8.0));
h.run_steps(1);
diff --git a/crates/ui-egui/tests/it/side_panels.rs b/crates/ui-egui/tests/it/side_panels.rs
new file mode 100644
index 000000000..2436d41b6
--- /dev/null
+++ b/crates/ui-egui/tests/it/side_panels.rs
@@ -0,0 +1,133 @@
+//! Side panels: the Tools pane, the rail and the open side panel.
+
+use egui::accesskit::Role;
+use egui_kittest::Harness;
+use egui_kittest::kittest::Queryable;
+use pdfcraft_ui_egui::PdfCraftApp;
+
+const FIXTURE: &[u8] = b"%PDF-1.7
+1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
+2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
+3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] >> endobj
+trailer << /Root 1 0 R >>
+%%EOF";
+
+const WIDTH: f32 = 1400.0;
+const NO_BOOKMARKS: &str = "This document has no bookmarks.";
+
+fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
+ let mut h = Harness::builder().with_size(egui::vec2(WIDTH, 900.0)).build_eframe(move |_cc| {
+ let mut app = PdfCraftApp::new();
+ app.set_option("language", "en").unwrap();
+ app.open_bytes("doc.pdf", None, FIXTURE.to_vec()).unwrap();
+ setup(&mut app);
+ app
+ });
+ h.run_steps(6);
+ h
+}
+
+fn rail(h: &Harness<'static, PdfCraftApp>) -> egui::Rect {
+ h.get_by_role_and_label(Role::Button, "Bookmarks").rect()
+}
+
+fn side(h: &Harness<'static, PdfCraftApp>) -> egui::Rect {
+ h.get_by_label(NO_BOOKMARKS).rect()
+}
+
+/// The Tools pane's All tools header button.
+fn tools(h: &Harness<'static, PdfCraftApp>) -> egui::Rect {
+ h.get_by_label("Expand all tools").rect()
+}
+
+#[test]
+fn rail_is_outermost_then_tools_then_the_side_panel_then_the_page() {
+ // Tools on the left: the page between Tools and the side panel, the rail at the right edge.
+ let h = harness(|app| app.set_option("panel", "bookmarks").unwrap());
+ let (r, s, t) = (rail(&h), side(&h), tools(&h));
+ assert!(r.right() > WIDTH - 12.0, "{r:?}");
+ assert!(s.right() < r.left() && s.left() > WIDTH - 48.0 - 340.0, "{s:?} {r:?}");
+ assert!(t.left() < 48.0, "{t:?}");
+ // Tools on the right: rail, Tools, side panel, page.
+ let h = harness(|app| {
+ app.set_option("tools-side", "right").unwrap();
+ app.set_option("panel", "bookmarks").unwrap();
+ });
+ let (r, s, t) = (rail(&h), side(&h), tools(&h));
+ assert!(r.right() > WIDTH - 12.0, "{r:?}");
+ assert!(t.right() < r.left() && t.left() > WIDTH - 48.0 - 280.0, "{t:?} {r:?}");
+ assert!(s.right() < t.left() && s.left() > WIDTH - 48.0 - 280.0 - 340.0, "{s:?} {t:?}");
+}
+
+#[test]
+fn read_mode_shows_the_side_panel_without_tools() {
+ let mut h = harness(|app| app.set_option("panel", "bookmarks").unwrap());
+ h.state_mut().set_option("mode", "read").unwrap();
+ h.run_steps(4);
+ assert!(h.query_by_label("Expand all tools").is_none());
+ assert!(side(&h).right() < rail(&h).left() && side(&h).left() > WIDTH / 2.0);
+ // The rail closes and opens it there too.
+ h.get_by_role_and_label(Role::Button, "Bookmarks").click();
+ h.run_steps(4);
+ assert!(h.query_by_label(NO_BOOKMARKS).is_none());
+ h.get_by_role_and_label(Role::Button, "Bookmarks").click();
+ h.run_steps(4);
+ assert!(side(&h).right() < rail(&h).left());
+}
+
+#[test]
+fn a_dragged_width_is_remembered_across_restarts() {
+ let mut h = harness(|app| app.set_option("panel", "bookmarks").unwrap());
+ let before = h.state().side_panels.side_width;
+ let side_left = side(&h).left();
+ // The side panel's resize edge: the one right of the page, left of the panel's text.
+ let edge = h
+ .get_all_by_label("Resize panels")
+ .map(|n| n.rect().center())
+ .filter(|c| c.x < side_left && c.x > WIDTH / 2.0)
+ .min_by(|a, b| (side_left - a.x).total_cmp(&(side_left - b.x)))
+ .expect("the side panel has a resize edge")
+ // The pane's half of the edge: the page, drawn after the panes, takes the other half.
+ + egui::vec2(2.0, 0.0);
+ h.hover_at(edge);
+ h.run_steps(1);
+ h.drag_at(edge);
+ h.run_steps(1);
+ for i in 1..=4 {
+ h.hover_at(edge - egui::vec2(10.0 * i as f32, 0.0));
+ h.run_steps(1);
+ }
+ h.drop_at(edge - egui::vec2(40.0, 0.0));
+ h.run_steps(3);
+ let after = h.state().side_panels.side_width;
+ // The pointer was pressed 2 pt inside the panel, and the edge follows the pointer.
+ assert!((after - (before + 40.0 - 2.0)).abs() <= 1.0, "{before} -> {after}");
+ let saved = h.state().persist();
+ let mut restored = PdfCraftApp::new();
+ restored.restore(&saved);
+ assert_eq!(restored.side_panels.side_width, after);
+}
+
+#[test]
+fn f4_toggles_the_side_panel_and_shift_f4_the_tools_panel() {
+ let mut h = harness(|app| app.set_option("panel", "bookmarks").unwrap());
+ h.get_by_label(NO_BOOKMARKS);
+ h.key_press(egui::Key::F4);
+ h.run_steps(3);
+ assert_eq!(h.state().right, None);
+ assert!(h.query_by_label(NO_BOOKMARKS).is_none());
+ // F4 again reopens the panel that was open, not the default.
+ h.key_press(egui::Key::F4);
+ h.run_steps(3);
+ h.get_by_label(NO_BOOKMARKS);
+ assert!(h.state().left_open);
+ h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::F4);
+ h.run_steps(3);
+ assert!(!h.state().left_open);
+ assert!(h.query_by_label("Expand all tools").is_none());
+ // Shift+F4 leaves the side panel alone.
+ h.get_by_label(NO_BOOKMARKS);
+ h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::F4);
+ h.run_steps(3);
+ h.get_by_label("Expand all tools");
+}
diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs
index 7582b6c4d..a047e916e 100644
--- a/crates/ui-egui/tests/it/ui.rs
+++ b/crates/ui-egui/tests/it/ui.rs
@@ -49,14 +49,14 @@ fn tools_panel_side_preference_moves_and_persists() {
h.get_by_label("Tools panel side");
h.get_by_label("Right").click();
h.run_steps(3);
- assert!(h.state().docking.tools_on_right());
+ assert!(h.state().side_panels.tools_on_right());
let saved = h.state().persist();
let mut restored = PdfCraftApp::new();
restored.restore(&saved);
- assert!(restored.docking.tools_on_right());
+ assert!(restored.side_panels.tools_on_right());
h.get_by_label("Left").click();
h.run_steps(3);
- assert!(!h.state().docking.tools_on_right());
+ assert!(!h.state().side_panels.tools_on_right());
}
/// Every piece of text painted on the last frame: its whole text, whether it was cut with "…",