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.
This commit is contained in:
archamon
2026-11-01 20:23:12 +00:00
parent b36bdaeac6
commit 53c79ca9b0
38 changed files with 792 additions and 36 deletions
+1
View File
@@ -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.
+5 -1
View File
@@ -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");
}
+6 -2
View File
@@ -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 } => {
+11 -1
View File
@@ -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<f64> =
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 {
+26
View File
@@ -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<f64> = 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<f64> = 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);
}
+7 -1
View File
@@ -355,6 +355,10 @@ pub enum Shape {
start: LineEnding,
end: LineEnding,
},
/// Translucent freehand highlighting, stored as ink with a distinct intent.
FreehandHighlight {
strokes: Vec<Vec<[f64; 2]>>,
},
/// Freehand strokes (Draw tool).
Ink {
strokes: Vec<Vec<[f64; 2]>>,
@@ -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),
+3 -2
View File
@@ -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::<Result<Vec<_>>>()
})
.collect::<Result<Vec<_>>>()?;
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"));
+1 -1
View File
@@ -754,7 +754,7 @@ pub fn tools() -> Vec<ToolDef> {
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(),
+21
View File
@@ -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" }));
+3
View File
@@ -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"),
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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,
+19
View File
@@ -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;
+5
View File
@@ -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::<serde_json::Value>(json) else { return };
if let Ok(mut bar) = serde_json::from_value::<crate::hotbar::Hotbar>(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 {
+1
View File
@@ -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);
+15 -1
View File
@@ -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)];
+7 -2
View File
@@ -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);
+2
View File
@@ -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" => {
+5
View File
@@ -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
+2
View File
@@ -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,
+1 -1
View File
@@ -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() {
+5 -2
View File
@@ -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;
}
+355
View File
@@ -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<String>,
/// 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<String>,
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<String> = 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<usize>) {
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::<egui::Vec2>(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]);
}
}
+2
View File
@@ -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<QuickTool>,
/// 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,
+45 -7
View File
@@ -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::<ToolsSide>(v.clone()).ok()) {
panels.tools_side = side;
}
if let Some(side) = value.get("rail_side").and_then(|v| serde_json::from_value::<ToolsSide>(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<Pane> 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);
}
}
+27 -4
View File
@@ -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<Self> {
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<CommentTool> {
("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,
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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);
}
});
+4 -3
View File
@@ -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<Pos2> = 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<Shap
let rect = [first[0].min(last[0]), first[1].min(last[1]), first[0].max(last[0]), first[1].max(last[1])];
let big = rect[2] - rect[0] >= 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<Shap
/// per point, so the default 2 pt keeps the 12-pixel eraser it always had, and the thickness
/// slider's 0.5–12 pt gives 3 to 72 pixels across. A setting that isn't a number is the default.
fn eraser_radius_px(width: f64) -> 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
+4 -1
View File
@@ -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);
@@ -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]
+14
View File
@@ -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);
+139
View File
@@ -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::Vec2>(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::Vec2>(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::Vec2>(egui::Id::unique("custom-hotbar"))).unwrap();
assert!(size.x < 150.0 && size.y < 150.0, "floating toolbar is compact: {size:?}");
}
+24
View File
@@ -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]
+1 -1
View File
@@ -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<br>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.<br>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<br>Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).<br>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<br>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) |
+1 -1
View File
@@ -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 |
+6 -1
View File
@@ -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"