mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 22:41:47 -06:00
egui-037: integrate shared panes and fix repeated motion input
This commit is contained in:
Generated
+8
-4
@@ -1146,7 +1146,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -1159,7 +1159,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-quit"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"egui",
|
||||
@@ -1171,9 +1171,13 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"log",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -6510,7 +6514,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.61.2",
|
||||
|
||||
+3
-3
@@ -13,11 +13,11 @@ 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 = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68", 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 = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" }
|
||||
# Shared system spooler; document composition remains in pdfcraft-print.
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" }
|
||||
# Whether a path stored in a document may be opened (GoToR link targets).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
# internal
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -2830,8 +2830,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,
|
||||
|
||||
+153
-157
@@ -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, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2};
|
||||
|
||||
use crate::canvas::{DocView, Fit, PageLayout};
|
||||
@@ -653,170 +654,165 @@ 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(egui::Id::unique("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| {
|
||||
// `rail-nav` is nested here, so its 0.37 state key comes from this content `Ui`, not
|
||||
// the root. Migrate it at the parent the nested panel is actually shown in.
|
||||
crate::migrate_panel_state_from(ui, "rail-nav");
|
||||
// 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::unique("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)
|
||||
.align(egui::Align2::CENTER_TOP)
|
||||
.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)
|
||||
.align(egui::Align2::CENTER_TOP)
|
||||
.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"))
|
||||
.accessible_name(tl!("Current page"));
|
||||
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::unique("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)
|
||||
.align(egui::Align2::CENTER_TOP)
|
||||
.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)
|
||||
.align(egui::Align2::CENTER_TOP)
|
||||
.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"))
|
||||
.accessible_name(tl!("Current page"));
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -574,8 +574,8 @@ pub(crate) trait Host {
|
||||
fn state(&self) -> Value;
|
||||
/// Run one registered command by id and return its reply.
|
||||
fn command(&mut self, id: &str) -> Reply;
|
||||
/// Apply or reset the panel docking layout from a control request.
|
||||
fn dock(&mut self, reset: bool, params: &Value) -> Reply;
|
||||
/// Apply or reset the side panel layout from a control request.
|
||||
fn panel(&mut self, reset: bool, params: &Value) -> Reply;
|
||||
/// List the commands available to the control channel.
|
||||
fn commands(&self) -> Value;
|
||||
/// Set one application option from a control request.
|
||||
@@ -920,8 +920,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") {
|
||||
@@ -1390,8 +1390,8 @@ impl Host for crate::PdfCraftApp {
|
||||
self.fonts_ready
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -1415,7 +1415,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!({
|
||||
@@ -1478,7 +1478,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,
|
||||
@@ -1506,7 +1506,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 })
|
||||
}
|
||||
|
||||
@@ -2348,8 +2348,8 @@ mod lifecycle_tests {
|
||||
fn command(&mut self, _id: &str) -> Reply {
|
||||
Err("no commands in this test".into())
|
||||
}
|
||||
fn dock(&mut self, _reset: bool, _params: &Value) -> Reply {
|
||||
Err("no docking in this test".into())
|
||||
fn panel(&mut self, _reset: bool, _params: &Value) -> Reply {
|
||||
Err("no side panels in this test".into())
|
||||
}
|
||||
fn commands(&self) -> Value {
|
||||
json!([])
|
||||
@@ -2389,8 +2389,8 @@ mod lifecycle_tests {
|
||||
fn command(&mut self, _id: &str) -> Reply {
|
||||
Err("no commands in this test".into())
|
||||
}
|
||||
fn dock(&mut self, _reset: bool, _params: &Value) -> Reply {
|
||||
Err("no docking in this test".into())
|
||||
fn panel(&mut self, _reset: bool, _params: &Value) -> Reply {
|
||||
Err("no side panels in this test".into())
|
||||
}
|
||||
fn commands(&self) -> Value {
|
||||
self.calls.borrow_mut().push("ui.commands");
|
||||
@@ -2898,8 +2898,8 @@ mod lifecycle_tests {
|
||||
fn command(&mut self, _id: &str) -> Reply {
|
||||
Err("no commands in this test".into())
|
||||
}
|
||||
fn dock(&mut self, _reset: bool, _params: &Value) -> Reply {
|
||||
Err("no docking in this test".into())
|
||||
fn panel(&mut self, _reset: bool, _params: &Value) -> Reply {
|
||||
Err("no side panels in this test".into())
|
||||
}
|
||||
fn commands(&self) -> Value {
|
||||
json!([])
|
||||
|
||||
@@ -1,549 +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
|
||||
}
|
||||
|
||||
/// Render the docking workspace, restoring or reporting a broken saved layout, and apply the panel actions it
|
||||
/// emits.
|
||||
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::unique("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::unique("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), .. })));
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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(_) => {}
|
||||
}
|
||||
@@ -386,16 +383,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);
|
||||
|
||||
+39
-56
@@ -44,7 +44,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;
|
||||
@@ -67,6 +66,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;
|
||||
@@ -204,7 +204,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,
|
||||
@@ -625,8 +625,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>,
|
||||
@@ -649,6 +649,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.
|
||||
@@ -1106,10 +1108,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,
|
||||
@@ -2474,7 +2476,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
|
||||
@@ -2583,9 +2584,6 @@ impl PdfCraftApp {
|
||||
if self.right.is_none() {
|
||||
self.right = before;
|
||||
}
|
||||
if self.right.is_some() {
|
||||
self.pending_panel_reveals.push(docking::Panel::Inspector);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2607,17 +2605,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,
|
||||
@@ -2694,14 +2696,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) {
|
||||
@@ -3065,11 +3066,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;
|
||||
@@ -3257,12 +3260,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(())
|
||||
}
|
||||
|
||||
@@ -3455,8 +3452,8 @@ impl PdfCraftApp {
|
||||
|
||||
/// Root-level panel salts whose 0.36 persisted state key changed in 0.37.
|
||||
///
|
||||
/// `rail-nav` is not here: it is shown inside the `rail` panel's own child `Ui`, so its 0.37 key
|
||||
/// is derived from that parent and is migrated where the nested panel is built (`chrome::right_rail`).
|
||||
/// The shared rail and side panes now own their state. The legacy `rail` and
|
||||
/// `read_right_panel` entries are inert; the former `rail-nav` footer is no longer migrated.
|
||||
const PANEL_SALTS: [&str; 4] = ["tab_strip", "mode_bar", "rail", "read_right_panel"];
|
||||
|
||||
/// Copy persisted 0.36 panel sizes to 0.37's parent-scope key, once, before the first panel.
|
||||
@@ -3765,27 +3762,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),
|
||||
@@ -3794,10 +3780,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();
|
||||
@@ -3870,8 +3852,9 @@ mod panel_migration_tests {
|
||||
assert!(egui::PanelState::load(&ctx, old).is_some(), "the old key stays for rollback");
|
||||
}
|
||||
|
||||
/// A panel inside another panel (`rail-nav` inside `rail`) is keyed from its own parent
|
||||
/// `Ui`, not the root. Migrating it at the root would silently miss its 0.36 state.
|
||||
/// A synthetic nested panel is keyed from its own parent `Ui`, not the root.
|
||||
/// Migrating it at the root would silently miss its 0.36 state; the fixture uses
|
||||
/// legacy rail names but does not model the current shared rail implementation.
|
||||
#[test]
|
||||
fn nested_panel_state_migrates_from_its_actual_parent_ui() {
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
+112
-22
@@ -91,7 +91,9 @@ pub struct Motion {
|
||||
/// This frame's wheel motion, read on its first pass and reused on later passes.
|
||||
///
|
||||
/// egui empties `RawInput` between the passes of a multi-pass frame, so reading the wheel
|
||||
/// again in a later pass sees nothing and would wipe the scroll/zoom the frame presented.
|
||||
/// again in a later pass sees nothing. Only the first pass is handed the motion (a scroll
|
||||
/// area keeps the offset it moved to, a view keeps the zoom it set); later passes are handed
|
||||
/// no scroll and a unit zoom, explicitly.
|
||||
frame: Option<(u64, WheelMotion)>,
|
||||
}
|
||||
|
||||
@@ -125,8 +127,17 @@ impl Motion {
|
||||
}
|
||||
let options = ctx.options(|o| o.input_options);
|
||||
// One frame may run several passes (`request_discard`). Only the first pass has this
|
||||
// frame's events; the presented pass must still see the same wheel motion.
|
||||
// frame's events, and only it may hand the wheel on: a scroll area applies the delta to
|
||||
// its persisted offset, and a view multiplies the zoom into its persisted zoom, so
|
||||
// replaying either would apply it twice. egui also rebuilds `smooth_scroll_delta` and
|
||||
// its zoom factor from its retained wheel state every pass, so later passes must be
|
||||
// given zero scroll and a unit zoom explicitly. A missing zoom entry would not do: it
|
||||
// would fall back to egui's rebuilt factor.
|
||||
let frame = ctx.cumulative_frame_nr();
|
||||
let first_pass = match self.frame {
|
||||
Some((read_in, _)) => read_in != frame,
|
||||
None => true,
|
||||
};
|
||||
let motion = match self.frame {
|
||||
Some((read_in, motion)) if read_in == frame => motion,
|
||||
_ => {
|
||||
@@ -135,9 +146,12 @@ impl Motion {
|
||||
motion
|
||||
}
|
||||
};
|
||||
ctx.input_mut(|i| i.smooth_scroll_delta = motion.scroll);
|
||||
let (scroll, zoom) = if first_pass { (motion.scroll, motion.zoom) } else { (Vec2::ZERO, 1.0) };
|
||||
ctx.input_mut(|i| i.smooth_scroll_delta = scroll);
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data_mut(|d| d.insert_temp(zoom_id(), (pass, motion.zoom)));
|
||||
ctx.data_mut(|d| d.insert_temp(zoom_id(), (pass, zoom)));
|
||||
// Existing kinetic scrolling remains owned by egui; gating wheel input does not
|
||||
// suppress a scroll area's per-pass advancement of retained velocity.
|
||||
}
|
||||
|
||||
/// egui's animations, and its scrolling to a place, take no time unless the window is to
|
||||
@@ -525,26 +539,102 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// A multi-pass frame (a `Grid` sizing pass, `request_discard`) reads the wheel once: the
|
||||
/// second, presented pass sees the same whole step, not a zero from its emptied input.
|
||||
/// With animation effects off, a real scroll area moves by one wheel step whether the frame
|
||||
/// runs one pass or two (a `request_discard` from a sizing pass): the scroll area already
|
||||
/// applied and stored the step in the first pass, so a later pass must not apply it again,
|
||||
/// and must not replay the frame's raw wheel events either.
|
||||
#[test]
|
||||
fn a_discarded_pass_keeps_the_frames_whole_wheel_step() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut motion = Motion { reduced: true, ..Default::default() };
|
||||
fn two_pass_scroll_area_applies_one_wheel_step_like_a_single_pass() {
|
||||
fn run(two_passes: bool) -> (Vec<Vec2>, Vec<usize>) {
|
||||
let ctx = egui::Context::default();
|
||||
let mut motion = Motion { reduced: true, ..Default::default() };
|
||||
let mut offsets = Vec::new();
|
||||
let mut wheel_events = Vec::new();
|
||||
let raw = egui::RawInput {
|
||||
time: Some(0.1),
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 300.0))),
|
||||
events: vec![egui::Event::PointerMoved(egui::pos2(50.0, 50.0)), notch(-1.0, Modifiers::NONE)],
|
||||
..Default::default()
|
||||
};
|
||||
let output = ctx.run_ui(raw, |ui| {
|
||||
motion.begin_pass(ui.ctx(), AnimationEffects::System);
|
||||
wheel_events.push(ui.input(|i| i.events.iter().filter(|e| matches!(e, egui::Event::MouseWheel { .. })).count()));
|
||||
// Taller than the 300-point viewport, so one step stays far from the scroll
|
||||
// limit and a doubled step could not be hidden by clamping.
|
||||
let out = egui::ScrollArea::vertical().id_salt("motion-two-pass-scroll").auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.set_min_size(egui::vec2(100.0, 3000.0));
|
||||
});
|
||||
offsets.push(out.state.unclamped_offset());
|
||||
if two_passes && ui.ctx().current_pass_index() == 0 {
|
||||
ui.ctx().request_discard("motion two-pass scroll regression");
|
||||
}
|
||||
});
|
||||
output.drop_without_applying_deltas();
|
||||
(offsets, wheel_events)
|
||||
}
|
||||
|
||||
let (single, single_events) = run(false);
|
||||
let (two, two_events) = run(true);
|
||||
|
||||
assert_eq!(single.len(), 1, "one frame, one pass");
|
||||
assert_eq!(two.len(), 2, "the frame really ran two passes");
|
||||
assert_eq!(single_events, vec![1], "one wheel event reaches the first pass");
|
||||
assert_eq!(two_events, vec![1, 0], "raw wheel events are not replayed in the later pass");
|
||||
|
||||
let step = single[0].y;
|
||||
assert!(step > 0.0, "the wheel step scrolled: {single:?}");
|
||||
assert!((two[0].y - step).abs() < 0.01, "the first pass applies the whole step: single={step}, two-pass={two:?}");
|
||||
assert!((two[1].y - step).abs() < 0.01, "the presented pass must not apply the same step again: single={step}, two-pass={two:?}");
|
||||
}
|
||||
|
||||
/// With animation effects off, ctrl/⌘-wheel and pinch zoom reach a view's persisted zoom
|
||||
/// once, whether the frame runs one pass or two (a `request_discard` from a sizing pass):
|
||||
/// multiplying an accumulator by each pass's factor gives the single step either way. A
|
||||
/// later pass gets a unit factor explicitly, and a frame with no wheel input resets to it.
|
||||
#[test]
|
||||
fn two_pass_zoom_applies_one_step_like_a_single_pass() {
|
||||
/// One frame's `zoom_delta` readings, one per pass.
|
||||
fn frame(ctx: &egui::Context, motion: &mut Motion, time: f64, events: Vec<Event>, two_passes: bool) -> Vec<f32> {
|
||||
let raw = egui::RawInput {
|
||||
time: Some(time),
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 300.0))),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut zooms = Vec::new();
|
||||
let output = ctx.run_ui(raw, |ui| {
|
||||
motion.begin_pass(ui.ctx(), AnimationEffects::System);
|
||||
zooms.push(zoom_delta(ui.ctx()));
|
||||
if two_passes && ui.ctx().current_pass_index() == 0 {
|
||||
ui.ctx().request_discard("motion two-pass zoom regression");
|
||||
}
|
||||
});
|
||||
output.drop_without_applying_deltas();
|
||||
zooms
|
||||
}
|
||||
|
||||
let line = egui::InputOptions::default().line_scroll_speed;
|
||||
let mut seen = Vec::new();
|
||||
let raw = egui::RawInput { time: Some(0.1), events: vec![notch(-1.0, Modifiers::NONE)], ..Default::default() };
|
||||
let output = ctx.run_ui(raw, |ui| {
|
||||
motion.begin_pass(ui.ctx(), AnimationEffects::System);
|
||||
seen.push(ui.ctx().input(|i| i.smooth_scroll_delta));
|
||||
if ui.ctx().current_pass_index() == 0 {
|
||||
ui.ctx().request_discard("motion multipass test");
|
||||
}
|
||||
});
|
||||
output.drop_without_applying_deltas();
|
||||
assert_eq!(seen.len(), 2, "the frame really ran two passes");
|
||||
assert_eq!(seen[0], vec2(0.0, -line), "the first pass reads the notch");
|
||||
assert_eq!(seen[1], seen[0], "the presented pass keeps it instead of wiping it");
|
||||
let ctrl_step = (egui::InputOptions::default().scroll_zoom_speed * line).exp();
|
||||
for (name, events, step) in [("ctrl-wheel", vec![notch(1.0, Modifiers::COMMAND)], ctrl_step), ("pinch", vec![Event::Zoom(1.5)], 1.5)] {
|
||||
assert!((step - 1.0).abs() > 0.01, "{name}: the factor is nontrivial: {step}");
|
||||
let single = {
|
||||
let ctx = egui::Context::default();
|
||||
let mut motion = Motion { reduced: true, ..Default::default() };
|
||||
frame(&ctx, &mut motion, 0.1, events.clone(), false)
|
||||
};
|
||||
let ctx = egui::Context::default();
|
||||
let mut motion = Motion { reduced: true, ..Default::default() };
|
||||
let two = frame(&ctx, &mut motion, 0.1, events, true);
|
||||
assert_eq!(single.len(), 1, "{name}: one frame, one pass: {single:?}");
|
||||
assert_eq!(two.len(), 2, "{name}: the frame really ran two passes: {two:?}");
|
||||
let product = |zooms: &[f32]| zooms.iter().product::<f32>();
|
||||
assert!((product(&single) - step).abs() < 1e-5, "{name}: one pass applies the step: {single:?}");
|
||||
assert!((product(&two) - step).abs() < 1e-5, "{name}: two passes apply it once: {two:?}");
|
||||
assert!((two[1] - 1.0).abs() < 1e-6, "{name}: a later pass zooms by nothing: {two:?}");
|
||||
// The next frame's wheel reading starts over; the two-pass frame's factor is gone.
|
||||
let after = frame(&ctx, &mut motion, 0.1 + 1.0 / 60.0, Vec::new(), false);
|
||||
assert_eq!(after, vec![1.0], "{name}: the cached factor does not survive the frame");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,6 +19,11 @@ struct Hit {
|
||||
|
||||
/// Where `needle` (folded with [`pdfcraft_render::text::fold_case`]) matches `hay`, for ranking.
|
||||
fn score(hay: &str, needle: &str) -> Option<usize> {
|
||||
score_for_direction(hay, needle, crate::i18n::current().rtl())
|
||||
}
|
||||
|
||||
/// Rank logical text, accepting reordered query words for right-to-left locales.
|
||||
fn score_for_direction(hay: &str, needle: &str, rtl: bool) -> Option<usize> {
|
||||
if needle.is_empty() {
|
||||
return Some(0);
|
||||
}
|
||||
@@ -26,8 +31,13 @@ fn score(hay: &str, needle: &str) -> Option<usize> {
|
||||
if let Some(p) = h.find(needle) {
|
||||
return Some(p);
|
||||
}
|
||||
// Catalog labels and the query are both logical text; the shaper reorders only at paint
|
||||
// time, so a phrase match above is already correct for right-to-left languages.
|
||||
// Match the all-tools search: RTL queries may name the same words in another order.
|
||||
if rtl && needle.contains(char::is_whitespace) {
|
||||
let first = needle.split_whitespace().try_fold(usize::MAX, |first, word| h.find(word).map(|at| first.min(at)));
|
||||
if let Some(first) = first {
|
||||
return Some(first);
|
||||
}
|
||||
}
|
||||
// Subsequence match as a fallback.
|
||||
let mut it = h.chars();
|
||||
needle.chars().all(|c| it.any(|x| x == c)).then_some(100)
|
||||
@@ -223,9 +233,20 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod search {
|
||||
use super::score;
|
||||
use super::{score, score_for_direction};
|
||||
use pdfcraft_render::text::fold_case;
|
||||
|
||||
/// Word-order tolerance is RTL-only and still requires every requested word.
|
||||
#[test]
|
||||
fn rtl_palette_queries_accept_reordered_words_without_reversing_text() {
|
||||
let label = "تنظيم الصفحات";
|
||||
assert_eq!(score_for_direction(label, &fold_case("الصفحات تنظيم"), true), Some(0));
|
||||
assert_eq!(score_for_direction(label, &fold_case("تنظيم الصفحات"), true), Some(0));
|
||||
assert_eq!(score_for_direction(label, &fold_case("الصفحات مفقود"), true), None);
|
||||
assert_eq!(score_for_direction(label, &fold_case("الصفحات تنظيم"), false), None);
|
||||
assert_eq!(score_for_direction("Organize Pages", &fold_case("Pages Organize"), false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_match_in_any_case_with_turkish_i() {
|
||||
assert_eq!(score("İçe aktar", &fold_case("içe")), Some(0));
|
||||
|
||||
@@ -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::unique("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(egui::Id::unique("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 });
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -480,16 +480,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
|
||||
@@ -1577,135 +1577,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;
|
||||
@@ -1715,12 +1652,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();
|
||||
@@ -1728,62 +1664,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);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ mod reading_colors;
|
||||
mod recovery;
|
||||
mod redact;
|
||||
mod reload;
|
||||
mod side_panels;
|
||||
mod signing;
|
||||
mod stamps;
|
||||
mod text_select;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -341,10 +341,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);
|
||||
@@ -356,10 +356,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);
|
||||
@@ -370,11 +377,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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 "…",
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
# Current integration — 2026-10-25
|
||||
|
||||
The local integration includes main `d764a8ebca2dc289841b34ea3048f613a9869bbb` and pins craft-ui, craft-quit and craft-print to published `42e30cd5b3f4b114270dd053fccbc33bedc47f68`. The egui, winit and independent craft-docpath pins are unchanged. Its `Cargo.lock` SHA-256 is `3e0c10576c8584eb1f45a27908177e7c65bb83f8e855bf3a5429edd4e7268c80`.
|
||||
|
||||
Main replaced the floating docking workspace with fixed shared side panes in `crates/ui-egui/src/side_panels.rs`. `PaneHost` owns the Tools and inspector columns; `Rail` owns the fixed navigation rail and footer. The integration retains the 0.37 ID, text alignment, capture, IME and accessibility adaptations. The former `docking.rs`, `rail-nav` panel and read-mode inspector panel no longer exist. Floating windows, tab groups and drag/redock operations from the earlier design are not capabilities of this integrated UI. Their historical acceptance items below do not establish current behavior.
|
||||
|
||||
Focused scroll, zoom and RTL palette regressions passed. Removing the production fixes while retaining the tests reproduced double scrolling, double zooming and failed reordered-word RTL search; both source files were then restored byte-for-byte. Independent source review accepted the integration. Full combined-tree CI and the separate web WASM check passed with required external oracles: 5,190 passing test executions, zero failures and 22 ignored across 89 summaries. The first attempt stopped at the storage reservation guard; the successful rerun used an increased reservation. These results qualify the current integration, not an earlier checkpoint.
|
||||
|
||||
Remaining acceptance includes native/browser GUI, native IME, packaging and paired performance. Shared-pane F6 cycling and explicit RTL edge mirroring are not enabled. New side-pane command labels still need catalog translations; English fallback remains. Current panel checks must exercise Tools/inspector visibility, Read mode, resizing and persistence, rail selection, F4/Shift+F4, capture and accessible Pane/Splitter labels, rather than the removed floating/tab-group workflows.
|
||||
|
||||
The qualification tables and original porting protocol below are historical records. Their old pins, lock hashes and docking design describe those checkpoints only; this section supersedes them for the current integration.
|
||||
|
||||
> Historical qualification record: the source and lockfile identities below describe earlier migration checkpoints. The current main reconciliation changes workspace versions; its exact source, lockfile hash and validation are recorded in the publication packet. Earlier results do not qualify that combined tree.
|
||||
|
||||
# PdfCraft — egui 0.37 porting plan
|
||||
@@ -12,7 +24,7 @@ The original planning record below is historical.
|
||||
|
||||
Status: implemented locally against `a2ec27b4775f53c40cad3456936d9406611e3a8d`; not published. Production code, manifests, vendor provenance and tests in this checkout carry the migration. The 2026-10-25 qualification section below supersedes the historical external-config and missing-IME statements; the 2026-10-24 amendment records the earlier local state. The manifests pin egui `6d518fc799681571963842f95b55a6b68431b15a`, winit `a517b3846ee8457d84ee4975110bfda6797c44dc`, and craft-ui/craft-quit/craft-print `25da1314d443171b4adeaad2cd9454b273f35c6f` directly by immutable git revision, while `craft-docpath` remains at `82a24b006eefa036b43c09a67fb9a3fbd17ffe63`; `Cargo.lock` is `f5cee0acb795240d2071116230316fd4e4b6bfe4e4314c82b0a4ca5459739f80`, and the target-main observation is `a7801dc84e74dd12757aecbb6be324dce7a33939`. The repository quality gates passed as recorded below; publication still requires fresh human approval. This revision also incorporates the independent adversarial review of the prior plan (`f781bf52d03d59cd2fb4539771c7a0d196e2f49e64c48635e72c224c9bdcdcd0`).
|
||||
|
||||
## Current qualification state — 2026-10-25
|
||||
## Historical qualification state — 2026-10-25
|
||||
|
||||
The managed run `egui037-pdfcraft-reviewed-ui-ci-20261025-0541` passed the first nine CI stages, including formatting, strict lint, workspace tests, WASM compilation, assets and dependency checks. It recorded 5,025 passing test executions, zero failures and 16 ignored across 198 summaries (including four focused regression executions). The final parity stage found one stale test reference after the Arabic logical-order test was renamed; the reference was corrected without changing executable source. Formatting and parity then passed in `egui037-pdfcraft-parity-final-20261025-0555`. An intervening submission `0553` was refused by the storage guard before execution and is not validation evidence. Final documentation/attribution corrections were checked by formatting, assets generation/validation and parity in `egui037-pdfcraft-final-doc-assets-20261025-0571`.
|
||||
|
||||
@@ -137,7 +149,7 @@ egui 0.37 removes `Event::Screenshot` and changes `ViewportCommand::Screenshot`
|
||||
|
||||
`Shared::passes` is a pass counter, not a frame counter. The migration renames the unit to accepted frames and increments it in `output_hook` only when `output.platform_output.request_discard_reasons.is_empty()`; an intermediate discarded pass does not count. A request made before a forced discard is not answered before the intended number of accepted frames.
|
||||
|
||||
### 2.6 Panels, docking and window chrome
|
||||
### 2.6 Panels, docking and window chrome (planning baseline)
|
||||
|
||||
PdfCraft uses the 0.37 `Panel` API already in its 0.36 form:
|
||||
|
||||
@@ -344,6 +356,9 @@ Keep the existing `eframe::App` hooks. Adding a private phase type is allowed on
|
||||
|
||||
### 6.2 Panels, docking and persistence
|
||||
|
||||
Current integration note: the shared side panes and rail described at the top supersede the former docking and panel layout below. Retain historical requirements as context, not as claims of implemented capabilities.
|
||||
|
||||
|
||||
The first compatibility PR implements an actual one-time panel-key migration. For each of `"tab_strip"`, `"mode_bar"`, `"rail"`, `"rail-nav"` and `"read_right_panel"`:
|
||||
|
||||
- old key (0.36): `Id::new(salt)`;
|
||||
@@ -411,6 +426,9 @@ Adopt E14 for fallbacks: set `FontTweak::fallback_align = FallbackAlign::Baselin
|
||||
|
||||
### 6.6 Language and RTL
|
||||
|
||||
Current integration note: the shared side panes and rail described at the top supersede the former docking and panel layout below. Retain historical requirements as context, not as claims of implemented capabilities.
|
||||
|
||||
|
||||
End state:
|
||||
|
||||
- `logic` resolves the language and calls `frame.set_ui_language(Some(code))` (E18), setting `Context::language` (E17).
|
||||
@@ -431,6 +449,9 @@ End state:
|
||||
|
||||
### 6.8 Accessibility
|
||||
|
||||
Current integration note: the shared side panes and rail described at the top supersede the former docking and panel layout below. Retain historical requirements as context, not as claims of implemented capabilities.
|
||||
|
||||
|
||||
- Map every `WidgetInfo` role explicitly (`Role::TextInput`, `Role::CheckBox`, etc.).
|
||||
- Add accessible names to every input widget. 0.37 kittest fails unnamed inputs; use `labelled_by`, `Ui::label_inputs_by`, `Ui::name_inputs` or `Response::accessible_name`.
|
||||
- Keep logical labels in AccessKit. Do not set preordered visual strings.
|
||||
|
||||
@@ -54,7 +54,7 @@ The Persian catalog (`fa.tsv`, interface name **فارسی**) has 2,785 entries,
|
||||
|
||||
Arabic glyphs come from the craft-fonts `Arab` face (Noto Sans Arabic, kept in the web build too). A build without craft-fonts shows Arabic as replacement boxes on the web; on desktop it falls back to one installed font (below). The craft-fonts revision pinned for CI and releases (`.github/workflows/`) has the Arabic and Hebrew faces. Its Noto Sans CJK SC face is 16.4 MB and is left out by the general CJK cap; native builds take the separately pinned Simplified Chinese face (LXGW WenKai Lite) from `fonts/simplified-chinese-manifest.txt`, and the web build embeds only BIZ UDPGothic Regular plus the small Arabic, Hebrew, Thai and Devanagari faces.
|
||||
|
||||
File names and document titles in Arabic or Hebrew are drawn with a craft-fonts `Arab` face when the build input has one; on desktop, a character no embedded face has is drawn with one font already installed on the machine (`PDFCRAFT_SYSTEM_FONTS=0` turns this off). epaint reorders each line with UAX #9, while file and path labels keep an explicit left-to-right base so the extension stays at the visual end; the tab strip, the recent-files list and Properties show such names in display order.
|
||||
File names and document titles use the craft-fonts `Arab` face (Noto Sans Arabic) for Arabic and the `Hebr` face (Noto Sans Hebrew) for Hebrew when those faces are included in the build input; on desktop, a character no embedded face has is drawn with one font already installed on the machine (`PDFCRAFT_SYSTEM_FONTS=0` turns this off). epaint reorders each line with UAX #9, while file and path labels keep an explicit left-to-right base so the extension stays at the visual end; the tab strip, the recent-files list and Properties show such names in display order.
|
||||
|
||||
Catalog coverage does not guarantee glyph coverage. Native builds embed LXGW WenKai Lite as the Simplified Chinese face from the pinned craft-fonts input (at most 16 MiB), while the web build embeds only BIZ UDPGothic Regular plus the small Arabic, Hebrew, Thai and Devanagari faces, so Chinese characters none of those faces has show replacement boxes on the web. Traditional Chinese has no embedded face and uses a `Hant` face installed on the machine, placed before the Japanese faces even when LXGW is bundled.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user