diff --git a/Cargo.lock b/Cargo.lock index 37f2487f8..07b8e4ef0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1174,9 +1174,13 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=ea4c823abb46fa9d21361604ed1c6d21c54e9ca8#ea4c823abb46fa9d21361604ed1c6d21c54e9ca8" dependencies = [ "egui", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", ] @@ -6463,7 +6467,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", "windows-sys 0.60.2", diff --git a/Cargo.toml b/Cargo.toml index 78c7a78c6..6a91547f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. -craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "ea4c823abb46fa9d21361604ed1c6d21c54e9ca8", features = ["serde"] } # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`. craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" } # Shared system spooler; document composition remains in pdfcraft-print. diff --git a/README.md b/README.md index d39bec5aa..f319ffead 100644 --- a/README.md +++ b/README.md @@ -332,15 +332,13 @@ pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect focused=true pdfcraft-cli ui --control ~/.pdfcraft-control.json quit ``` -`ui.dock` arranges the `tools` and `inspector` panels; `canvas` is the protected document view. -Each per-panel request requires a `panel` ID and an `operation`: `open`, `close`, -`activate`, `float` with `rect: [x, y, width, height]`, or `move` with `target` and -`zone: center|left|right|top|bottom`. For example, -`{"operation":"float","panel":"tools","rect":[100,100,320,440]}` floats Tools. -`ui.dock.reset` restores the default -arrangement. The control channel accepts these methods directly; `ui.command` also accepts -`{"id":"ui.dock","params":{...}}`. Saved preferences retain groups, active tabs, visibility -and floating geometry. Invalid requests return an error without changing the document or layout. +`ui.panel` opens, closes or sizes a side panel: `{"panel":"tools"|"side","open":true|false,"width":320}`. +`tools` is the Tools panel and `side` the panel the rail opens (reopening it shows the one open last). +`width` is clamped to the panel's limits (Tools 240–520, side panel 260–520 points). `ui.dock.reset` +resets the side panels: Tools open on the left, default widths. The control channel accepts these +methods directly; `ui.command` also accepts `{"id":"ui.panel","params":{...}}`. Saved preferences keep +the Tools side, the open panels and their widths. Invalid requests return an error without changing +the document or the panels. `resize` requests a native inner-window size in logical points (each dimension must be between 320 and 8192); the window manager may constrain it. `state` reports the observed `window.width`, `window.height` and `window.pixels_per_point`. `focus` accepts exactly one widget `id` or unique enabled, focusable `label`; subsequent `key` requests traverse or activate the actual widgets. `inspect` includes focusability and accepts a `focused` filter. `quit` takes no parameters: clean documents exit normally, while unsaved edits or typing open the existing Save/Don't save/Cancel prompt and return `quitting: false, needs_confirmation: true`. It never silently discards work. Window resizing and application quit are native-only. @@ -595,13 +593,4 @@ Forks and modified versions must remove them. Made by the ArtCraft team and community.

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