From 53c79ca9b0160cea485230cf3fa840bac80d1e8c Mon Sep 17 00:00:00 2001 From: archamon Date: Sun, 1 Nov 2026 18:39:39 +0000 Subject: [PATCH] feat(ui,annot): customizable hotbar strip, freehand highlight, and moveable navigation rail - Add Freehand-Highlight tool with 100pt line width support, Lucide highlighter icon, and PDF Multiply blending. - Add customizable floating and docked hotbar with command picker, size slider, full-side strip chrome integration, and settings gear icon anchored at the bar end. - Add left/right docking options for the navigation rail with toggle button and preferences setting. --- ROADMAP.md | 1 + crates/annot/src/appearance.rs | 6 +- crates/annot/src/create.rs | 8 +- crates/annot/src/modify.rs | 12 +- crates/annot/src/tests.rs | 26 ++ crates/annot/src/types.rs | 8 +- crates/automation/src/comments.rs | 5 +- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 21 ++ crates/engine/src/commands.rs | 3 + crates/engine/src/comments.rs | 1 + crates/engine/src/edit/labels.rs | 1 + crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/control.rs | 19 ++ crates/ui-egui/src/app/prefs.rs | 5 + crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/canvas/bars.rs | 16 +- crates/ui-egui/src/chrome.rs | 9 +- crates/ui-egui/src/commands.rs | 2 + crates/ui-egui/src/control.rs | 5 + crates/ui-egui/src/control/host.rs | 2 + crates/ui-egui/src/dialogs/comment_props.rs | 2 +- crates/ui-egui/src/editing.rs | 7 +- crates/ui-egui/src/hotbar.rs | 355 ++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/side_panels.rs | 52 ++- crates/ui-egui/src/tools/comments.rs | 31 +- crates/ui-egui/src/tools/comments/input.rs | 6 +- crates/ui-egui/src/tools/comments/menu.rs | 2 +- crates/ui-egui/src/tools/comments/paint.rs | 7 +- crates/ui-egui/src/tools/comments/state.rs | 5 +- crates/ui-egui/src/tools/comments/tests.rs | 17 + crates/ui-egui/src/workflows/js_ui.rs | 14 + crates/ui-egui/tests/it/comments.rs | 139 ++++++++ crates/ui-egui/tests/it/ui.rs | 24 ++ docs/roadmap.md | 2 +- docs/ui-parity.md | 2 +- parity/acrobat-features.toml | 7 +- 38 files changed, 792 insertions(+), 36 deletions(-) create mode 100644 crates/ui-egui/src/hotbar.rs diff --git a/ROADMAP.md b/ROADMAP.md index 74d9241e1c..6e7140b909 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -221,6 +221,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another. +- **2026-11-01 (M5/M14, customizable hotbar, freehand highlight, and moveable navigation rail):** Added Freehand-Highlight (`/Subtype /Ink`, `/IT /FreehandHighlight`, Multiply blending, 100 pt max line width) inside the standard Highlight flyout and headless `comment_add`; added a customizable floating or side-docked hotbar with a full-side chrome strip mode, settings gear icon anchored at the bar end, and 24–64 px sizing; and added Left/Right docking for the navigation rail (`rail_side`). Focused unit and integration tests pass across `annot`, `automation`, and `ui-egui`. Estimates unchanged. - 2026-11-01: PR #2155 further review preserves inherited Form text state and soft-mask chronology during Arrange, composes crop-group moves in the proper scope, and uses affine/press-relative image resize plus a shared rotation pivot. No-op script buttons preserve hidden source text; paste coordinates, visible selection ownership, and Snapshot/Export permissions have dedicated regressions. Candidate only; estimates unchanged. - 2026-11-01: PR #2155 latest follow-up handles explicit image-paste events without release-time guesses, refreshes the font-size field when a selection becomes mixed, and specifies mixed-script normalization with exact Undo restoration. Candidate only; estimates unchanged. diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index abc746c0dd..909250ef26 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -392,9 +392,13 @@ struct AppearanceDrawing { } impl AppearanceDrawing { + /// Initialize an appearance drawing buffer and configure its graphics state for opacity and blend mode. fn new(context: &AppearanceContext<'_>) -> Self { let mut drawing = Self::default(); - if let Some(gs) = ext_gstate(context.opacity, context.subtype == b"Highlight") { + if let Some(gs) = ext_gstate( + context.opacity, + context.subtype == b"Highlight" || (context.subtype == b"Ink" && context.dict.name(b"IT") == Some(b"FreehandHighlight")), + ) { drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs)); drawing.content.push_str("/GS0 gs\n"); } diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index a6db5b940b..d897133bce 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -87,7 +87,7 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr let pad = half + if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 }; grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0) } - Shape::Ink { strokes } | Shape::Signature { strokes } => { + Shape::Ink { strokes } | Shape::FreehandHighlight { strokes } | Shape::Signature { strokes } => { if strokes.iter().all(Vec::is_empty) || !strokes.iter().flatten().all(|p| finite(p)) { return Err(bad("drawing (no points)")); } @@ -164,6 +164,7 @@ fn subject(shape: &Shape) -> &'static str { Shape::Oval { .. } => "Oval", Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Arrow", Shape::Line { .. } => "Line", + Shape::FreehandHighlight { .. } => "Freehand Highlight", Shape::Ink { .. } => "Pencil", Shape::TextBox { .. } => "Text Box", Shape::Typewriter { .. } => "Typewriter", @@ -292,10 +293,13 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R } } } - Shape::Ink { strokes } | Shape::Signature { strokes } => { + Shape::Ink { strokes } | Shape::FreehandHighlight { strokes } | Shape::Signature { strokes } => { d.set(b"C".to_vec(), rgb(style.color)); let list = strokes.iter().filter(|s| !s.is_empty()).map(|s| num_array(&s.concat())).collect(); d.set(b"InkList".to_vec(), Object::Array(list)); + if matches!(&new.shape, Shape::FreehandHighlight { .. }) { + d.set(b"IT".to_vec(), Object::name("FreehandHighlight")); + } border(&mut d); } Shape::Polygon { vertices, cloud } => { diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 2e3ae0b7d0..fdddbc8718 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -576,10 +576,20 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha } } if let Some(w) = width { + let old_w = border_width_of(d); + let w = w.max(0.0); let mut bs = d.get(b"BS").and_then(|b| b.as_dict()).cloned().unwrap_or_default(); - bs.set(b"W".to_vec(), Object::Real(w.max(0.0))); + bs.set(b"W".to_vec(), Object::Real(w)); d.set(b"BS".to_vec(), Object::Dict(bs)); d.remove(b"Border"); + if d.name(b"Subtype") == Some(b"Ink") && w > old_w { + let vals: Vec = + d.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default(); + if let Some(rect) = four(&vals) { + let expanded = grow(normalize(rect), (w - old_w) / 2.0); + d.set(b"Rect".to_vec(), num_array(&expanded)); + } + } } if let Some(ends) = endings { if ends.len() == 1 { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index a3dc14e262..985e98fb01 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2194,3 +2194,29 @@ fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() { assert!(long.contains(&format!("({}) Tj", "w".repeat(256))), "cut to 256"); assert!(!long.contains(&"w".repeat(257))); } + +#[test] +fn freehand_highlight_is_compatible_ink_with_marker_blending() { + let shape = Shape::FreehandHighlight { strokes: vec![vec![[20.0, 100.0], [120.0, 100.0]]] }; + let mut annotation = new(0, shape.clone()); + annotation.style = Style::default_for(&shape); + let mut doc = fixture(); + let index = add_annotation(&mut doc, &annotation, &meta("marker")).unwrap(); + let doc = reopen(&doc); + let ink = &list(&doc, 0)[index]; + + assert_eq!(ink.name(b"Subtype"), Some(&b"Ink"[..])); + assert_eq!(ink.name(b"IT"), Some(&b"FreehandHighlight"[..])); + assert_eq!(ink.get(b"CA").and_then(Object::as_f64), Some(0.35)); + let ap = ink.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap(); + let resources = doc.get(ap).as_dict().unwrap().get(b"Resources").cloned().unwrap(); + let blend = resources.as_dict().unwrap().get(b"ExtGState").unwrap().as_dict().unwrap().get(b"GS0").unwrap().as_dict().unwrap(); + assert_eq!(blend.name(b"BM"), Some(&b"Multiply"[..])); + + let mut doc = doc; + let rect_before: Vec = list(&doc, 0)[index].get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(Object::as_f64).collect(); + set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(34.0), endings: None }, &meta("wider")).unwrap(); + let rect_after: Vec = list(&doc, 0)[index].get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(Object::as_f64).collect(); + assert!((rect_after[0] - (rect_before[0] - 10.0)).abs() < 1e-4); + assert!((rect_after[2] - (rect_before[2] + 10.0)).abs() < 1e-4); +} diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index bd3409b5ae..522995b9f9 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -355,6 +355,10 @@ pub enum Shape { start: LineEnding, end: LineEnding, }, + /// Translucent freehand highlighting, stored as ink with a distinct intent. + FreehandHighlight { + strokes: Vec>, + }, /// Freehand strokes (Draw tool). Ink { strokes: Vec>, @@ -474,6 +478,7 @@ pub fn rect_quad(r: [f64; 4]) -> [f64; 8] { } impl Shape { + /// Return the PDF `/Subtype` name for this annotation shape. pub fn subtype(&self) -> &'static str { match self { Shape::Note { .. } => "Text", @@ -481,7 +486,7 @@ impl Shape { Shape::Rectangle { .. } => "Square", Shape::Oval { .. } => "Circle", Shape::Line { .. } => "Line", - Shape::Ink { .. } | Shape::Signature { .. } => "Ink", + Shape::Ink { .. } | Shape::FreehandHighlight { .. } | Shape::Signature { .. } => "Ink", Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. } => "FreeText", Shape::Mark { .. } | Shape::Stamp { .. } | Shape::CustomStamp { .. } | Shape::TypedSignature { .. } => "Stamp", Shape::Redact { .. } => "Redact", @@ -610,6 +615,7 @@ impl Style { } Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0), Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0), + Shape::FreehandHighlight { .. } => return Self { color: [1.0, 0.94, 0.0], opacity: 0.35, width: 14.0, fill: None }, Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0), Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0), Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5), diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index f87fd0b58e..5dbd5b8542 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -183,6 +183,7 @@ impl Automation { "page": c.page + 1, "index": c.index + 1, "type": c.subtype, + "intent": c.intent, "author": c.author, "contents": c.contents, "modified": c.modified, @@ -328,7 +329,7 @@ impl Automation { let [start, end] = ending_pair(kind, endings.as_deref())?; Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), start, end } } - "ink" => { + "ink" | "freehand_highlight" => { let wrong = || ToolError::InvalidArgs("strokes must be an array of arrays of [x, y] points".into()); let strokes = a.get("strokes").ok_or_else(|| ToolError::InvalidArgs("ink needs `strokes`".into()))?; let strokes = strokes @@ -346,7 +347,7 @@ impl Automation { .collect::>>() }) .collect::>>()?; - Shape::Ink { strokes } + if kind == "freehand_highlight" { Shape::FreehandHighlight { strokes } } else { Shape::Ink { strokes } } } "polygon" | "cloud" | "polyline" => { let wrong = || ToolError::InvalidArgs(format!("{kind} needs `points`: an array of [x, y] points")); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 233bb101cd..e39673c201 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -754,7 +754,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, - "type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "replace", "rectangle", "oval", "line", "arrow", "ink", "textbox", "stamp", "polygon", "cloud", "polyline", "callout", "caret", "attachment"] }, + "type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "replace", "rectangle", "oval", "line", "arrow", "ink", "freehand_highlight", "textbox", "stamp", "polygon", "cloud", "polyline", "callout", "caret", "attachment"] }, "path": { "type": "string", "description": "attachment: the file to attach." }, "points": { "type": "array", "items": point(), "minItems": 2 }, "knee": point(), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 8cff9fc4aa..0d73dbb900 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2149,6 +2149,27 @@ fn comments_through_tools() { save_and_reopen_comments(&dir, &mut a, doc); } +#[test] +fn freehand_highlight_through_tools() { + let dir = workdir("freehand-highlight"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok( + &mut a, + "comment_add", + json!({ "doc": doc, "page": 1, "type": "freehand_highlight", "width": 100, + "strokes": [[[20, 100], [120, 100]]] }), + ); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 1); + assert_eq!(list["comments"][0]["type"], "Ink"); + assert_eq!(list["comments"][0]["intent"], "FreehandHighlight"); + assert_eq!(list["comments"][0]["color"], "#FFF000"); + let undone = ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(undone["undone"], "Add freehand highlight"); + assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 0); +} + fn add_comment_examples(a: &mut Automation, doc: u64) -> String { // "Page 2" sits at y = 150 (user space) = 150 from the top of the 300 pt page. let hl = ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" })); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6bfe9fa826..0647726452 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -163,6 +163,8 @@ const HELP: Option<&str> = Some("Help"); /// Every command, in menu order. pub const COMMANDS: &[CommandSpec] = &[ + c("view.select_tool", "Select", None, None, Document, "mouse-pointer-2"), + c("view.hand_tool", "Hand", None, None, Document, "hand"), c("file.open", "Open…", FILE, Some(Shortcut::cmd("O")), Nothing, "folder-open"), c("file.open_recent", "Open Recent", FILE, None, Nothing, "clock"), // Shown at the foot of File ▸ Open Recent and on Home, not as its own File menu item. @@ -259,6 +261,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("comment.underline", "Underline text", None, None, Annotate, "underline"), c("comment.strikeout", "Strikethrough text", None, None, Annotate, "strikethrough"), c("comment.squiggly", "Squiggly underline text", None, None, Annotate, "spline"), + c("comment.freehand_highlight", "Freehand-Highlight", None, None, Annotate, "highlighter"), c("comment.ink", "Draw freehand", None, None, Annotate, "pencil"), c("comment.line", "Draw a line", None, None, Annotate, "minus"), c("comment.arrow", "Draw an arrow", None, None, Annotate, "move-right"), diff --git a/crates/engine/src/comments.rs b/crates/engine/src/comments.rs index eee799ed2f..c8262a7241 100644 --- a/crates/engine/src/comments.rs +++ b/crates/engine/src/comments.rs @@ -82,6 +82,7 @@ pub fn comment_type_name(subtype: &str) -> &str { /// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`. pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { match a.intent.as_deref() { + Some("FreehandHighlight") if a.subtype == "Ink" => "Freehand Highlight", Some("FreeTextCallout") => "Callout", Some("PolygonCloud") => "Cloud", Some("FreeTextTypeWriter") => "Typewriter", diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index afc6056e85..5343804ef3 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -327,6 +327,7 @@ fn add_annotation(s: &Shape) -> &'static str { Shape::Oval { .. } => "Add oval", Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", Shape::Line { .. } => "Add line", + Shape::FreehandHighlight { .. } => "Add freehand highlight", Shape::Ink { .. } => "Add drawing", Shape::TextBox { .. } => "Add text box", Shape::Typewriter { .. } => "Add text", diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 4de70f3d9e..43d0066e63 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -88,6 +88,7 @@ impl PdfCraftApp { space_hand: None, full_screen_tool: None, comment_prefs: Default::default(), + hotbar: Default::default(), theme: ThemeKind::Light, // A fresh install follows the desktop's appearance (#1248); saved settings override. theme_preference: ThemePreference::System, diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 26e20f8df5..e4078efd13 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -16,6 +16,18 @@ impl PdfCraftApp { pub fn set_option(&mut self, key: &str, value: &str) -> Result<(), String> { let view = self.active.and_then(|i| self.views.get_mut(i)); match (key, view) { + ("hotbar-size", _) => { + let size: f32 = value.parse().map_err(|_| "hotbar-size must be 24–64 pixels")?; + if !size.is_finite() || !(24.0..=64.0).contains(&size) { + return Err("hotbar-size must be 24–64 pixels".into()); + } + self.hotbar.button_size = size; + } + ("hotbar-full-side", _) => { + self.hotbar.full_side = value.parse().map_err(|_| "hotbar-full-side must be true or false")?; + } + ("hotbar-tools", _) => self.hotbar.set_tools(value)?, + ("hotbar-side", _) => self.hotbar.place(value)?, ("language", _) => { let language = i18n::normalize_pref(value).ok_or_else(|| { let codes: Vec<&str> = std::iter::once(i18n::AUTO).chain(i18n::Lang::all().map(i18n::Lang::code)).collect(); @@ -217,6 +229,13 @@ impl PdfCraftApp { _ => return Err("tools-side must be left or right".into()), } } + ("rail-side", _) => { + self.side_panels.rail_side = match value { + "left" => side_panels::ToolsSide::Left, + "right" => side_panels::ToolsSide::Right, + _ => return Err("rail-side must be left or right".into()), + } + } ("organize", Some(v)) => { if value == "off" { v.organize = false; diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index c2a7de31dc..7bd8943774 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -146,6 +146,7 @@ impl PdfCraftApp { // Set outside json! above, which is at serde_json's macro recursion limit. v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover); v["show_page_edge_shadows"] = self.show_page_edge_shadows.into(); + v["hotbar"] = serde_json::to_value(&self.hotbar).unwrap_or_default(); v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned); v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice); v["organize_zoom"] = serde_json::json!(self.organize_zoom); @@ -157,6 +158,10 @@ 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 Ok(mut bar) = serde_json::from_value::(v["hotbar"].clone()) { + bar.normalize(); + self.hotbar = bar; + } let panels = side_panels::restore(&v); self.side_panels = panels.panels; if let Some(open) = panels.tools_open { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7a0976644b..a220644421 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4056,6 +4056,7 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, // the quick tools come back with the window. if app.mode != crate::Mode::Read && !app.full_screen { quick_bar(app, avail, ui); + crate::hotbar::show(app, avail, ui.ctx()); } if let Some((source, file, destination)) = remote_open { app.open_remote_link(source, file, destination); diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index 2472366672..4ad2bf05db 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -279,7 +279,15 @@ fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, te /// The floating quick-action bar at the left edge of the document area. pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let pos = area.left_top() + vec2(14.0, 14.0); + let mut offset = vec2(0.0, 0.0); + if app.hotbar.full_side { + match app.hotbar.dock.as_deref() { + Some("left") => offset.x += app.hotbar.button_size + 14.0, + Some("top") => offset.y += app.hotbar.button_size + 14.0, + _ => {} + } + } + let pos = area.left_top() + vec2(14.0, 14.0) + offset; egui::Area::new(egui::Id::unique("quick-bar")).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::NONE .fill(t.card) @@ -302,6 +310,12 @@ pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { let current = app.comment_prefs.group_tool[g]; let active = matches!(app.quick_tool, QuickTool::Comment(t) if t.group() == g); let resp = icons::button(ui, current.icon(), 32.0, active, tl!(current.label())); + if comments::GROUPS[g].len() == 1 { + if resp.clicked() { + app.execute(current.command()); + } + continue; + } // A small corner triangle marks the flyout. let r = resp.rect; let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)]; diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index aa6233c133..59525aae7b 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1,6 +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 craft_ui::panes::{Rail, RailItem, RailRequest}; use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2}; use crate::canvas::{DocView, Fit, PageLayout}; @@ -572,13 +572,18 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let view = &mut app.views[index]; let mut page_display = None; let rail = Rail::new(egui::Id::unique("rail")) - .edge(Edge::End) + .edge(app.side_panels.rail_edge()) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(6, 8)).stroke(Stroke::new(1.0, t.divider))) .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 right = app.side_panels.rail_on_right(); + let dock_tip = if right { tl!("Dock navigation rail on Left") } else { tl!("Dock navigation rail on Right") }; + if icons::button(ui, if right { "panel-left" } else { "panel-right" }, 32.0, false, dock_tip).clicked() { + app.side_panels.rail_side = if right { crate::side_panels::ToolsSide::Left } else { crate::side_panels::ToolsSide::Right }; + } 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); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 08d2040e23..f072b7febe 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -259,6 +259,8 @@ impl PdfCraftApp { self.notify(error); } } + "view.select_tool" => self.quick_tool = crate::QuickTool::Select, + "view.hand_tool" => self.quick_tool = crate::QuickTool::Hand, "view.side_panel" => self.toggle_side_panel(), "view.tools_pane" => self.left_open = !self.left_open, "view.focus_page_input" => { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c3c730ed68..44e5481a11 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -39,6 +39,11 @@ //! `theme=light|dark|system` changes the saved theme preference; `ui.state.theme_preference` //! reports the choice and `ui.state.theme` reports the resolved light/dark colours. //! `comment-tool-pinned=true|false` remembers the annotation quick bar's pin across restarts; +//! `hotbar-tools` accepts comma-separated command IDs (empty clears the bar), and +//! `hotbar-side=float|left|right|top|bottom` floats or docks the customizable hotbar; +//! `hotbar-full-side=true|false` extends a docked bar along the entire side. +//! `hotbar-size=24..64` controls its button size in logical pixels; `ui.state.hotbar` +//! reports its tools, relative position, orientation and strip preference. These options persist across restarts. //! `ui.state.comment_tool_pinned` reports the same preference. //! - `ui.open {path}`: open a file. A file whose tab is already open focuses it //! (`"focused": true`) instead of adding a tab; native opens report `pending: true` until diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index 64b10b69fb..b803f2738c 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -133,9 +133,11 @@ impl Host for crate::PdfCraftApp { "home": self.active.is_none(), "mode": format!("{:?}", self.mode), "default_mode": self.default_mode, + "hotbar": self.hotbar, "comment_tool_pinned": self.comment_prefs.pinned, "left_panel": if self.left_open { json!(format!("{:?}", self.left)) } else { Value::Null }, "tools_side": if self.side_panels.tools_on_right() { "right" } else { "left" }, + "rail_side": if self.side_panels.rail_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, diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index 4702e63f9e..35ce510ae7 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -240,7 +240,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo let min_width = if e.font_size.is_some() { 0.0 } else { 0.5 }; if let Some(w) = e.width.as_mut() { ui.label(tl!("Thickness")); - ui.add(egui::Slider::new(w, min_width..=12.0).step_by(0.5).suffix(" pt")); + ui.add(egui::Slider::new(w, min_width..=crate::comments::MAX_LINE_WIDTH).step_by(0.5).suffix(" pt")); ui.end_row(); } if let Some(ends) = e.endings.as_mut() { diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index d4520a15b4..b810e2abe5 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -52,8 +52,11 @@ fn select_after_edit( // A stroke drawn with the pen stays unselected, so the selection box and // author popup don't sit over the next stroke (#429). Edit::AddAnnotation(a) - if matches!(a.shape, pdfcraft_engine::Shape::Ink { .. }) - && quick_tool == crate::QuickTool::Comment(crate::comments::CommentTool::Ink) => + if matches!(a.shape, pdfcraft_engine::Shape::Ink { .. } | pdfcraft_engine::Shape::FreehandHighlight { .. }) + && matches!( + quick_tool, + crate::QuickTool::Comment(crate::comments::CommentTool::Ink | crate::comments::CommentTool::FreehandHighlight) + ) => { view.comments.selected = None; } diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs new file mode 100644 index 0000000000..88c2fd4c6a --- /dev/null +++ b/crates/ui-egui/src/hotbar.rs @@ -0,0 +1,355 @@ +//! A movable, persistent set of shortcuts to the shared command registry. +use egui::{Rect, Sense, vec2}; +use pdfcraft_engine::commands::{COMMANDS, command}; +use serde::{Deserialize, Serialize}; + +use crate::{PdfCraftApp, icons, theme::Tokens}; + +/// Persistent state and UI state for the customizable document hotbar. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default)] +pub(crate) struct Hotbar { + pub tools: Vec, + /// Position as a fraction of the available travel, so resizing keeps the bar visible. + pub position: [f32; 2], + pub vertical: bool, + pub button_size: f32, + pub dock: Option, + pub full_side: bool, + #[serde(skip)] + search: String, + #[serde(skip)] + picker_open: bool, +} + +impl Default for Hotbar { + /// Create a default empty horizontal floating hotbar. + fn default() -> Self { + Self { + tools: Vec::new(), + position: [0.5, 0.0], + vertical: false, + button_size: 32.0, + dock: None, + full_side: false, + search: String::new(), + picker_open: false, + } + } +} + +impl Hotbar { + /// Clamp button size and normalized position coordinates, drop unknown docked sides, + /// and deduplicate/filter tool command identifiers against the command registry. + pub fn normalize(&mut self) { + self.button_size = if self.button_size.is_finite() { self.button_size.clamp(24.0, 64.0) } else { 32.0 }; + if self.dock.as_deref().is_some_and(|side| !matches!(side, "left" | "right" | "top" | "bottom")) { + self.dock = None; + } + let mut seen = std::collections::HashSet::new(); + self.tools.retain(|id| command(id).is_some() && seen.insert(id.clone())); + for coordinate in &mut self.position { + *coordinate = if coordinate.is_finite() { coordinate.clamp(0.0, 1.0) } else { 0.5 }; + } + } + + /// Parse and validate a comma-separated list of command identifiers for the hotbar. + pub fn set_tools(&mut self, value: &str) -> Result<(), String> { + let tools: Vec = value.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect(); + if let Some(id) = tools.iter().find(|id| command(id).is_none()) { + return Err(format!("Unknown hotbar command: {id}")); + } + self.tools = tools; + self.normalize(); + Ok(()) + } + + /// Dock the hotbar to `"left"`, `"right"`, `"top"`, or `"bottom"`, or undock with `"float"`. + pub fn place(&mut self, side: &str) -> Result<(), String> { + if side == "float" { + self.dock = None; + return Ok(()); + } + let (position, vertical) = match side { + "left" => ([0.0, 0.5], true), + "right" => ([1.0, 0.5], true), + "top" => ([0.5, 0.0], false), + "bottom" => ([0.5, 1.0], false), + _ => return Err("hotbar-side must be float, left, right, top or bottom".into()), + }; + self.dock = Some(side.to_owned()); + self.position = position; + self.vertical = vertical; + Ok(()) + } +} + +/// Render the hotbar settings gear button and open the tool picker when clicked. +fn settings_button(ui: &mut egui::Ui, cell: f32, picker_open: &mut bool) { + let settings = ui + .push_id("hotbar-settings", |ui| { + let mut btn = icons::button(ui, "settings", cell, false, tl!("Hotbar settings")); + btn = btn.on_hover_text(tl!("Hotbar settings / add tools")); + btn.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "+")); + btn + }) + .inner; + if settings.clicked() { + *picker_open = true; + } +} + +/// Render the configured tool shortcut buttons and their removal context menus. +fn tool_buttons(ui: &mut egui::Ui, app: &PdfCraftApp, tools: &[String], cell: f32, run: &mut Option<&'static str>, remove: &mut Option) { + for (index, id) in tools.iter().enumerate() { + let Some(spec) = command(id) else { continue }; + let active = crate::comments::CommentTool::from_command(id).is_some_and(|tool| app.quick_tool == crate::QuickTool::Comment(tool)); + let button = ui.add_enabled_ui(app.command_enabled(spec), |ui| icons::button(ui, spec.icon, cell, active, tl!(spec.label))).inner; + if button.clicked() { + *run = Some(spec.id); + } + button.context_menu(|ui| { + if ui.button(tl!("Remove from hotbar")).clicked() { + *remove = Some(index); + ui.close(); + } + }); + } +} + +/// Draw the floating or side-docked hotbar and its tool picker window inside `area`. +pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, ctx: &egui::Context) { + let mut bar = std::mem::take(&mut app.hotbar); + let t = Tokens::get(ctx); + let id = egui::Id::unique("custom-hotbar"); + let full_side = bar.full_side && bar.dock.is_some(); + let bounds = if full_side { area } else { area.shrink(10.0) }; + if let Some(side) = bar.dock.clone() { + let _ = bar.place(&side); + } + let cell = bar.button_size; + let mut size = ctx.data_mut(|data| data.get_temp::(id)).unwrap_or(vec2(90.0, 44.0)); + if full_side { + size = if bar.vertical { vec2(cell + 12.0, bounds.height()) } else { vec2(bounds.width(), cell + 12.0) }; + } + let travel = (bounds.size() - size).max(vec2(0.0, 0.0)); + let pos = bounds.min + vec2(travel.x * bar.position[0], travel.y * bar.position[1]); + let mut run = None; + let mut picker_open = bar.picker_open; + let shown = egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).constrain_to(bounds).show(ctx, |ui| { + let frame = if full_side { + egui::Frame::NONE.fill(t.chrome).corner_radius(egui::CornerRadius::ZERO).inner_margin(6) + } else { + egui::Frame::new().fill(t.card).stroke(egui::Stroke::new(1.0, t.border)).corner_radius(10).inner_margin(6) + }; + let frame_resp = frame.show(ui, |ui| { + if full_side { + ui.set_min_size((size - vec2(12.0, 12.0)).max(vec2(0.0, 0.0))); + } + ui.spacing_mut().item_spacing = vec2(4.0, 4.0); + if bar.vertical { + ui.set_width(cell); + } else { + ui.set_height(cell); + } + let mut remove = None; + if full_side { + if bar.vertical { + ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| { + settings_button(ui, cell, &mut picker_open); + ui.add_space(4.0); + let remaining = ui.available_height().max(cell); + egui::ScrollArea::vertical().max_width(cell).max_height(remaining).show(ui, |ui| { + ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { + tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove); + }); + }); + }); + } else { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + settings_button(ui, cell, &mut picker_open); + ui.add_space(4.0); + let remaining = ui.available_width().max(cell); + egui::ScrollArea::horizontal().max_height(cell).max_width(remaining).show(ui, |ui| { + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove); + }); + }); + }); + } + } else { + let layout = + if bar.vertical { egui::Layout::top_down(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) }; + ui.with_layout(layout, |ui| { + let grip_size = if bar.vertical { vec2(cell, 18.0) } else { vec2(18.0, cell) }; + let (grip_rect, grip) = ui.allocate_exact_size(grip_size, Sense::drag()); + for x in [-3.0, 3.0] { + for y in [-6.0, 0.0, 6.0] { + let offset = if bar.vertical { vec2(y, x) } else { vec2(x, y) }; + ui.painter().circle_filled(grip_rect.center() + offset, 1.5, t.text_faint); + } + } + let drag_label = tl!("Drag hotbar"); + let grip = grip.on_hover_text(drag_label).on_hover_cursor(egui::CursorIcon::Grab); + grip.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, drag_label)); + if grip.dragged() && bar.dock.is_none() { + let delta = grip.drag_delta(); + bar.position[0] = (bar.position[0] + delta.x / travel.x.max(1.0)).clamp(0.0, 1.0); + bar.position[1] = (bar.position[1] + delta.y / travel.y.max(1.0)).clamp(0.0, 1.0); + } + let scroll_max = if bar.vertical { (bounds.height() - cell - 50.0).max(cell) } else { (bounds.width() - cell - 50.0).max(cell) }; + let scroll = if bar.vertical { + egui::ScrollArea::vertical().max_width(cell).max_height(scroll_max) + } else { + egui::ScrollArea::horizontal().max_height(cell).max_width(scroll_max) + }; + scroll.auto_shrink([true, true]).show(ui, |ui| { + ui.with_layout(layout, |ui| { + tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove); + }); + }); + settings_button(ui, cell, &mut picker_open); + }); + } + if let Some(index) = remove { + bar.tools.remove(index); + } + }); + if full_side { + let r = frame_resp.response.rect; + let divider = egui::Stroke::new(1.0, t.divider); + match bar.dock.as_deref() { + Some("left") => { + ui.painter().vline(r.right() - 0.5, r.top()..=r.bottom(), divider); + } + Some("right") => { + ui.painter().vline(r.left() + 0.5, r.top()..=r.bottom(), divider); + } + Some("top") => { + ui.painter().hline(r.left()..=r.right(), r.bottom() - 0.5, divider); + } + Some("bottom") => { + ui.painter().hline(r.left()..=r.right(), r.top() + 0.5, divider); + } + _ => {} + } + } + }); + let picker_id = egui::Id::unique("hotbar-picker"); + // A remembered collapsed state can survive disabling the collapse affordance. + let mut expanded = egui::collapsing_header::CollapsingState::load_with_default_open(ctx, picker_id.with("collapsing"), true); + expanded.set_open(true); + expanded.store(ctx); + let screen = ctx.content_rect().shrink(12.0); + let picker_x = if pos.x + cell + 380.0 < screen.right() { pos.x + cell + 20.0 } else { pos.x - 360.0 }; + let picker_pos = egui::pos2( + picker_x.clamp(screen.left(), (screen.right() - 350.0).max(screen.left())), + pos.y.clamp(screen.top(), (screen.bottom() - 640.0).max(screen.top())), + ); + egui::Window::new(tl!("Hotbar tools")) + .id(picker_id) + .order(egui::Order::Foreground) + .open(&mut picker_open) + .collapsible(false) + .resizable(false) + .fixed_pos(picker_pos) + .max_height((screen.height() - 24.0).max(180.0)) + .vscroll(true) + .show(ctx, |ui| { + ui.set_min_width(260.0); + ui.add(egui::Slider::new(&mut bar.button_size, 24.0..=64.0).text(tl!("Toolbar size")).suffix(" px")); + ui.add_enabled(bar.dock.is_none(), crate::widgets::checkbox(&mut bar.vertical, tl!("Vertical layout"))); + if ui.selectable_label(bar.dock.is_none(), tl!("Float (drag anywhere)")).clicked() { + let _ = bar.place("float"); + } + ui.label(tl!("Stick to a side:")); + ui.horizontal(|ui| { + for (label, side) in [(tl!("Left"), "left"), (tl!("Right"), "right"), (tl!("Top"), "top"), (tl!("Bottom"), "bottom")] { + if ui.selectable_label(bar.dock.as_deref() == Some(side), label).clicked() { + let _ = bar.place(side); + } + } + }); + ui.add_enabled(bar.dock.is_some(), crate::widgets::checkbox(&mut bar.full_side, tl!("Full-side strip"))) + .on_hover_text(tl!("Extend the docked toolbar along the entire side. Choose a side above to enable.")); + if ui.button(tl!("Close menu")).clicked() { + ui.close(); + } + ui.separator(); + ui.label(tl!("Add tools to hotbar")); + ui.add(egui::TextEdit::singleline(&mut bar.search).hint_text(tl!("Search all tools…"))); + let search = bar.search.to_lowercase(); + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for spec in COMMANDS { + if !spec.label.to_lowercase().contains(&search) && !spec.id.contains(&search) { + continue; + } + let mut selected = bar.tools.iter().any(|id| id == spec.id); + if ui.add(crate::widgets::checkbox(&mut selected, tl!(spec.label))).changed() { + if selected { + bar.tools.push(spec.id.to_owned()); + } else { + bar.tools.retain(|id| id != spec.id); + } + } + } + }); + ui.separator(); + }); + ctx.data_mut(|data| data.insert_temp(id, shown.response.rect.size())); + bar.picker_open = picker_open; + app.hotbar = bar; + if let Some(id) = run { + app.execute(id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hotbar_validation_and_placement() { + let mut bar = Hotbar::default(); + assert!(bar.tools.is_empty()); + bar.set_tools("comment.ink,comment.freehand_highlight,comment.ink").unwrap(); + assert_eq!(bar.tools.len(), 2); + assert!(bar.set_tools("does.not.exist").is_err()); + assert_eq!(bar.tools.len(), 2); + bar.place("right").unwrap(); + assert!(bar.vertical); + assert_eq!(bar.position, [1.0, 0.5]); + bar.place("bottom").unwrap(); + assert!(!bar.vertical); + assert_eq!(bar.position, [0.5, 1.0]); + assert_eq!(bar.dock.as_deref(), Some("bottom")); + bar.place("float").unwrap(); + assert!(bar.dock.is_none()); + } + + #[test] + fn hotbar_preferences_round_trip() { + let mut app = PdfCraftApp::new(); + app.set_option("hotbar-tools", "comment.freehand_highlight,comment.ink").unwrap(); + app.set_option("hotbar-side", "left").unwrap(); + app.set_option("hotbar-size", "48").unwrap(); + app.set_option("hotbar-full-side", "true").unwrap(); + assert!(app.set_option("hotbar-size", "NaN").is_err()); + let mut restored = PdfCraftApp::new(); + restored.restore(&app.persist()); + assert_eq!(restored.hotbar.tools, app.hotbar.tools); + assert_eq!(restored.hotbar.position, [0.0, 0.5]); + assert!(restored.hotbar.vertical); + assert_eq!(restored.hotbar.button_size, 48.0); + assert!(restored.hotbar.full_side); + assert_eq!(restored.hotbar.dock.as_deref(), Some("left")); + } + + #[test] + fn hotbar_restore_filters_unknown_tools_and_offscreen_positions() { + let mut app = PdfCraftApp::new(); + app.restore(r#"{"hotbar":{"tools":["invalid","comment.ink","comment.ink"],"position":[-10,999]}}"#); + assert_eq!(app.hotbar.tools, ["comment.ink"]); + assert_eq!(app.hotbar.position, [0.0, 1.0]); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ab5d1334e3..fe0138f27f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -56,6 +56,7 @@ mod dialogs; pub use edit_text_ui::{CropMode, DragTarget, ImageDrag, ImageHandle, ImageSelection}; mod editing; mod home; +mod hotbar; mod panels; pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Which as PrintWhich, system_printers}; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; @@ -294,6 +295,7 @@ pub struct PdfCraftApp { full_screen_tool: Option, /// Comment author, per-tool colours and widths, pin. pub comment_prefs: comments::CommentPrefs, + pub(crate) hotbar: hotbar::Hotbar, /// Resolved colours, including the current OS theme when following the system. pub theme: ThemeKind, pub theme_preference: ThemePreference, diff --git a/crates/ui-egui/src/panels/side_panels.rs b/crates/ui-egui/src/panels/side_panels.rs index 71c3baa648..447aeac9fb 100644 --- a/crates/ui-egui/src/panels/side_panels.rs +++ b/crates/ui-egui/src/panels/side_panels.rs @@ -65,13 +65,14 @@ 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 rail_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 } + Self { tools_side: ToolsSide::Left, rail_side: ToolsSide::Right, tools_width: TOOLS_WIDTH, side_width: SIDE_WIDTH } } } @@ -103,6 +104,19 @@ impl SidePanels { } } + /// Whether the navigation rail is docked on the right side of the window. + pub fn rail_on_right(&self) -> bool { + self.rail_side == ToolsSide::Right + } + + /// Return the layout [`Edge`] (`Start` or `End`) where the navigation rail and side panel dock. + pub fn rail_edge(&self) -> Edge { + match self.rail_side { + ToolsSide::Left => Edge::Start, + ToolsSide::Right => Edge::End, + } + } + pub fn width(&self, pane: Pane) -> f32 { match pane { Pane::Tools => self.tools_width, @@ -123,6 +137,7 @@ impl SidePanels { json!({ "version": VERSION, "tools_side": self.tools_side, + "rail_side": self.rail_side, "tools_open": tools_open, "tools_width": self.tools_width, "side_width": self.side_width, @@ -143,6 +158,9 @@ pub fn restore(saved: &Value) -> Restored { 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(side) = value.get("rail_side").and_then(|v| serde_json::from_value::(v.clone()).ok()) { + panels.rail_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; } @@ -346,10 +364,11 @@ impl PaneContent for Content<'_> { } } +/// Return the docked [`PaneState`] (edge and width) for `pane`. fn state_of(panels: &SidePanels, pane: Pane) -> PaneState { let edge = match pane { Pane::Tools => panels.tools_edge(), - Pane::Side => Edge::End, + Pane::Side => panels.rail_edge(), }; PaneState::docked(edge, panels.width(pane)) } @@ -434,7 +453,7 @@ mod tests { #[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.panels, SidePanels { tools_side: ToolsSide::Left, rail_side: ToolsSide::Right, tools_width: 300.0, side_width: 400.0 }); assert_eq!((restored.tools_open, restored.side_closed), (Some(true), false)); } @@ -448,6 +467,7 @@ mod tests { ); let restored = restore(&old); assert_eq!(restored.panels.tools_side, ToolsSide::Right); + assert_eq!(restored.panels.rail_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); @@ -461,7 +481,10 @@ mod tests { 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.panels, + SidePanels { tools_side: ToolsSide::Left, rail_side: ToolsSide::Right, tools_width: TOOLS_WIDTH, side_width: 360.0 } + ); assert_eq!((restored.tools_open, restored.side_closed), (Some(true), false)); } @@ -470,6 +493,7 @@ mod tests { 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.rail_side, ToolsSide::Right); 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)); } @@ -485,7 +509,7 @@ mod tests { ]), ); let restored = restore(&old); - assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Right, tools_width: 250.0, side_width: 380.0 }); + assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Right, rail_side: ToolsSide::Right, tools_width: 250.0, side_width: 380.0 }); assert_eq!((restored.tools_open, restored.side_closed), (Some(false), true)); } @@ -504,7 +528,10 @@ mod tests { } // 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 }); + assert_eq!( + restored.panels, + SidePanels { tools_side: ToolsSide::Left, rail_side: ToolsSide::Right, 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 { @@ -515,8 +542,19 @@ mod tests { #[test] fn saved_value_round_trips() { - let panels = SidePanels { tools_side: ToolsSide::Right, tools_width: 300.0, side_width: 410.0 }; + let panels = SidePanels { tools_side: ToolsSide::Right, rail_side: ToolsSide::Left, 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 }); } + + #[test] + fn rail_side_placement_and_restore() { + let mut panels = SidePanels::default(); + assert_eq!(panels.rail_side, ToolsSide::Right); + assert_eq!(panels.rail_edge(), Edge::End); + panels.rail_side = ToolsSide::Left; + assert_eq!(panels.rail_edge(), Edge::Start); + let restored = restore(&json!({"side_panels": {"version": 1, "rail_side": "left"}})); + assert_eq!(restored.panels.rail_side, ToolsSide::Left); + } } diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index e9956e5f95..cb8bafe642 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -54,6 +54,9 @@ pub(crate) use state::{EditorFocus, PageCx}; /// Acrobat's selection blue for annotation frames and handles (11-visual-spec §5). const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); +/// Maximum stroke thickness offered by commenting controls, in PDF points. +pub const MAX_LINE_WIDTH: f64 = 100.0; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum CommentTool { Note, @@ -63,6 +66,7 @@ pub enum CommentTool { StrikeOut, Squiggly, Ink, + FreehandHighlight, Line, Arrow, Rectangle, @@ -80,7 +84,15 @@ pub enum CommentTool { /// The quick-bar flyout groups, in Acrobat's order: Comment ▸, Highlight ▸, Draw ▸. pub const GROUPS: [&[CommentTool]; 3] = [ &[CommentTool::Note, CommentTool::TextBox, CommentTool::Callout, CommentTool::Attach], - &[CommentTool::Highlight, CommentTool::Underline, CommentTool::StrikeOut, CommentTool::Squiggly, CommentTool::Caret, CommentTool::ReplaceText], + &[ + CommentTool::Highlight, + CommentTool::FreehandHighlight, + CommentTool::Underline, + CommentTool::StrikeOut, + CommentTool::Squiggly, + CommentTool::Caret, + CommentTool::ReplaceText, + ], &[ CommentTool::Ink, CommentTool::Line, @@ -94,7 +106,8 @@ pub const GROUPS: [&[CommentTool]; 3] = [ ], ]; -pub const ALL: [CommentTool; 19] = [ +pub const ALL: [CommentTool; 20] = [ + CommentTool::FreehandHighlight, CommentTool::Eraser, CommentTool::ReplaceText, CommentTool::Attach, @@ -117,6 +130,7 @@ pub const ALL: [CommentTool; 19] = [ ]; impl CommentTool { + /// The command registry identifier (`"comment.*"`) corresponding to this tool. pub fn command(self) -> &'static str { match self { Self::Note => "comment.note", @@ -125,6 +139,7 @@ impl CommentTool { Self::Underline => "comment.underline", Self::StrikeOut => "comment.strikeout", Self::Squiggly => "comment.squiggly", + Self::FreehandHighlight => "comment.freehand_highlight", Self::Ink => "comment.ink", Self::Line => "comment.line", Self::Arrow => "comment.arrow", @@ -141,10 +156,12 @@ impl CommentTool { } } + /// Resolve a [`CommentTool`] from its command registry identifier. pub fn from_command(id: &str) -> Option { ALL.into_iter().find(|t| t.command() == id) } + /// User-facing label for this commenting tool. pub fn label(self) -> &'static str { match self { Self::Note => "Add a sticky note", @@ -153,6 +170,7 @@ impl CommentTool { Self::Underline => "Underline", Self::StrikeOut => "Strikethrough", Self::Squiggly => "Squiggly underline", + Self::FreehandHighlight => "Freehand-Highlight", Self::Ink => "Draw", Self::Line => "Line", Self::Arrow => "Arrow", @@ -169,6 +187,7 @@ impl CommentTool { } } + /// Lucide icon name for this commenting tool. pub fn icon(self) -> &'static str { match self { Self::Note => "message-square-plus", @@ -177,6 +196,7 @@ impl CommentTool { Self::Underline => "underline", Self::StrikeOut => "strikethrough", Self::Squiggly => "spline", + Self::FreehandHighlight => "highlighter", Self::Ink => "pencil", Self::Line => "minus", Self::Arrow => "move-right", @@ -209,7 +229,7 @@ impl CommentTool { /// Tools that draw with a drag gesture. pub fn draws(self) -> bool { - matches!(self, Self::Ink | Self::Line | Self::Arrow | Self::Rectangle | Self::Oval | Self::Eraser) + matches!(self, Self::Ink | Self::FreehandHighlight | Self::Line | Self::Arrow | Self::Rectangle | Self::Oval | Self::Eraser) } /// Tools that place points one click at a time (double-click or Enter finishes). @@ -240,6 +260,7 @@ impl CommentTool { Self::Highlight | Self::Underline | Self::StrikeOut | Self::Squiggly => { Shape::TextMarkup { kind: self.markup().unwrap_or(Markup::Highlight), quads: Vec::new() } } + Self::FreehandHighlight => Shape::FreehandHighlight { strokes: Vec::new() }, Self::Ink => Shape::Ink { strokes: Vec::new() }, Self::Line => Shape::Line { from: [0.0; 2], to: [0.0; 2], start: LineEnding::None, end: LineEnding::None }, Self::Arrow => Shape::Line { from: [0.0; 2], to: [0.0; 2], start: LineEnding::None, end: LineEnding::OpenArrow }, @@ -318,9 +339,10 @@ impl CommentPrefs { } } + /// Clamp and store the stroke width (`tool.min_width()..=MAX_LINE_WIDTH`) for `tool`. pub fn set_width(&mut self, tool: CommentTool, w: f64) { if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { - s.width = w.clamp(tool.min_width(), 12.0); + s.width = if w.is_finite() { w.clamp(tool.min_width(), MAX_LINE_WIDTH) } else { s.width }; } } @@ -381,6 +403,7 @@ pub fn tool_for(a: &Annotation) -> Option { ("StrikeOut", Some("StrikeOutTextEdit")) => CommentTool::ReplaceText, ("StrikeOut", _) => CommentTool::StrikeOut, ("Squiggly", _) => CommentTool::Squiggly, + ("Ink", Some("FreehandHighlight")) => CommentTool::FreehandHighlight, ("Ink", _) => CommentTool::Ink, ("Line", _) => CommentTool::Line, ("Square", _) => CommentTool::Rectangle, diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 5e74fdf56e..a7ef6c92e1 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -140,7 +140,7 @@ fn on_link(cx: &PageCx<'_>, p: Pos2) -> bool { /// clear; not while drawing freehand, where it would cover the next stroke (#429); and not while a /// comment is moved, resized or drawn. pub(crate) fn describe(cx: &PageCx<'_>, view: &mut DocView) { - let freehand = matches!(cx.tool, QuickTool::Comment(CommentTool::Ink | CommentTool::Eraser)); + let freehand = matches!(cx.tool, QuickTool::Comment(CommentTool::Ink | CommentTool::FreehandHighlight | CommentTool::Eraser)); if matches!(cx.tool, QuickTool::Fill(_)) || freehand || view.comments.gesture.is_some() { return; } @@ -343,7 +343,7 @@ impl Gestures<'_> { { let p = clamp_to(page_rect, p); let u = cx.to_user(p); - if matches!(*tool, CommentTool::Ink | CommentTool::Eraser) { + if matches!(*tool, CommentTool::Ink | CommentTool::FreehandHighlight | CommentTool::Eraser) { let last = points.last().map(|l| cx.to_screen(*l)).unwrap_or(p); if last.distance(p) >= 1.5 { points.push(u); @@ -369,7 +369,7 @@ impl Gestures<'_> { // A quick press/release does not start an egui drag, but Acrobat's Draw tool treats // it as a one-point ink stroke (a dot). The appearance uses round line caps, so a // single PDF point is visible and remains editable like any other drawing. - if tool == CommentTool::Ink + if matches!(tool, CommentTool::Ink | CommentTool::FreehandHighlight) && resp.clicked() && over_page && let Some(p) = pointer diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index 52ea8d707d..b577fbbd29 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -330,7 +330,7 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m ui.set_min_width(180.0); let mut w = style.width; ui.label(egui::RichText::new(tl!("Line thickness")).font(theme::semibold(12.0))); - if ui.add(egui::Slider::new(&mut w, tool.min_width()..=12.0).step_by(0.5).suffix(" pt")).changed() { + if ui.add(egui::Slider::new(&mut w, tool.min_width()..=super::MAX_LINE_WIDTH).step_by(0.5).suffix(" pt")).changed() { prefs.set_width(tool, w); } }); diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index bfcd67982b..59b5ca773a 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -127,10 +127,10 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin { let style = cx.prefs.style(*tool); let zoom = cx.xf.zoom(); - let stroke = Stroke::new((style.width as f32 * zoom).max(1.0), color32(style.color)); + let stroke = Stroke::new((style.width as f32 * zoom).max(1.0), color32(style.color).gamma_multiply(style.opacity as f32)); let pts: Vec = points.iter().map(|p| cx.to_screen(*p)).collect(); match tool { - CommentTool::Ink => { + CommentTool::Ink | CommentTool::FreehandHighlight => { painter.add(egui::Shape::line(pts, stroke)); } CommentTool::Eraser => { @@ -186,6 +186,7 @@ pub(super) fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option= 2.0 && rect[3] - rect[1] >= 2.0; match tool { + CommentTool::FreehandHighlight if !points.is_empty() => Some(Shape::FreehandHighlight { strokes: vec![points.to_vec()] }), CommentTool::Ink if !points.is_empty() => Some(Shape::Ink { strokes: vec![points.to_vec()] }), CommentTool::Line | CommentTool::Arrow if far => Some(Shape::Line { from: first, @@ -209,7 +210,7 @@ pub(super) fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option f64 { - 3.0 * if width.is_finite() { width.clamp(0.5, 12.0) } else { 2.0 } + 3.0 * if width.is_finite() { width.clamp(0.5, super::MAX_LINE_WIDTH) } else { 2.0 } } /// Eraser: rub out drawings along `path` (user space); one undo step for all of them. It rubs diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index ab24352256..6cac912210 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -87,7 +87,10 @@ const SETTLE_MAX: usize = 16; impl CommentView { /// Remember a released drag drawing so it stays on screen until its page is re-rendered. pub(crate) fn settle(&mut self, now: f64, page: usize, tool: CommentTool, points: &[[f64; 2]]) { - if !matches!(tool, CommentTool::Ink | CommentTool::Line | CommentTool::Arrow | CommentTool::Rectangle | CommentTool::Oval) { + if !matches!( + tool, + CommentTool::Ink | CommentTool::FreehandHighlight | CommentTool::Line | CommentTool::Arrow | CommentTool::Rectangle | CommentTool::Oval + ) { return; } self.settling.retain(|s| now - s.since < SETTLE_SECS); diff --git a/crates/ui-egui/src/tools/comments/tests.rs b/crates/ui-egui/src/tools/comments/tests.rs index 91e796e43a..6110e8a6fc 100644 --- a/crates/ui-egui/src/tools/comments/tests.rs +++ b/crates/ui-egui/src/tools/comments/tests.rs @@ -1,5 +1,22 @@ use super::*; +#[test] +fn freehand_highlight_and_draw_accept_widths_up_to_100() { + assert_eq!(CommentTool::FreehandHighlight.group(), CommentTool::Highlight.group()); + assert_eq!(GROUPS.len(), 3); + let mut prefs = CommentPrefs::default(); + assert_eq!(prefs.style(CommentTool::FreehandHighlight).width, 14.0); + assert_eq!(prefs.style(CommentTool::FreehandHighlight).opacity, 0.35); + for tool in [CommentTool::Ink, CommentTool::FreehandHighlight] { + prefs.set_width(tool, 75.0); + assert_eq!(prefs.style(tool).width, 75.0); + prefs.set_width(tool, 200.0); + assert_eq!(prefs.style(tool).width, 100.0); + prefs.set_width(tool, f64::NAN); + assert_eq!(prefs.style(tool).width, 100.0); + } +} + /// A selected line is drawn where it is going: moved by its body (both ends follow the /// pointer), with an end dragged (that end alone), and as released until the page is drawn. #[test] diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 4c99ee1db8..208ea30770 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -434,6 +434,20 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok app.side_panels.tools_side = crate::side_panels::ToolsSide::Right; } }); + ui.horizontal(|ui| { + ui.label(tl!("Navigation rail side")); + let right = app.side_panels.rail_on_right(); + let left_btn = ui.selectable_label(!right, tl!("Left")); + left_btn.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, !right, tl!("Navigation rail on left"))); + if left_btn.clicked() { + app.side_panels.rail_side = crate::side_panels::ToolsSide::Left; + } + let right_btn = ui.selectable_label(right, tl!("Right")); + right_btn.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, right, tl!("Navigation rail on right"))); + if right_btn.clicked() { + app.side_panels.rail_side = crate::side_panels::ToolsSide::Right; + } + }); ui.add_space(8.0); crate::appearance::preferences_section(ui, app, t); ui.add_space(8.0); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 057663a76c..41f5040294 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3092,3 +3092,142 @@ fn a_card_with_an_editor_open_out_of_view_is_drawn_at_its_size() { let after = h.get_by_label("Card 61").rect().top(); assert!(after > before + 20.0, "the cards below make room for card 1's editor: {before} → {after}"); } + +#[test] +fn freehand_highlighter_draws_translucent_yellow_ink() { + let mut h = harness(|app| app.set_option("quick", "freehand_highlight").unwrap()); + drag_pt(&mut h, (40.0, 100.0), (240.0, 100.0)); + + let c = comments(&h); + assert_eq!(c.len(), 1, "{c:?}"); + assert_eq!(c[0].subtype, "Ink"); + assert_eq!(c[0].intent.as_deref(), Some("FreehandHighlight")); + let props = h.state().session.get(h.state().views[0].id).unwrap().comment_props(0, c[0].index).expect("ink properties"); + assert_eq!(props.color, Some([1.0, 0.94, 0.0])); + assert!((props.opacity - 0.35).abs() < 0.001); + assert_eq!(props.width, Some(14.0)); + assert_eq!(h.state().quick_tool, QuickTool::Comment(pdfcraft_ui_egui::comments::CommentTool::FreehandHighlight)); + h.state_mut().comment_prefs.set_width(pdfcraft_ui_egui::comments::CommentTool::FreehandHighlight, 100.0); + drag_pt(&mut h, (40.0, 160.0), (240.0, 160.0)); + let c = comments(&h); + assert_eq!(c.len(), 2); + let props = h.state().session.get(h.state().views[0].id).unwrap().comment_props(0, c.iter().map(|a| a.index).max().unwrap()).unwrap(); + assert_eq!(props.width, Some(100.0)); + assert_eq!(h.state().views[0].comments.selected, None); +} + +#[test] +fn hotbar_picker_adds_and_runs_freehand_highlight() { + let mut h = harness(|_| {}); + h.get_by_label("+").click(); + h.run_steps(3); + field(&h, "Search all tools…").click(); + h.run_steps(2); + field(&h, "Search all tools…").type_text("freehand"); + h.run_steps(3); + h.get_by_role_and_label(Role::CheckBox, "Freehand-Highlight").click(); + h.run_steps(3); + assert!(h.query_by_label("Close menu").is_some(), "picker stays open after adding a tool"); + h.get_by_role_and_label(Role::CheckBox, "Draw freehand").click(); + h.run_steps(3); + h.get_by_label("Close menu").click(); + h.run_steps(3); + let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_eq!(saved["hotbar"]["tools"], serde_json::json!(["comment.freehand_highlight", "comment.ink"])); + h.state_mut().set_option("hotbar-side", "bottom").unwrap(); + h.run_steps(3); + // The new tool is available directly from its custom shortcut. + h.query_all_by_role_and_label(Role::Button, "Freehand-Highlight").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().quick_tool, QuickTool::Comment(pdfcraft_ui_egui::comments::CommentTool::FreehandHighlight)); +} + +#[test] +fn hotbar_drag_moves_and_persists_position() { + let mut h = harness(|_| {}); + let before: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + let from = h.get_by_label("Drag hotbar").rect().center(); + drag(&mut h, from, from + egui::vec2(90.0, 90.0)); + let after: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_ne!(before["hotbar"]["position"], after["hotbar"]["position"]); + let mut restored = PdfCraftApp::new(); + restored.restore(&h.state().persist()); + let saved: serde_json::Value = serde_json::from_str(&restored.persist()).unwrap(); + assert_eq!(saved["hotbar"], after["hotbar"]); +} + +#[test] +fn hotbar_vertical_size_and_docking() { + let mut h = harness(|app| { + app.set_option("hotbar-tools", "comment.ink,comment.freehand_highlight").unwrap(); + app.set_option("hotbar-side", "right").unwrap(); + }); + for size in [24, 48, 64] { + h.state_mut().set_option("hotbar-size", &size.to_string()).unwrap(); + h.run_steps(5); + let measured = h.ctx.data_mut(|d| d.get_temp::(egui::Id::unique("custom-hotbar"))).unwrap(); + assert!(measured.x <= size as f32 + 24.0, "vertical bar too wide: {measured:?}"); + assert!(measured.x >= size as f32); + } + let from = h.get_by_label("Drag hotbar").rect().center(); + drag(&mut h, from, from - egui::vec2(90.0, 90.0)); + let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_eq!(saved["hotbar"]["position"], serde_json::json!([1.0, 0.5])); + assert_eq!(saved["hotbar"]["dock"], "right"); + h.state_mut().set_option("hotbar-side", "float").unwrap(); + h.run_steps(3); + let from = h.get_by_label("Drag hotbar").rect().center(); + drag(&mut h, from, from - egui::vec2(90.0, 90.0)); + let moved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_ne!(saved["hotbar"]["position"], moved["hotbar"]["position"]); +} + +#[test] +fn hotbar_settings_reopen_even_with_remembered_collapsed_state() { + let mut h = harness(|_| {}); + let mut collapsed = + egui::collapsing_header::CollapsingState::load_with_default_open(&h.ctx, egui::Id::unique("hotbar-picker").with("collapsing"), false); + collapsed.set_open(false); + collapsed.store(&h.ctx); + h.get_by_label("+").click(); + h.run_steps(5); + assert!(h.query_by_label("Close menu").is_some()); + h.get_by_label("+").click(); + h.run_steps(3); + assert!(h.query_by_label("Close menu").is_some(), "+ always shows settings"); + h.get_by_label("Close menu").click(); + h.run_steps(3); + h.get_by_label("+").click(); + h.run_steps(3); + assert!(h.query_by_label("Close menu").is_some()); +} + +#[test] +fn hotbar_full_side_strip_spans_each_edge_and_returns_to_float() { + let mut h = harness(|app| { + app.set_option("hotbar-side", "right").unwrap(); + app.set_option("hotbar-size", "48").unwrap(); + }); + h.get_by_label("+").click(); + h.run_steps(5); + h.get_by_label("Full-side strip").click(); + h.run_steps(3); + let enabled: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_eq!(enabled["hotbar"]["full_side"], true, "settings checkbox enables strip"); + h.get_by_label("Close menu").click(); + for side in ["right", "left", "top", "bottom"] { + h.state_mut().set_option("hotbar-side", side).unwrap(); + h.run_steps(5); + let size = h.ctx.data_mut(|d| d.get_temp::(egui::Id::unique("custom-hotbar"))).unwrap(); + let (length, thickness) = if matches!(side, "left" | "right") { (size.y, size.x) } else { (size.x, size.y) }; + assert!(length > 300.0, "strip must fill the edge: {side} {size:?}"); + assert!(thickness <= 64.0, "strip must stay narrow: {size:?}"); + h.get_by_label("+"); + } + let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_eq!(saved["hotbar"]["full_side"], true); + h.state_mut().set_option("hotbar-side", "float").unwrap(); + h.run_steps(5); + let size = h.ctx.data_mut(|d| d.get_temp::(egui::Id::unique("custom-hotbar"))).unwrap(); + assert!(size.x < 150.0 && size.y < 150.0, "floating toolbar is compact: {size:?}"); +} diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 69be82e7a7..b5565a53bb 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -346,6 +346,30 @@ fn closed_right_panel_stays_closed_for_a_document_asking_for_its_outline() { assert_eq!(h.state().right, None, "the closed right panel reopened after a restart"); } +#[test] +fn navigation_rail_toggles_between_right_and_left_and_persists() { + use pdfcraft_ui_egui::Dialog; + + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + assert_eq!(h.state().side_panels.rail_side, pdfcraft_ui_egui::side_panels::ToolsSide::Right); + h.get_by_label("Dock navigation rail on Left").click(); + h.run_steps(3); + assert_eq!(h.state().side_panels.rail_side, pdfcraft_ui_egui::side_panels::ToolsSide::Left); + let saved = h.state().persist(); + let mut h2 = harness(move |app| { + app.restore(&saved); + }); + assert_eq!(h2.state().side_panels.rail_side, pdfcraft_ui_egui::side_panels::ToolsSide::Left); + h2.state_mut().dialog = Some(Dialog::Preferences); + h2.run_steps(3); + h2.get_by_label("Navigation rail on right").click(); + h2.run_steps(3); + assert_eq!(h2.state().side_panels.rail_side, pdfcraft_ui_egui::side_panels::ToolsSide::Right); + h2.get_by_label("Navigation rail on left").click(); + h2.run_steps(3); + assert_eq!(h2.state().side_panels.rail_side, pdfcraft_ui_egui::side_panels::ToolsSide::Left); +} + /// Read mode hides the tools, not the navigation panes: the rail's Bookmarks button opens the /// bookmarks when Read is the default workspace (#932), and the left tool pane stays hidden. #[test] diff --git a/docs/roadmap.md b/docs/roadmap.md index c5bc1ab7d0..59f478dea1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -237,7 +237,7 @@ total matches the parity estimate. | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Arabic (logical catalog; explicit right-to-left labels and mirrored stock chrome; end-to-end RTL workflows unqualified); the 2,674-entry Brazilian Portuguese catalog covers the current key union; Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation. Candidate #3558: bounded-worker macOS per-file PDFCraft association recovery after desktop open/save/sign; metadata-only watcher handling and upstream #3042 shared-writer preservation; recovery parser limits do not narrow save limits. Native workflow qualified on macOS 27.0.1; other versions and physical SMB/FUSE volumes remain unqualified. Estimates unchanged. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation. | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Local customization: adjustable, floating or side-docked hotbar with optional full-side strip and persistent tool picker; freehand highlighting is grouped with Highlight. Estimates unchanged. Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Arabic (logical catalog; explicit right-to-left labels and mirrored stock chrome; end-to-end RTL workflows unqualified); the 2,674-entry Brazilian Portuguese catalog covers the current key union; Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation. Candidate #3558: bounded-worker macOS per-file PDFCraft association recovery after desktop open/save/sign; metadata-only watcher handling and upstream #3042 shared-writer preservation; recovery parser limits do not narrow save limits. Native workflow qualified on macOS 27.0.1; other versions and physical SMB/FUSE volumes remain unqualified. Estimates unchanged. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation. | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | | | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 86eb0c66e7..f950e36a94 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -26,7 +26,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) |---|---|---|---| | Home view (recent files, recommended tools) | yes | yes | shipped | | All tools pane, tool sets | yes | yes, with milestone badges | shipped | -| Quick-action toolbar, customisable | yes | fixed | partial (`view.customize-quick-tools` planned) | +| Quick-action toolbar, customisable | yes | customizable floating/docked hotbar, adjustable size, persistent multi-select picker | partial (`view.customize-quick-tools`; no shortcut reordering) | | Command search | "Search tools" | command palette (⌘K), matches translated and English labels | shipped | | Document tabs | yes | yes, with unsaved marker | shipped | | Multiple windows, drag a tab out | yes | no | planned | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 301c941e5c..02bb03f09c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1808,8 +1808,13 @@ evidence = [ "crates/ui-egui/tests/it/pinned_tools.rs::home_shows_pinned_tools_section_and_placeholder", "crates/ui-egui/tests/it/pinned_tools.rs::pin_tool_dialog_workflow", "crates/ui-egui/tests/it/pinned_tools.rs::sidebar_pin_toggle_unpins_and_pins", + "crates/ui-egui/tests/it/comments.rs::hotbar_picker_adds_and_runs_freehand_highlight", + "crates/ui-egui/tests/it/comments.rs::hotbar_drag_moves_and_persists_position", + "crates/ui-egui/tests/it/comments.rs::hotbar_vertical_size_and_docking", + "crates/ui-egui/tests/it/comments.rs::hotbar_settings_reopen_even_with_remembered_collapsed_state", + "crates/ui-egui/tests/it/comments.rs::hotbar_full_side_strip_spans_each_edge_and_returns_to_float", ] -notes = "Pin and unpin tools on the Home page via tool card hover button, All Tools row toggle, or 'Pin a tool' search dialog. Pinned tool order persists across sessions. Floating quick-action toolbar customisation remains planned." +notes = "Pin and unpin tools on the Home page via tool card hover button, All Tools row toggle, or 'Pin a tool' search dialog. Pinned tool order persists across sessions. Customizable document hotbar searches the command registry, adds/removes tools, resizes buttons from 24 to 64 pixels, floats or docks on any side with an optional full-side chrome strip, and persists preferences (UI control: hotbar-tools, hotbar-size, hotbar-side, hotbar-full-side). Missing: hotbar shortcut reordering and full Acrobat customization-dialog parity." [[feature]] id = "view.restriction-notice"