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
This commit is contained in:
aiongg
2026-10-25 12:06:35 -04:00
committed by GitHub
parent 71b47b519d
commit d764a8ebca
19 changed files with 974 additions and 959 deletions
Generated
+6 -2
View File
@@ -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",
+1 -1
View File
@@ -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.
+8 -19
View File
@@ -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.
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
Tools and Inspector panels can be dragged into tab groups, split beside the PDF, or floated within the app window. Read and full-screen modes keep their existing focused document view. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
Advanced `ui.dock` operations use the same validated transaction as pointer gestures:
`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and
`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`),
`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null.
A center `move` can include `before` (panel ID or null to append) for tab order.
The equivalent `{"action": ...}` form accepts a serialized shared docking action;
this is also the form emitted by the renderer. Protected-canvas and app-specific panel
rules apply equally to both forms.
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.
+17 -1
View File
@@ -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"),
+1 -2
View File
@@ -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<R>(
ui: &mut egui::Ui,
scroll: egui::ScrollArea,
+152 -153
View File
@@ -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<String> = 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::<String>(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::<String>(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::<String>(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::<String>(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
+3 -1
View File
@@ -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.
+8 -8
View File
@@ -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<egui::Rect>, ppp: f32
}
impl Host for crate::PdfCraftApp {
fn dock(&mut self, reset: bool, params: &Value) -> Reply {
crate::docking::command(self, reset, params)
fn 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<Value> = 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 })
}
-547
View File
@@ -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, String> {
Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
}
}
fn permissions(panel: &Panel) -> Permissions {
if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() }
}
fn tabs(panel: Panel) -> Node<Panel> {
Node::Tabs { panels: vec![panel], active: 0 }
}
fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Panel>) -> Node<Panel> {
Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Workspace {
pub layout: Layout<Panel>,
/// Closed panels retain their most recent group, side, size or floating rectangle.
pub hidden: Vec<(Panel, Location<Panel>)>,
}
impl Default for Workspace {
fn default() -> Self {
Self {
layout: Layout {
root: Some(split(
SplitAxis::Horizontal,
SplitSize::FixedFirst(272.0),
tabs(Panel::Tools),
split(SplitAxis::Horizontal, SplitSize::FixedSecond(330.0), tabs(Panel::Canvas), tabs(Panel::Inspector)),
)),
floating: Vec::new(),
},
hidden: Vec::new(),
}
}
}
impl Workspace {
/// 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<Panel>) -> Result<(), String> {
self.validate()?;
let mut next = self.clone();
if let Action::Close { panel } = &action {
let location = next.layout.location(panel).map_err(|error| error.to_string())?;
next.hidden.retain(|(id, _)| id != panel);
next.hidden.push((*panel, location));
} else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action {
next.hidden.retain(|(id, _)| id != panel);
}
next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?;
next.validate()?;
*self = next;
Ok(())
}
/// Whether a panel's body is exposed by its selected tab or open stack entry.
pub(crate) fn exposed(&self, panel: Panel) -> bool {
if self.layout.floating.iter().any(|group| group.panels.get(group.active) == Some(&panel)) {
return true;
}
let mut pending: Vec<_> = self.layout.root.iter().collect();
while let Some(node) = pending.pop() {
match node {
Node::Split { first, second, .. } => {
pending.push(first);
pending.push(second);
}
Node::Tabs { panels, active } if panels.get(*active) == Some(&panel) => return true,
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == panel && entry.open) => return true,
_ => {}
}
}
false
}
pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> {
if self.layout.contains(&panel) == visible {
return Ok(());
}
if !visible {
return self.apply(Action::Close { panel });
}
self.validate()?;
let mut next = self.clone();
let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone());
next.hidden.retain(|(id, _)| *id != panel);
if let Some(saved) = &mut location
&& saved.floating.is_some()
&& saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor)))
{
// The other members of a floating group may have been closed too.
saved.anchor = None;
saved.placement = Placement::Tab { before: None };
}
let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok());
if !restored {
next.layout
.apply_with_permissions(
Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) },
permissions,
)
.map_err(|error| error.to_string())?;
}
next.validate()?;
*self = next;
Ok(())
}
fn command(&mut self, params: &Value) -> Result<(), String> {
self.validate()?;
if let Some(action) = params.get("action") {
let action: Action<Panel> = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?;
return self.apply(action);
}
let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
if operation == "resizeSplit" {
let path: Vec<bool> =
serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?;
let size: SplitSize =
serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?;
return self.apply(Action::ResizeSplit { path, size });
}
let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?;
let action = match operation {
"open" => {
self.set_visible(panel, true)?;
return self.apply(Action::Activate { panel });
}
"close" => Action::Close { panel },
"activate" => Action::Activate { panel },
"setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? },
"resizeStack" => {
let value = params.get("height").ok_or("height is required")?;
let height = if value.is_null() {
None
} else {
Some(
value
.as_f64()
.filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height))
.ok_or("height must be finite and bounded")? as f32,
)
};
Action::ResizeStack { panel, height }
}
"float" | "moveFloating" => {
let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?;
let mut values = [0.0f32; 4];
for (output, value) in values.iter_mut().zip(rect) {
*output = value
.as_f64()
.filter(|number| number.is_finite() && number.abs() <= 1_000_000.0)
.ok_or("rect values must be finite and bounded")? as f32;
}
if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } }
}
"move" => {
let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?;
let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") {
"center" => Placement::Split(Zone::Center),
"left" => Placement::Split(Zone::Left),
"right" => Placement::Split(Zone::Right),
"top" => Placement::Split(Zone::Top),
"bottom" => Placement::Split(Zone::Bottom),
_ => return Err("zone must be center, left, right, top or bottom".into()),
};
let placement = if let Some(before) = params.get("before") {
if !matches!(placement, Placement::Split(Zone::Center)) {
return Err("before only applies to a center move".into());
}
Placement::Tab {
before: if before.is_null() {
None
} else {
Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?)
},
}
} else {
placement
};
Action::Move { panel, anchor, placement }
}
_ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()),
};
self.apply(action)
}
}
fn sync(workspace: &mut Workspace, app: &crate::PdfCraftApp) -> Result<(), String> {
workspace.set_visible(Panel::Tools, app.left_open)?;
workspace.set_visible(Panel::Inspector, app.right.is_some())
}
fn drain_reveals(workspace: &mut Workspace, app: &mut crate::PdfCraftApp) {
for panel in std::mem::take(&mut app.pending_panel_reveals) {
if workspace.layout.contains(&panel) {
let _ = workspace.apply(Action::Activate { panel });
}
}
}
fn publish_visibility(app: &mut crate::PdfCraftApp, workspace: &Workspace) {
app.left_open = workspace.layout.contains(&Panel::Tools);
if !workspace.layout.contains(&Panel::Inspector) {
if app.right.is_some() {
app.choose_right_panel(None);
}
} else if app.right.is_none() {
app.choose_right_panel(Some(crate::RightPanel::Comments));
}
}
pub fn command(app: &mut crate::PdfCraftApp, reset: bool, params: &Value) -> Result<Value, String> {
let mut workspace = app.docking.clone();
if reset {
workspace = Workspace::default();
workspace.set_visible(Panel::Inspector, app.right.is_some())?;
} else {
sync(&mut workspace, app)?;
workspace.command(params)?;
}
publish_visibility(app, &workspace);
app.docking = workspace;
Ok(json!({"docking": app.docking}))
}
// Split paths change when a temporarily unavailable pane is pruned. Translate renderer
// paths back to the saved tree before applying geometry changes.
fn saved_split_path(saved: &Layout<Panel>, rendered: &Layout<Panel>, path: &[bool]) -> Option<Vec<bool>> {
let mut target = rendered.root.as_ref()?;
for second in path {
let Node::Split { first, second: other, .. } = target else { return None };
target = if *second { other } else { first };
}
let mut pending = vec![(saved.root.as_ref()?, Vec::new())];
while let Some((node, original_path)) = pending.pop() {
if let Node::Split { first, second, .. } = node {
let mut projection = Layout { root: Some(node.clone()), floating: Vec::new() };
if projection.contains(&Panel::Inspector) {
let _ = projection.apply(Action::Close { panel: Panel::Inspector });
}
let has_available_panel = |child: &Node<Panel>| {
Layout { root: Some(child.clone()), floating: Vec::new() }.panels().into_iter().any(|panel| *panel != Panel::Inspector)
};
// An ancestor whose Inspector-only side disappeared projects to its surviving
// child. Its divider is absent, so only the child's split may own this resize.
if has_available_panel(first) && has_available_panel(second) && projection.root.as_ref() == Some(target) {
return Some(original_path);
}
let mut second_path = original_path.clone();
second_path.push(true);
pending.push((second, second_path));
let mut first_path = original_path;
first_path.push(false);
pending.push((first, first_path));
}
}
None
}
pub fn show(app: &mut crate::PdfCraftApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::get(ui.ctx());
let mut workspace = std::mem::take(&mut app.docking);
if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) {
app.notify_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), .. })));
}
}
+1 -1
View File
@@ -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"))
}
+5 -12
View File
@@ -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);
+34 -52
View File
@@ -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<docking::Panel>,
/// 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<DocView>,
pub(crate) closed_tabs: Vec<String>,
@@ -647,6 +647,8 @@ pub struct PdfCraftApp {
pub left: LeftPanel,
pub left_open: bool,
pub right: Option<RightPanel>,
/// 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<String> = 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::<serde_json::Value>(json) else { return };
if let Some(saved) = v.get("docking")
&& let Ok(workspace) = serde_json::from_value::<docking::Workspace>(saved.clone())
&& workspace.validate().is_ok()
{
self.docking = workspace;
self.left_open =
v.get("docking_left_open").and_then(serde_json::Value::as_bool).unwrap_or(self.docking.layout.contains(&docking::Panel::Tools));
self.right = v.get("docking_right").and_then(|value| serde_json::from_value(value.clone()).ok()).flatten();
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();
+522
View File
@@ -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<f32> = 240.0..=520.0;
const SIDE_RANGE: RangeInclusive<f32> = 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<Self> {
match id {
"tools" => Some(Self::Tools),
"side" => Some(Self::Side),
_ => None,
}
}
fn range(self) -> RangeInclusive<f32> {
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<f32> {
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<bool>,
/// 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::<ToolsSide>(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<Split>,
}
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<f64>,
}
/// 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<f64> {
let key = if second { "FixedSecond" } else { "FixedFirst" };
size?.get(key)?.as_f64()
}
fn group_ids(node: &Value) -> Option<Vec<&str>> {
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<Found> {
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<f64>) {
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<Restored> {
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<Value, String> {
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<Pane> 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<f32> {
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<Pane> {
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 });
}
}
+59 -144
View File
@@ -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);
}
+1
View File
@@ -68,6 +68,7 @@ mod reading_colors;
mod recovery;
mod redact;
mod reload;
mod side_panels;
mod signing;
mod stamps;
mod text_select;
+5 -5
View File
@@ -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
+15 -8
View File
@@ -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);
+133
View File
@@ -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");
}
+3 -3
View File
@@ -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 "…",