mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 22:41:47 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user