mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-08 12:02:28 +00:00
Merge origin/main into feat/3d-embed
Resolves the ROADMAP.md progress-log conflict by keeping both lines, newest first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
commit
342524e130
12 files changed
+976
-317
No files matched your search
@@ -219,7 +219,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop.
|
||||
|
||||
- **2026-11-02 (M12, #1297):** `model_add` embeds a U3D or PRC file, recognised by its header, as a 3D annotation with a chosen activation and a poster (an image or PDF page fitted in the box, or a labelled frame); an older file's catalog `/Version` is raised to what the model needs. Views, a rendered poster and a UI command are later steps. Estimates unchanged.
|
||||
|
||||
- **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer.
|
||||
|
||||
- **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged.
|
||||
|
||||
@@ -160,6 +160,9 @@ impl PdfCraftApp {
|
||||
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()) {
|
||||
if v["hotbar"].get("placed").is_none() {
|
||||
bar.adopt_legacy();
|
||||
}
|
||||
bar.normalize();
|
||||
self.hotbar = bar;
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ use crate::theme::{self, Tokens};
|
||||
use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets};
|
||||
|
||||
mod area;
|
||||
pub(super) mod autoscroll;
|
||||
mod bars;
|
||||
pub(crate) mod autoscroll;
|
||||
pub(crate) mod bars;
|
||||
mod cache;
|
||||
pub(super) mod crop;
|
||||
mod find;
|
||||
@@ -253,6 +253,22 @@ impl ImageObb {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether pointer `p` is on an image being cropped, its crop box or its handles (#5835).
|
||||
pub(crate) fn crop_contains_point(
|
||||
img: &pdfcraft_engine::PageImage,
|
||||
crop: &crate::edit_text_ui::CropMode,
|
||||
xf: &PageXform,
|
||||
info: &DocInfo,
|
||||
page: usize,
|
||||
p: Pos2,
|
||||
) -> bool {
|
||||
let obb = ImageObb::from_image(img, xf, info, page);
|
||||
let full_obb = ImageObb::from_matrix(img.matrix, xf, info, page);
|
||||
crate::edit_text_ui::quad_contains(obb.corners(), p)
|
||||
|| crate::edit_text_ui::quad_contains(full_obb.corners(), p)
|
||||
|| crate::edit_text_ui::crop_controls_contain(img, crop, xf, info, page, p)
|
||||
}
|
||||
|
||||
/// The page display as it was before full screen, put back on leaving it
|
||||
/// ([`DocView::enter_presentation`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -2441,10 +2457,23 @@ fn draw_pages(
|
||||
let xf =
|
||||
PageXform { rect: r, rot: view.rotation, pw: info.pages[page_idx].width.max(1.0), ph: info.pages[page_idx].height.max(1.0) };
|
||||
let images = cached_edit_images(view, doc, page_idx).to_vec();
|
||||
// Crop controls own the click even when another image overlaps them.
|
||||
if view.image_selection.as_ref().is_some_and(|s| {
|
||||
s.page == page_idx
|
||||
&& s.crop
|
||||
.as_ref()
|
||||
.zip(images.get(s.index))
|
||||
.is_some_and(|(crop, img)| crop_contains_point(img, crop, &xf, info, page_idx, p))
|
||||
}) {
|
||||
over_image = true;
|
||||
break;
|
||||
}
|
||||
for (idx, img) in images.iter().enumerate().rev() {
|
||||
let obb = ImageObb::from_image(img, &xf, info, page_idx);
|
||||
let b = image_screen_rect(img, &xf, info, page_idx);
|
||||
if obb.contains(b, p) {
|
||||
let selected_crop = view.image_selection.as_ref().is_some_and(|s| s.page == page_idx && s.index == idx && s.crop.is_some());
|
||||
let hit = if selected_crop { crate::edit_text_ui::quad_contains(obb.corners(), p) } else { obb.contains(b, p) };
|
||||
if hit {
|
||||
over_image = true;
|
||||
if view.image_selection.as_ref().is_none_or(|s| s.page != page_idx || s.index != idx) {
|
||||
view.image_selection = Some(crate::edit_text_ui::ImageSelection::at(page_idx, idx));
|
||||
@@ -2557,6 +2586,22 @@ fn draw_pages(
|
||||
view.current = current;
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
let is_crop_over_image = tool == QuickTool::EditText
|
||||
&& view.image_selection.clone().is_some_and(|sel| {
|
||||
let Some(ref crop) = sel.crop else { return false };
|
||||
let pointer_pos = ptr.pos;
|
||||
let Some(p) = pointer_pos else { return false };
|
||||
let Some((_, xf)) = view.screen_xforms.iter().find(|(page_idx, _)| *page_idx == sel.page).cloned() else { return false };
|
||||
let images = cached_edit_images(view, doc, sel.page).to_vec();
|
||||
let Some(img) = images.get(sel.index) else { return false };
|
||||
crop_contains_point(img, crop, &xf, info, sel.page, p)
|
||||
});
|
||||
if is_crop_over_image {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false));
|
||||
egui::Popup::close_all(ui.ctx());
|
||||
ui.close();
|
||||
return;
|
||||
}
|
||||
// Preparing a form: the selected field's menu.
|
||||
if preparing && let Some((name, wi)) = view.prepare.selected.clone() {
|
||||
// Several fields: Align, Center, Distribute, Set Fields to Same Size.
|
||||
@@ -4063,7 +4108,6 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui,
|
||||
// The page nearest the left edge, whose margin the bar sits in.
|
||||
let page_left = app.views[index].screen_rects.iter().map(|(_, r)| r.left()).reduce(f32::min);
|
||||
quick_bar(app, avail, page_left, ui);
|
||||
crate::hotbar::show(app, avail, ui.ctx());
|
||||
}
|
||||
if let Some((source, file, destination)) = remote_open {
|
||||
app.open_remote_link(source, file, destination);
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use super::{
|
||||
Color32, CornerRadius, DocInfo, DocView, Edit, PdfCraftApp, Pos2, QuickTool, Rect, Sense, SnapshotKind, Stroke, Tokens, comments, icons, theme,
|
||||
vec2,
|
||||
};
|
||||
use super::{Color32, CornerRadius, DocInfo, DocView, PdfCraftApp, Pos2, Rect, Sense, SnapshotKind, Stroke, Tokens, icons, vec2};
|
||||
|
||||
/// Whether `p` lies in the bottom-left quadrant of `area`, where the page size shows (#1855).
|
||||
pub(super) fn in_size_hint_zone(area: Rect, p: Pos2) -> bool {
|
||||
@@ -277,258 +274,23 @@ fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, te
|
||||
}
|
||||
|
||||
/// The bar's narrowest form: one 32 pt button, 6 pt margins and a 1 pt border each side.
|
||||
const QUICK_BAR_WIDTH: f32 = 46.0;
|
||||
const QUICK_BAR_EDGE: f32 = 14.0;
|
||||
pub(crate) const QUICK_BAR_WIDTH: f32 = 46.0;
|
||||
pub(crate) const QUICK_BAR_EDGE: f32 = 14.0;
|
||||
|
||||
/// How far from the side panel the quick-action bar stands, given the width of the margin between
|
||||
/// the panel and the page and the bar's own width: centred in the margin when it holds the bar,
|
||||
/// else 14 pt in (a page filling the width). With no page on screen the caller passes a margin of 0,
|
||||
/// which is 14 pt too.
|
||||
fn quick_bar_offset(margin: f32, width: f32) -> f32 {
|
||||
pub(crate) fn quick_bar_offset(margin: f32, width: f32) -> f32 {
|
||||
// shortcut: centred in at most 100 pt of margin, so a wide margin keeps the bar by the panel.
|
||||
// `f32::min` returns the other operand for NaN, which would pass the fit check below.
|
||||
let margin = if margin.is_nan() { 0.0 } else { margin.min(100.0) };
|
||||
if margin >= width + 2.0 * 8.0 { (margin - width) / 2.0 } else { QUICK_BAR_EDGE }
|
||||
}
|
||||
|
||||
/// The floating quick-action bar in the margin between the side panel and the page.
|
||||
/// The unified quick-action bar and hotbar in the margin or docked to an edge.
|
||||
pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, page_left: Option<f32>, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
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 id = egui::Id::unique("quick-bar");
|
||||
// Last frame's bar: Fill & Sign's Text and Date tools add a wider row of controls to it.
|
||||
let width = ui.ctx().memory(|m| m.area_rect(id)).map_or(QUICK_BAR_WIDTH, |bar| bar.width().max(QUICK_BAR_WIDTH));
|
||||
// The margin starts past a hotbar docked on the left.
|
||||
let x = quick_bar_offset(page_left.map_or(0.0, |left| left - area.left() - offset.x), width);
|
||||
let pos = area.left_top() + vec2(x, QUICK_BAR_EDGE) + offset;
|
||||
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| {
|
||||
egui::Frame::NONE
|
||||
.fill(t.card)
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
.corner_radius(CornerRadius::same(12))
|
||||
.shadow(egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(if t.dark() { 100 } else { 28 }) })
|
||||
.inner_margin(egui::Margin::same(6))
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
ui.vertical(|ui| {
|
||||
if icons::button(ui, "mouse-pointer-2", 32.0, app.quick_tool == QuickTool::Select, tl!("Select (V)")).clicked() {
|
||||
app.quick_tool = QuickTool::Select;
|
||||
}
|
||||
if icons::button(ui, "hand", 32.0, app.quick_tool == QuickTool::Hand, tl!("Hand (H)")).clicked() {
|
||||
app.quick_tool = QuickTool::Hand;
|
||||
}
|
||||
// Comment ▸, Highlight ▸, Draw ▸ (Acrobat's comment toolbar groups). Clicking a
|
||||
// group selects its last-used tool; clicking it again opens the flyout.
|
||||
for g in 0..comments::GROUPS.len() {
|
||||
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)];
|
||||
ui.painter().add(egui::Shape::convex_polygon(tri.to_vec(), if active { t.accent } else { t.text_faint }, Stroke::NONE));
|
||||
let open = (resp.clicked() && active) || resp.secondary_clicked();
|
||||
if resp.clicked() && !active {
|
||||
app.execute(current.command());
|
||||
}
|
||||
egui::Popup::menu(&resp)
|
||||
.open_memory(open.then_some(egui::SetOpenCommand::Toggle))
|
||||
.align(egui::RectAlign::RIGHT_START)
|
||||
.gap(6.0)
|
||||
.show(|ui| {
|
||||
for tool in comments::GROUPS[g] {
|
||||
let on = app.quick_tool == QuickTool::Comment(*tool);
|
||||
let (row, click) = ui.allocate_exact_size(vec2(180.0, 28.0), Sense::click());
|
||||
let press = theme::Press::track(ui, &click);
|
||||
press.wash(ui, row, 4, on);
|
||||
let body = row.translate(press.offset());
|
||||
icons::paint(ui, Rect::from_min_size(body.min + vec2(8.0, 6.0), vec2(16.0, 16.0)), tool.icon(), 16.0, t.text);
|
||||
let reserve = if on { 28.0 } else { 8.0 };
|
||||
crate::widgets::paint_left(
|
||||
ui,
|
||||
body.left_center() + vec2(34.0, 0.0),
|
||||
tl!(tool.label()),
|
||||
theme::regular(13.0),
|
||||
t.text,
|
||||
(row.width() - 34.0 - reserve).max(0.0),
|
||||
);
|
||||
if on {
|
||||
icons::paint(
|
||||
ui,
|
||||
Rect::from_min_size(body.right_top() + vec2(-24.0, 7.0), vec2(14.0, 14.0)),
|
||||
"check",
|
||||
14.0,
|
||||
t.accent,
|
||||
);
|
||||
}
|
||||
let click = theme::hand(click);
|
||||
let info = tl!(tool.label()).to_string();
|
||||
click.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, info.clone()));
|
||||
if click.clicked() {
|
||||
app.execute(tool.command());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Edit text & images, while the Edit workspace is open (Acrobat's Edit toolbar)
|
||||
// or the tool is in use.
|
||||
if app.mode == crate::Mode::Edit || app.quick_tool == QuickTool::EditText {
|
||||
let on = app.quick_tool == QuickTool::EditText;
|
||||
if icons::button(ui, "text-cursor-input", 32.0, on, tl!("Edit text & images")).clicked() {
|
||||
if on {
|
||||
app.put_down_edit_text();
|
||||
} else {
|
||||
app.quick_tool = QuickTool::EditText;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fill & Sign ▸ (text, marks, date, signature).
|
||||
let current_fill = match app.quick_tool {
|
||||
QuickTool::Fill(f) => Some(f),
|
||||
_ => None,
|
||||
};
|
||||
let shown = current_fill.unwrap_or(crate::fill_sign::FillTool::Text);
|
||||
let resp = icons::button(
|
||||
ui,
|
||||
if current_fill.is_some() { shown.icon() } else { "pen-line" },
|
||||
32.0,
|
||||
current_fill.is_some(),
|
||||
tl!("Fill & Sign"),
|
||||
);
|
||||
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)];
|
||||
ui.painter().add(egui::Shape::convex_polygon(
|
||||
tri.to_vec(),
|
||||
if current_fill.is_some() { t.accent } else { t.text_faint },
|
||||
Stroke::NONE,
|
||||
));
|
||||
if resp.clicked() && current_fill.is_none() {
|
||||
app.execute(shown.command());
|
||||
}
|
||||
let open = (resp.clicked() && current_fill.is_some()) || resp.secondary_clicked();
|
||||
egui::Popup::menu(&resp)
|
||||
.open_memory(open.then_some(egui::SetOpenCommand::Toggle))
|
||||
.align(egui::RectAlign::RIGHT_START)
|
||||
.gap(6.0)
|
||||
.show(|ui| {
|
||||
for tool in crate::fill_sign::FILL_TOOLS {
|
||||
if matches!(tool, crate::fill_sign::FillTool::Signature | crate::fill_sign::FillTool::Initials) {
|
||||
continue;
|
||||
}
|
||||
let on = current_fill == Some(tool);
|
||||
let (row, click) = ui.allocate_exact_size(vec2(180.0, 28.0), Sense::click());
|
||||
let press = theme::Press::track(ui, &click);
|
||||
press.wash(ui, row, 4, on);
|
||||
let body = row.translate(press.offset());
|
||||
icons::paint(ui, Rect::from_min_size(body.min + vec2(8.0, 6.0), vec2(16.0, 16.0)), tool.icon(), 16.0, t.text);
|
||||
let reserve = if on { 28.0 } else { 8.0 };
|
||||
crate::widgets::paint_left(
|
||||
ui,
|
||||
body.left_center() + vec2(34.0, 0.0),
|
||||
tl!(tool.label()),
|
||||
theme::regular(13.0),
|
||||
t.text,
|
||||
(row.width() - 34.0 - reserve).max(0.0),
|
||||
);
|
||||
if on {
|
||||
icons::paint(
|
||||
ui,
|
||||
Rect::from_min_size(body.right_top() + vec2(-24.0, 7.0), vec2(14.0, 14.0)),
|
||||
"check",
|
||||
14.0,
|
||||
t.accent,
|
||||
);
|
||||
}
|
||||
let click = theme::hand(click);
|
||||
let info = tl!(tool.label()).to_string();
|
||||
click.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, info.clone()));
|
||||
if click.clicked() {
|
||||
app.execute(tool.command());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if let Some(command) = crate::fill_sign::signature_entries(ui, app, &t) {
|
||||
app.execute(command);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if matches!(current_fill, Some(crate::fill_sign::FillTool::Text | crate::fill_sign::FillTool::Date)) {
|
||||
ui.label(tl!("Font size:"));
|
||||
let size = ui.add(
|
||||
egui::DragValue::new(&mut app.fill_text_size)
|
||||
.range(crate::fill_sign::MIN_TEXT_SIZE..=crate::fill_sign::MAX_TEXT_SIZE)
|
||||
.speed(0.25)
|
||||
.suffix(" pt"),
|
||||
);
|
||||
// #1274: the size also applies to the Fill & Sign text that is selected,
|
||||
// once per change (a typed value, or the end of a drag), so a drag adds
|
||||
// one undo step and not one per frame.
|
||||
if ((size.changed() && !size.dragged()) || size.drag_stopped())
|
||||
&& let Some((index, id)) = app.active_ids()
|
||||
&& let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected)
|
||||
&& app.session.get(id).is_some_and(|doc| {
|
||||
doc.allows_annotation()
|
||||
&& doc
|
||||
.info
|
||||
.annotations
|
||||
.iter()
|
||||
.any(|a| a.page == page && a.index == annotation && a.fill_sign && a.subtype == "FreeText" && !a.locked)
|
||||
})
|
||||
{
|
||||
app.apply_edit(Edit::FontSizeAnnotation { page, index: annotation, size: app.fill_text_size });
|
||||
}
|
||||
}
|
||||
if matches!(current_fill, Some(crate::fill_sign::FillTool::Text | crate::fill_sign::FillTool::Date)) {
|
||||
ui.menu_button(tl!("Colour"), |ui| {
|
||||
if let Some(color) = comments::swatch_grid(ui, Some(app.fill_text_color)) {
|
||||
app.fill_text_color = color;
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
if let QuickTool::Comment(tool) = app.quick_tool {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(32.0, 9.0), Sense::hover());
|
||||
ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider));
|
||||
if let Some(color) = comments::quick_bar_controls(ui, tool, &mut app.comment_prefs)
|
||||
&& let Some((index, id)) = app.active_ids()
|
||||
&& let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected)
|
||||
&& app.session.get(id).is_some_and(|doc| {
|
||||
doc.allows_annotation()
|
||||
&& doc
|
||||
.info
|
||||
.annotations
|
||||
.iter()
|
||||
.any(|a| a.page == page && a.index == annotation && !a.is_reply() && comments::made_by(a, tool))
|
||||
})
|
||||
{
|
||||
app.apply_edit(Edit::StyleAnnotation {
|
||||
page,
|
||||
index: annotation,
|
||||
color: Some(color),
|
||||
opacity: None,
|
||||
width: None,
|
||||
endings: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
crate::hotbar::show(app, area, page_left, ui);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -22,14 +22,12 @@ pub(crate) const PREFERENCES_MIN_SCROLL: f32 = 60.0;
|
||||
///
|
||||
/// Draws a rounded container with options inside. The active option is highlighted
|
||||
/// with the accent colour. Emits AccessKit RadioButton widget info with the option's label.
|
||||
#[expect(dead_code, reason = "convenience helpers for non-searched segmented pill controls")]
|
||||
pub fn segmented_pill_choice<T: PartialEq + Copy>(ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str)]) -> bool {
|
||||
let with_a11y: Vec<(T, &str, &str)> = options.iter().map(|&(v, l)| (v, l, l)).collect();
|
||||
segmented_pill_choice_a11y(ui, t, current, &with_a11y)
|
||||
}
|
||||
|
||||
/// A segmented pill selection bar with separate accessibility labels.
|
||||
#[expect(dead_code, reason = "convenience helpers for non-searched segmented pill controls")]
|
||||
pub fn segmented_pill_choice_a11y<T: PartialEq + Copy>(ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str, &str)]) -> bool {
|
||||
segmented_pill_choice_a11y_query(ui, t, current, options, "").0
|
||||
}
|
||||
|
||||
+501
-62
@@ -1,9 +1,10 @@
|
||||
//! A movable, persistent set of shortcuts to the shared command registry.
|
||||
use egui::{Rect, Sense, vec2};
|
||||
//! A movable, persistent set of shortcuts to the shared command registry and core document tools.
|
||||
use egui::{Color32, CornerRadius, Rect, Sense, Stroke, vec2};
|
||||
use pdfcraft_engine::Edit;
|
||||
use pdfcraft_engine::commands::{COMMANDS, command};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{PdfCraftApp, icons, theme::Tokens};
|
||||
use crate::{PdfCraftApp, QuickTool, comments, icons, theme, theme::Tokens};
|
||||
|
||||
/// Persistent state and UI state for the customizable document hotbar.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -16,6 +17,8 @@ pub(crate) struct Hotbar {
|
||||
pub button_size: f32,
|
||||
pub dock: Option<String>,
|
||||
pub full_side: bool,
|
||||
#[serde(default)]
|
||||
pub placed: bool,
|
||||
#[serde(skip)]
|
||||
search: String,
|
||||
#[serde(skip)]
|
||||
@@ -23,15 +26,16 @@ pub(crate) struct Hotbar {
|
||||
}
|
||||
|
||||
impl Default for Hotbar {
|
||||
/// Create a default empty horizontal floating hotbar.
|
||||
/// Create a default vertical floating hotbar positioned in the document margin.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tools: Vec::new(),
|
||||
position: [0.5, 0.0],
|
||||
vertical: false,
|
||||
position: [0.0, 0.0],
|
||||
vertical: true,
|
||||
button_size: 32.0,
|
||||
dock: None,
|
||||
full_side: false,
|
||||
placed: false,
|
||||
search: String::new(),
|
||||
picker_open: false,
|
||||
}
|
||||
@@ -51,6 +55,24 @@ impl Hotbar {
|
||||
for coordinate in &mut self.position {
|
||||
*coordinate = if coordinate.is_finite() { coordinate.clamp(0.0, 1.0) } else { 0.5 };
|
||||
}
|
||||
if self.dock.is_some() || self.position != [0.0, 0.0] {
|
||||
self.placed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Adopt preferences saved before the hotbar absorbed the quick-action bar (#5890), which have
|
||||
/// no `placed` key. A bar left at that release's default (floating, horizontal, top centre)
|
||||
/// moves to the new default in the left margin; any other spot is one the user chose, so it
|
||||
/// is kept, including the top-left corner.
|
||||
pub fn adopt_legacy(&mut self) {
|
||||
if self.dock.is_none() && !self.vertical && self.position == [0.5, 0.0] {
|
||||
self.position = [0.0, 0.0];
|
||||
self.vertical = true;
|
||||
self.full_side = false;
|
||||
self.placed = false;
|
||||
} else {
|
||||
self.placed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse and validate a comma-separated list of command identifiers for the hotbar.
|
||||
@@ -68,6 +90,7 @@ impl Hotbar {
|
||||
pub fn place(&mut self, side: &str) -> Result<(), String> {
|
||||
if side == "float" {
|
||||
self.dock = None;
|
||||
self.placed = true;
|
||||
return Ok(());
|
||||
}
|
||||
let (position, vertical) = match side {
|
||||
@@ -80,12 +103,13 @@ impl Hotbar {
|
||||
self.dock = Some(side.to_owned());
|
||||
self.position = position;
|
||||
self.vertical = vertical;
|
||||
self.placed = true;
|
||||
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) {
|
||||
fn settings_button(ui: &mut egui::Ui, cell: f32, picker_open: &mut bool) -> bool {
|
||||
let settings = ui
|
||||
.push_id("hotbar-settings", |ui| {
|
||||
icons::button(ui, "settings", cell, false, tl!("Hotbar settings")).on_hover_text(tl!("Hotbar settings / add tools"))
|
||||
@@ -93,6 +117,25 @@ fn settings_button(ui: &mut egui::Ui, cell: f32, picker_open: &mut bool) {
|
||||
.inner;
|
||||
if settings.clicked() {
|
||||
*picker_open = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine where flyout popup menus should open from a toolbar button based on docking and layout.
|
||||
fn popup_align_for(dock: Option<&str>, vertical: bool) -> egui::RectAlign {
|
||||
match dock {
|
||||
Some("right") => egui::RectAlign::LEFT_START,
|
||||
Some("bottom") => egui::RectAlign::TOP_START,
|
||||
Some("top") => egui::RectAlign::BOTTOM_START,
|
||||
_ => {
|
||||
if vertical {
|
||||
egui::RectAlign::RIGHT_START
|
||||
} else {
|
||||
egui::RectAlign::BOTTOM_START
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +146,7 @@ fn matches_search(spec: &pdfcraft_engine::commands::CommandSpec, query: &str) ->
|
||||
tl!(spec.label).to_lowercase().contains(&search) || spec.label.to_lowercase().contains(&search) || spec.id.to_lowercase().contains(&search)
|
||||
}
|
||||
|
||||
/// Render the configured tool shortcut buttons and their removal context menus.
|
||||
/// Render the configured custom 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 };
|
||||
@@ -121,30 +164,279 @@ fn tool_buttons(ui: &mut egui::Ui, app: &PdfCraftApp, tools: &[String], cell: f3
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
/// Render core quick tools: Select, Hand, Comment groups, Edit text, and Fill & Sign.
|
||||
fn core_tool_buttons(ui: &mut egui::Ui, app: &mut PdfCraftApp, cell: f32, popup_align: egui::RectAlign, t: &Tokens) {
|
||||
if icons::button(ui, "mouse-pointer-2", cell, app.quick_tool == QuickTool::Select, tl!("Select (V)")).clicked() {
|
||||
app.quick_tool = QuickTool::Select;
|
||||
}
|
||||
if icons::button(ui, "hand", cell, app.quick_tool == QuickTool::Hand, tl!("Hand (H)")).clicked() {
|
||||
app.quick_tool = QuickTool::Hand;
|
||||
}
|
||||
// Comment ▸, Highlight ▸, Draw ▸ (Acrobat's comment toolbar groups). Clicking a
|
||||
// group selects its last-used tool; clicking it again opens the flyout.
|
||||
for g in 0..comments::GROUPS.len() {
|
||||
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(), cell, 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)];
|
||||
ui.painter().add(egui::Shape::convex_polygon(tri.to_vec(), if active { t.accent } else { t.text_faint }, Stroke::NONE));
|
||||
let open = (resp.clicked() && active) || resp.secondary_clicked();
|
||||
if resp.clicked() && !active {
|
||||
app.execute(current.command());
|
||||
}
|
||||
egui::Popup::menu(&resp).open_memory(open.then_some(egui::SetOpenCommand::Toggle)).align(popup_align).gap(6.0).show(|ui| {
|
||||
for tool in comments::GROUPS[g] {
|
||||
let on = app.quick_tool == QuickTool::Comment(*tool);
|
||||
let (row, click) = ui.allocate_exact_size(vec2(180.0, 28.0), Sense::click());
|
||||
let press = theme::Press::track(ui, &click);
|
||||
press.wash(ui, row, 4, on);
|
||||
let body = row.translate(press.offset());
|
||||
icons::paint(ui, Rect::from_min_size(body.min + vec2(8.0, 6.0), vec2(16.0, 16.0)), tool.icon(), 16.0, t.text);
|
||||
let reserve = if on { 28.0 } else { 8.0 };
|
||||
crate::widgets::paint_left(
|
||||
ui,
|
||||
body.left_center() + vec2(34.0, 0.0),
|
||||
tl!(tool.label()),
|
||||
theme::regular(13.0),
|
||||
t.text,
|
||||
(row.width() - 34.0 - reserve).max(0.0),
|
||||
);
|
||||
if on {
|
||||
icons::paint(ui, Rect::from_min_size(body.right_top() + vec2(-24.0, 7.0), vec2(14.0, 14.0)), "check", 14.0, t.accent);
|
||||
}
|
||||
let click = theme::hand(click);
|
||||
let info = tl!(tool.label()).to_string();
|
||||
click.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, info.clone()));
|
||||
if click.clicked() {
|
||||
app.execute(tool.command());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Edit text & images, while the Edit workspace is open (Acrobat's Edit toolbar)
|
||||
// or the tool is in use.
|
||||
if app.mode == crate::Mode::Edit || app.quick_tool == QuickTool::EditText {
|
||||
let on = app.quick_tool == QuickTool::EditText;
|
||||
if icons::button(ui, "text-cursor-input", cell, on, tl!("Edit text & images")).clicked() {
|
||||
if on {
|
||||
app.put_down_edit_text();
|
||||
} else {
|
||||
app.quick_tool = QuickTool::EditText;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fill & Sign ▸ (text, marks, date, signature).
|
||||
let current_fill = match app.quick_tool {
|
||||
QuickTool::Fill(f) => Some(f),
|
||||
_ => None,
|
||||
};
|
||||
let shown = current_fill.unwrap_or(crate::fill_sign::FillTool::Text);
|
||||
let resp = icons::button(ui, if current_fill.is_some() { shown.icon() } else { "pen-line" }, cell, current_fill.is_some(), tl!("Fill & Sign"));
|
||||
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)];
|
||||
ui.painter().add(egui::Shape::convex_polygon(tri.to_vec(), if current_fill.is_some() { t.accent } else { t.text_faint }, Stroke::NONE));
|
||||
if resp.clicked() && current_fill.is_none() {
|
||||
app.execute(shown.command());
|
||||
}
|
||||
let open = (resp.clicked() && current_fill.is_some()) || resp.secondary_clicked();
|
||||
egui::Popup::menu(&resp).open_memory(open.then_some(egui::SetOpenCommand::Toggle)).align(popup_align).gap(6.0).show(|ui| {
|
||||
for tool in crate::fill_sign::FILL_TOOLS {
|
||||
if matches!(tool, crate::fill_sign::FillTool::Signature | crate::fill_sign::FillTool::Initials) {
|
||||
continue;
|
||||
}
|
||||
let on = current_fill == Some(tool);
|
||||
let (row, click) = ui.allocate_exact_size(vec2(180.0, 28.0), Sense::click());
|
||||
let press = theme::Press::track(ui, &click);
|
||||
press.wash(ui, row, 4, on);
|
||||
let body = row.translate(press.offset());
|
||||
icons::paint(ui, Rect::from_min_size(body.min + vec2(8.0, 6.0), vec2(16.0, 16.0)), tool.icon(), 16.0, t.text);
|
||||
let reserve = if on { 28.0 } else { 8.0 };
|
||||
crate::widgets::paint_left(
|
||||
ui,
|
||||
body.left_center() + vec2(34.0, 0.0),
|
||||
tl!(tool.label()),
|
||||
theme::regular(13.0),
|
||||
t.text,
|
||||
(row.width() - 34.0 - reserve).max(0.0),
|
||||
);
|
||||
if on {
|
||||
icons::paint(ui, Rect::from_min_size(body.right_top() + vec2(-24.0, 7.0), vec2(14.0, 14.0)), "check", 14.0, t.accent);
|
||||
}
|
||||
let click = theme::hand(click);
|
||||
let info = tl!(tool.label()).to_string();
|
||||
click.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, info.clone()));
|
||||
if click.clicked() {
|
||||
app.execute(tool.command());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if let Some(command) = crate::fill_sign::signature_entries(ui, app, t) {
|
||||
app.execute(command);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Extra controls rendered below/beside the buttons when a comment or fill tool is active.
|
||||
fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, vertical: bool) {
|
||||
let current_fill = match app.quick_tool {
|
||||
QuickTool::Fill(f) => Some(f),
|
||||
_ => None,
|
||||
};
|
||||
if matches!(current_fill, Some(crate::fill_sign::FillTool::Text | crate::fill_sign::FillTool::Date)) {
|
||||
if vertical {
|
||||
ui.label(tl!("Font size:"));
|
||||
}
|
||||
let size = ui.add(
|
||||
egui::DragValue::new(&mut app.fill_text_size)
|
||||
.range(crate::fill_sign::MIN_TEXT_SIZE..=crate::fill_sign::MAX_TEXT_SIZE)
|
||||
.speed(0.25)
|
||||
.suffix(" pt"),
|
||||
);
|
||||
if ((size.changed() && !size.dragged()) || size.drag_stopped())
|
||||
&& let Some((index, id)) = app.active_ids()
|
||||
&& let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected)
|
||||
&& app.session.get(id).is_some_and(|doc| {
|
||||
doc.allows_annotation()
|
||||
&& doc
|
||||
.info
|
||||
.annotations
|
||||
.iter()
|
||||
.any(|a| a.page == page && a.index == annotation && a.fill_sign && a.subtype == "FreeText" && !a.locked)
|
||||
})
|
||||
{
|
||||
app.apply_edit(Edit::FontSizeAnnotation { page, index: annotation, size: app.fill_text_size });
|
||||
}
|
||||
ui.menu_button(tl!("Colour"), |ui| {
|
||||
if let Some(color) = comments::swatch_grid(ui, Some(app.fill_text_color)) {
|
||||
app.fill_text_color = color;
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
if let QuickTool::Comment(tool) = app.quick_tool {
|
||||
let (r, _) = if vertical {
|
||||
ui.allocate_exact_size(vec2(crate::canvas::bars::QUICK_BAR_WIDTH, 9.0), Sense::hover())
|
||||
} else {
|
||||
ui.allocate_exact_size(vec2(9.0, crate::canvas::bars::QUICK_BAR_WIDTH), Sense::hover())
|
||||
};
|
||||
if vertical {
|
||||
ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider));
|
||||
} else {
|
||||
ui.painter().vline(r.center().x, r.y_range().shrink(6.0), Stroke::new(1.0, t.divider));
|
||||
}
|
||||
if let Some(color) = comments::quick_bar_controls(ui, tool, &mut app.comment_prefs)
|
||||
&& let Some((index, id)) = app.active_ids()
|
||||
&& let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected)
|
||||
&& app.session.get(id).is_some_and(|doc| {
|
||||
doc.allows_annotation()
|
||||
&& doc
|
||||
.info
|
||||
.annotations
|
||||
.iter()
|
||||
.any(|a| a.page == page && a.index == annotation && !a.is_reply() && !a.locked && comments::made_by(a, tool))
|
||||
})
|
||||
{
|
||||
app.apply_edit(Edit::StyleAnnotation { page, index: annotation, color: Some(color), opacity: None, width: None, endings: None });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a modern iOS-style pill toggle switch.
|
||||
fn toggle_switch(ui: &mut egui::Ui, on: &mut bool, label: &str, t: &Tokens) -> egui::Response {
|
||||
let width = 34.0;
|
||||
let height = 18.0;
|
||||
let (rect, mut response) = ui.allocate_exact_size(vec2(width, height), Sense::click());
|
||||
if response.clicked() {
|
||||
*on = !*on;
|
||||
response.mark_changed();
|
||||
}
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Switch, ui.is_enabled(), *on, label));
|
||||
|
||||
if ui.is_rect_visible(rect) {
|
||||
let radius = height / 2.0;
|
||||
let bg_color = if *on { t.accent } else { t.border };
|
||||
ui.painter().rect(rect, CornerRadius::same(radius as u8), bg_color, Stroke::NONE, egui::StrokeKind::Inside);
|
||||
let circle_radius = radius - 2.5;
|
||||
let circle_x = if *on { rect.max.x - radius } else { rect.min.x + radius };
|
||||
ui.painter().circle_filled(egui::pos2(circle_x, rect.center().y), circle_radius, Color32::WHITE);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
/// Render the dock placement choice: [ Float | Left | Right | Top | Bottom ], scrolling sideways
|
||||
/// when the picker is narrower than the row.
|
||||
fn segmented_dock_picker(ui: &mut egui::Ui, bar: &mut Hotbar, t: &Tokens) {
|
||||
let options = [
|
||||
(None, tl!("Float")),
|
||||
(Some("left"), tl!("Left")),
|
||||
(Some("right"), tl!("Right")),
|
||||
(Some("top"), tl!("Top")),
|
||||
(Some("bottom"), tl!("Bottom")),
|
||||
];
|
||||
let mut side = options.iter().map(|(side, _)| *side).find(|side| *side == bar.dock.as_deref()).flatten();
|
||||
let changed = egui::ScrollArea::horizontal()
|
||||
.id_salt("hotbar-dock")
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| crate::dialogs::preferences::segmented_pill_choice(ui, t, &mut side, &options))
|
||||
.inner;
|
||||
if changed {
|
||||
// `place` accepts every side listed above.
|
||||
let _ = bar.place(side.unwrap_or("float"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the unified floating or side-docked toolbar, combining quick actions, core tools, and custom shortcuts.
|
||||
pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, page_left: Option<f32>, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut bar = std::mem::take(&mut app.hotbar);
|
||||
let t = Tokens::get(ctx);
|
||||
let id = egui::Id::unique("custom-hotbar");
|
||||
bar.normalize();
|
||||
let t = Tokens::get(&ctx);
|
||||
let id = egui::Id::unique("quick-bar");
|
||||
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));
|
||||
let popup_align = popup_align_for(bar.dock.as_deref(), bar.vertical);
|
||||
let mut size = ctx.data_mut(|data| data.get_temp::<egui::Vec2>(id)).unwrap_or(vec2(crate::canvas::bars::QUICK_BAR_WIDTH, 220.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 default_x = crate::canvas::bars::quick_bar_offset(page_left.map_or(0.0, |left| left - area.left()), size.x);
|
||||
let pos = if full_side {
|
||||
bounds.min + vec2(travel.x * bar.position[0], travel.y * bar.position[1])
|
||||
} else if bar.dock.is_none() && !bar.placed {
|
||||
area.left_top() + vec2(default_x, crate::canvas::bars::QUICK_BAR_EDGE)
|
||||
} else {
|
||||
bounds.min + vec2(travel.x * bar.position[0], travel.y * bar.position[1])
|
||||
};
|
||||
let mut run = None;
|
||||
let was_picker_open = bar.picker_open;
|
||||
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 mut settings_clicked = false;
|
||||
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)
|
||||
egui::Frame::new()
|
||||
.fill(t.card)
|
||||
.stroke(egui::Stroke::new(1.0, t.border))
|
||||
.corner_radius(CornerRadius::same(12))
|
||||
.shadow(egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(if t.dark() { 100 } else { 28 }) })
|
||||
.inner_margin(6)
|
||||
};
|
||||
let frame_resp = frame.show(ui, |ui| {
|
||||
if full_side {
|
||||
@@ -160,23 +452,33 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, ctx: &egui::Context) {
|
||||
if full_side {
|
||||
if bar.vertical {
|
||||
ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| {
|
||||
settings_button(ui, cell, &mut picker_open);
|
||||
settings_clicked |= 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| {
|
||||
egui::ScrollArea::vertical().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);
|
||||
core_tool_buttons(ui, app, cell, popup_align, &t);
|
||||
active_tool_controls(ui, app, &t, true);
|
||||
if !bar.tools.is_empty() {
|
||||
ui.separator();
|
||||
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);
|
||||
settings_clicked |= 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);
|
||||
core_tool_buttons(ui, app, cell, popup_align, &t);
|
||||
active_tool_controls(ui, app, &t, false);
|
||||
if !bar.tools.is_empty() {
|
||||
ui.separator();
|
||||
tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -198,21 +500,44 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, ctx: &egui::Context) {
|
||||
grip.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, drag_label));
|
||||
if grip.dragged() && bar.dock.is_none() {
|
||||
let delta = grip.drag_delta();
|
||||
if !bar.placed {
|
||||
let cur_x = (pos.x - bounds.min.x).max(0.0);
|
||||
let cur_y = (pos.y - bounds.min.y).max(0.0);
|
||||
bar.position[0] = (cur_x / travel.x.max(1.0)).clamp(0.0, 1.0);
|
||||
bar.position[1] = (cur_y / travel.y.max(1.0)).clamp(0.0, 1.0);
|
||||
}
|
||||
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);
|
||||
bar.placed = true;
|
||||
}
|
||||
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);
|
||||
if bar.vertical {
|
||||
let fixed_overhead = grip_size.y + cell + 24.0;
|
||||
let max_tools_length = (bounds.height() - fixed_overhead).max(cell);
|
||||
egui::ScrollArea::vertical().max_height(max_tools_length).show(ui, |ui| {
|
||||
ui.with_layout(layout, |ui| {
|
||||
core_tool_buttons(ui, app, cell, popup_align, &t);
|
||||
active_tool_controls(ui, app, &t, true);
|
||||
if !bar.tools.is_empty() {
|
||||
ui.separator();
|
||||
tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
settings_button(ui, cell, &mut picker_open);
|
||||
settings_clicked |= settings_button(ui, cell, &mut picker_open);
|
||||
} else {
|
||||
core_tool_buttons(ui, app, cell, popup_align, &t);
|
||||
active_tool_controls(ui, app, &t, false);
|
||||
if !bar.tools.is_empty() {
|
||||
ui.separator();
|
||||
let scroll_max = (bounds.width() - cell - 50.0).max(cell);
|
||||
egui::ScrollArea::horizontal().max_height(cell).max_width(scroll_max).show(ui, |ui| {
|
||||
ui.with_layout(layout, |ui| {
|
||||
tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove);
|
||||
});
|
||||
});
|
||||
}
|
||||
settings_clicked |= settings_button(ui, cell, &mut picker_open);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(index) = remove {
|
||||
@@ -241,50 +566,132 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, ctx: &egui::Context) {
|
||||
});
|
||||
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);
|
||||
let mut expanded = egui::collapsing_header::CollapsingState::load_with_default_open(&ctx, picker_id.with("collapsing"), true);
|
||||
expanded.set_open(true);
|
||||
expanded.store(ctx);
|
||||
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"))
|
||||
let margin_x = 14.0;
|
||||
let card_margin_total = margin_x * 2.0;
|
||||
let card_width = 280.0f32.min((screen.width() - card_margin_total).max(180.0));
|
||||
let outer_width = card_width + card_margin_total;
|
||||
let bar_rect = shown.response.rect;
|
||||
let picker_x =
|
||||
if bar_rect.right() + outer_width + 10.0 < screen.right() { bar_rect.right() + 14.0 } else { bar_rect.left() - outer_width - 14.0 };
|
||||
let picker_y = if bar.dock.as_deref() == Some("top") {
|
||||
(bar_rect.bottom() + 10.0).clamp(screen.top(), (screen.bottom() - 480.0).max(screen.top()))
|
||||
} else if bar.dock.as_deref() == Some("bottom") {
|
||||
(bar_rect.top() - 500.0).clamp(screen.top(), (screen.bottom() - 480.0).max(screen.top()))
|
||||
} else {
|
||||
bar_rect.top().clamp(screen.top(), (screen.bottom() - 480.0).max(screen.top()))
|
||||
};
|
||||
let picker_pos = egui::pos2(picker_x.clamp(screen.left(), (screen.right() - outer_width).max(screen.left())), picker_y);
|
||||
let mut close_dialog = false;
|
||||
let picker_resp = egui::Window::new(tl!("Hotbar tools"))
|
||||
.id(picker_id)
|
||||
.order(egui::Order::Foreground)
|
||||
.open(&mut picker_open)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(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:"));
|
||||
.frame(
|
||||
egui::Frame::NONE
|
||||
.fill(t.card)
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
.corner_radius(CornerRadius::same(10))
|
||||
.shadow(egui::Shadow { offset: [0, 6], blur: 20, spread: 0, color: Color32::from_black_alpha(if t.dark() { 120 } else { 35 }) })
|
||||
.inner_margin(egui::Margin::symmetric(14, 12)),
|
||||
)
|
||||
.show(&ctx, |ui| {
|
||||
ui.set_width(card_width);
|
||||
|
||||
// Title bar: "Hotbar Tools" on the left, "X" close button on the right
|
||||
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.label(egui::RichText::new(tl!("Hotbar tools")).size(14.0).strong().color(t.text));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if icons::button(ui, "x", 16.0, false, tl!("Close menu")).clicked() {
|
||||
close_dialog = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(10.0);
|
||||
|
||||
// Section 1: Layout & Sizing
|
||||
ui.label(egui::RichText::new(tl!("Layout & Sizing")).size(13.0).strong().color(t.text));
|
||||
ui.add_space(6.0);
|
||||
|
||||
// Toolbar size slider row
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Toolbar size")).size(12.5).color(t.text));
|
||||
let slider_width = (ui.available_width() - 55.0).clamp(50.0, 140.0);
|
||||
ui.spacing_mut().slider_width = slider_width;
|
||||
ui.add(egui::Slider::new(&mut bar.button_size, 24.0..=64.0).show_value(false));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(egui::RichText::new(format!("{:.0} px", bar.button_size)).size(12.0).color(t.text_muted));
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Vertical layout toggle switch row
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_enabled_ui(bar.dock.is_none(), |ui| {
|
||||
let label = tl!("Vertical layout");
|
||||
toggle_switch(ui, &mut bar.vertical, label, &t);
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new(label).size(12.5).color(t.text));
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(10.0);
|
||||
|
||||
// Stick to a side section with segmented control
|
||||
ui.label(egui::RichText::new(tl!("Stick to a side:")).size(12.5).color(t.text));
|
||||
ui.add_space(6.0);
|
||||
segmented_dock_picker(ui, &mut bar, &t);
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Full-side strip checkbox
|
||||
let strip_hint = tl!("Extend the docked toolbar along the entire side. Choose a side above to enable.");
|
||||
ui.add_enabled(bar.dock.is_some(), crate::widgets::checkbox(&mut bar.full_side, tl!("Full-side strip")))
|
||||
.on_hover_text(strip_hint)
|
||||
.on_disabled_hover_text(strip_hint);
|
||||
if ui.button(tl!("Close menu")).clicked() {
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
ui.label(tl!("Add tools to hotbar"));
|
||||
ui.add(egui::TextEdit::singleline(&mut bar.search).hint_text(tl!("Search all tools…")));
|
||||
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
|
||||
ui.add_space(10.0);
|
||||
|
||||
// Section 2: Add Tools to Hotbar
|
||||
ui.label(egui::RichText::new(tl!("Add Tools to Hotbar")).size(13.0).strong().color(t.text));
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Search bar pill
|
||||
let search_frame = egui::Frame::NONE
|
||||
.fill(t.field)
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
.corner_radius(CornerRadius::same(8))
|
||||
.inner_margin(egui::Margin::symmetric(10, 6));
|
||||
search_frame.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
ui.add(icons::image("search", 14.0, t.text_faint));
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut bar.search)
|
||||
.hint_text(tl!("Search all tools…"))
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_width(ui.available_width()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Scrollable tools list with checkboxes
|
||||
let max_list_height = (screen.height() - 340.0).clamp(120.0, 220.0);
|
||||
egui::ScrollArea::vertical().max_height(max_list_height).auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 5.0;
|
||||
for spec in COMMANDS {
|
||||
if !matches_search(spec, &bar.search) {
|
||||
continue;
|
||||
@@ -299,9 +706,23 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
});
|
||||
ctx.data_mut(|data| data.insert_temp(id, shown.response.rect.size()));
|
||||
let clicked_outside = was_picker_open
|
||||
&& !settings_clicked
|
||||
&& picker_resp.as_ref().is_some_and(|wr| {
|
||||
wr.response.clicked_elsewhere()
|
||||
|| ctx.input(|i| i.pointer.any_click() && !i.pointer.interact_pos().is_some_and(|p| wr.response.rect.contains(p)))
|
||||
});
|
||||
if clicked_outside || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close_dialog = true;
|
||||
}
|
||||
if close_dialog {
|
||||
picker_open = false;
|
||||
}
|
||||
ctx.data_mut(|data| {
|
||||
data.insert_temp(id, shown.response.rect.size());
|
||||
data.insert_temp(egui::Id::unique("custom-hotbar"), shown.response.rect.size());
|
||||
});
|
||||
bar.picker_open = picker_open;
|
||||
app.hotbar = bar;
|
||||
if let Some(id) = run {
|
||||
@@ -358,6 +779,24 @@ mod tests {
|
||||
assert_eq!(app.hotbar.position, [0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotbar_restore_moves_legacy_default_into_margin_and_keeps_chosen_spots() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.restore(r#"{"hotbar":{"tools":["comment.ink"],"position":[0.5,0.0],"vertical":false,"button_size":40.0,"dock":null}}"#);
|
||||
assert_eq!(app.hotbar.position, [0.0, 0.0]);
|
||||
assert!(app.hotbar.vertical);
|
||||
assert!(!app.hotbar.placed);
|
||||
assert_eq!(app.hotbar.tools, ["comment.ink"]);
|
||||
assert_eq!(app.hotbar.button_size, 40.0);
|
||||
app.restore(r#"{"hotbar":{"position":[0.0,0.0],"vertical":false,"dock":null}}"#);
|
||||
assert_eq!(app.hotbar.position, [0.0, 0.0]);
|
||||
assert!(!app.hotbar.vertical);
|
||||
assert!(app.hotbar.placed, "a legacy bar dragged to the corner stays there");
|
||||
app.restore(r#"{"hotbar":{"position":[0.5,0.0],"vertical":false,"dock":null,"placed":true}}"#);
|
||||
assert_eq!(app.hotbar.position, [0.5, 0.0], "current preferences are not migrated");
|
||||
assert!(!app.hotbar.vertical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotbar_search_matches_translated_and_canonical_labels() {
|
||||
let ink = command("comment.ink").unwrap();
|
||||
|
||||
@@ -23,7 +23,8 @@ mod layout;
|
||||
pub(crate) use background::TextBackground;
|
||||
pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection, image_menu_id};
|
||||
pub(crate) use image::{
|
||||
ImageCx, Matrix, body_hit as picture_hit, handle_hit as picture_handle_hit, image_context_menu, image_input, quad_contains, user_to_screen,
|
||||
ImageCx, Matrix, body_hit as picture_hit, crop_controls_contain, handle_hit as picture_handle_hit, image_context_menu, image_input,
|
||||
quad_contains, user_to_screen,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use image::{rotate_knob, turn_degrees, turn_degrees_delta};
|
||||
|
||||
@@ -246,6 +246,29 @@ fn same_rect(a: [f64; 4], b: [f64; 4]) -> bool {
|
||||
a.into_iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3)
|
||||
}
|
||||
|
||||
fn crop_bounds(image: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect {
|
||||
let full = xf.user_rect(info, page, image.rect.map(|v| v as f32));
|
||||
if full.is_positive() { full } else { xf.user_rect(info, page, image.visible_rect.unwrap_or(image.rect).map(|v| v as f32)) }
|
||||
}
|
||||
|
||||
/// Whether `p` is on the crop box or one of its handles (#5835). The Done, Cancel and Remove
|
||||
/// Crop pills are widgets of their own, so they take their clicks before the canvas does.
|
||||
pub(crate) fn crop_controls_contain(
|
||||
image: &pdfcraft_engine::PageImage,
|
||||
mode: &CropMode,
|
||||
xf: &PageXform,
|
||||
info: &DocInfo,
|
||||
page: usize,
|
||||
p: Pos2,
|
||||
) -> bool {
|
||||
let b = crop_bounds(image, xf, info, page);
|
||||
let k = xf.user_rect(info, page, mode.keep.map(|v| v as f32)).intersect(b);
|
||||
if !b.is_finite() || !b.is_positive() || !b.size().is_finite() || !k.is_finite() || !k.is_positive() {
|
||||
return false;
|
||||
}
|
||||
k.contains(p) || crop_handles(k).iter().any(|h| h.distance(p) < 10.0)
|
||||
}
|
||||
|
||||
/// Crop mode for the selected image `i` whose visible frame is `b` (screen). Returns `true`
|
||||
/// (the pointer and keys are the crop's while it lasts).
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
@@ -254,8 +277,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e
|
||||
let painter = ui.painter();
|
||||
let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false };
|
||||
let hover_pos = ptr.pos;
|
||||
let full_b = xf.user_rect(info, page, image.rect.map(|v| v as f32));
|
||||
let b = if full_b.is_positive() { full_b } else { xf.user_rect(info, page, image.visible_rect.unwrap_or(image.rect).map(|v| v as f32)) };
|
||||
let b = crop_bounds(image, xf, info, page);
|
||||
let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b);
|
||||
let k = keep_screen(mode.keep);
|
||||
if !b.is_finite() || !b.is_positive() || !b.size().is_finite() || !k.is_finite() || !k.is_positive() {
|
||||
|
||||
@@ -3199,6 +3199,29 @@ fn hotbar_vertical_size_and_docking() {
|
||||
assert_ne!(saved["hotbar"]["position"], moved["hotbar"]["position"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotbar_settings_layout_and_side_controls_are_reachable_by_label() {
|
||||
let mut h = harness(|_| {});
|
||||
h.get_by_label("Hotbar settings").click();
|
||||
h.run_steps(3);
|
||||
fn vertical(h: &Harness<'static, PdfCraftApp>) -> serde_json::Value {
|
||||
serde_json::from_str::<serde_json::Value>(&h.state().persist()).unwrap()["hotbar"]["vertical"].clone()
|
||||
}
|
||||
let before = vertical(&h);
|
||||
h.get_by_role_and_label(Role::Switch, "Vertical layout").click();
|
||||
h.run_steps(3);
|
||||
assert_ne!(vertical(&h), before, "the layout switch toggles by its label");
|
||||
h.get_by_role_and_label(Role::RadioButton, "Right").click();
|
||||
h.run_steps(3);
|
||||
let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap();
|
||||
assert_eq!(saved["hotbar"]["dock"], "right");
|
||||
assert_eq!(saved["hotbar"]["vertical"], true, "docking at the side lays the bar out vertically");
|
||||
h.get_by_role_and_label(Role::RadioButton, "Float").click();
|
||||
h.run_steps(3);
|
||||
let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap();
|
||||
assert!(saved["hotbar"]["dock"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotbar_settings_reopen_even_with_remembered_collapsed_state() {
|
||||
let mut h = harness(|_| {});
|
||||
@@ -3246,5 +3269,113 @@ fn hotbar_full_side_strip_spans_each_edge_and_returns_to_float() {
|
||||
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:?}");
|
||||
let (length, thickness) = if size.y > size.x { (size.y, size.x) } else { (size.x, size.y) };
|
||||
assert!(thickness <= 64.0 && length < 550.0, "floating toolbar is compact: {size:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotbar_docked_left_does_not_override_quick_tools() {
|
||||
let mut h = harness(|app| {
|
||||
app.set_option("hotbar-side", "left").unwrap();
|
||||
});
|
||||
h.run_steps(5);
|
||||
// Controls in the docked bar share one layout, so none may overlap (#5890).
|
||||
let labels = ["Drag hotbar", "Select (V)", "Hand (H)", "Add a sticky note", "Highlight", "Draw", "Fill & Sign", "Hotbar settings"];
|
||||
let rects: Vec<_> = labels.iter().map(|&label| h.get_by_label(label).rect()).collect();
|
||||
for (i, a) in rects.iter().enumerate() {
|
||||
for (j, b) in rects.iter().enumerate().skip(i + 1) {
|
||||
let overlap = a.min.x < b.max.x && b.min.x < a.max.x && a.min.y < b.max.y && b.min.y < a.max.y;
|
||||
assert!(!overlap, "{} overlaps {}: {a:?} / {b:?}", labels[i], labels[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotbar_docked_in_compact_window_keeps_settings_button_visible() {
|
||||
for side in ["left", "right"] {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(800.0, 480.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.set_option("hotbar-side", side).unwrap();
|
||||
app.set_option("hotbar-size", "48").unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(5);
|
||||
let settings_rect = h.get_by_label("Hotbar settings").rect();
|
||||
assert!(settings_rect.max.y <= 480.0, "{side} hotbar settings button must stay within window height: {settings_rect:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotbar_settings_menu_closes_on_click_outside() {
|
||||
let mut h = harness(|app| {
|
||||
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
|
||||
});
|
||||
h.get_by_label("Hotbar settings").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Close menu").is_some());
|
||||
|
||||
// Clicking outside on the canvas dismisses the settings menu
|
||||
h.event(egui::Event::PointerButton {
|
||||
pos: egui::pos2(600.0, 300.0),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: true,
|
||||
modifiers: Default::default(),
|
||||
});
|
||||
h.event(egui::Event::PointerButton {
|
||||
pos: egui::pos2(600.0, 300.0),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: Default::default(),
|
||||
});
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Close menu").is_none(), "clicking outside closes the hotbar settings menu");
|
||||
|
||||
// Reopening and clicking another tool outside dismisses the menu and activates that tool
|
||||
h.get_by_label("Hotbar settings").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Close menu").is_some());
|
||||
h.get_by_label("Hand (H)").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Close menu").is_none());
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Hand);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotbar_picker_in_narrow_viewport_fits_within_screen_width() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(270.0, 480.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.get_by_label("Hotbar settings").click();
|
||||
h.run_steps(5);
|
||||
let close_btn = h.get_by_label("Close menu");
|
||||
assert!(close_btn.rect().max.x <= 270.0, "Close menu button must stay within viewport width: {:?}", close_btn.rect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotbar_floating_picker_does_not_overlap_rendered_bar() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1000.0, 600.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
|
||||
app
|
||||
});
|
||||
let settings_btn = h.get_by_label("Hotbar settings");
|
||||
let settings_rect = settings_btn.rect();
|
||||
settings_btn.click();
|
||||
h.run_steps(5);
|
||||
|
||||
let picker_close = h.get_by_label("Close menu");
|
||||
let picker_rect = picker_close.rect();
|
||||
assert!(
|
||||
picker_rect.min.x >= settings_rect.max.x || picker_rect.max.x <= settings_rect.min.x,
|
||||
"picker ({:?}) must not overlap the settings button ({:?}) on the floating bar",
|
||||
picker_rect,
|
||||
settings_rect
|
||||
);
|
||||
}
|
||||
@@ -7884,6 +7884,254 @@ fn test_image_context_menu_precedence() {
|
||||
assert!(h.query_all_by_label("Take a snapshot").next().is_none(), "canvas background menu must not trigger");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_context_menu_in_crop_mode_and_when_moved() {
|
||||
let mut png = Vec::new();
|
||||
image::RgbImage::from_pixel(100, 100, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("picture.png", None, png.clone()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
let id = h.state().views[0].id;
|
||||
// Shrink and center the picture on the page so page margins exist around it
|
||||
let full = h.state().session.get(id).unwrap().page_images(0)[0].rect;
|
||||
let (qw, qh) = ((full[2] - full[0]) / 4.0, (full[3] - full[1]) / 4.0);
|
||||
let (mx, my) = ((full[0] + full[2]) / 2.0, (full[1] + full[3]) / 2.0);
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage {
|
||||
page: 0,
|
||||
index: 0,
|
||||
change: pdfcraft_engine::ImageEdit::Move([mx - qw, my - qh, mx + qw, my + qh]),
|
||||
});
|
||||
h.run_steps(4);
|
||||
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(2);
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
|
||||
let c = r.center();
|
||||
|
||||
// Select the image
|
||||
h.hover_at(c);
|
||||
h.run_steps(1);
|
||||
h.drag_at(c);
|
||||
h.run_steps(1);
|
||||
h.drop_at(c);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().views[0].image_selection.is_some(), "selected");
|
||||
|
||||
// Enter crop mode:
|
||||
let doc_id = h.state().views[0].id;
|
||||
let original_rect = h.state().session.get(doc_id).unwrap().page_images(0)[0].rect;
|
||||
h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new(original_rect));
|
||||
h.run_steps(2);
|
||||
|
||||
// Set a smaller crop box that is moved towards top-left
|
||||
let moved_crop = egui::Rect::from_center_size(c - egui::vec2(5.0, 5.0), egui::vec2(20.0, 20.0));
|
||||
set_crop_box(&mut h, moved_crop);
|
||||
h.run_steps(2);
|
||||
|
||||
// Simulate secondary click on the moved crop box
|
||||
let click_pos = moved_crop.center();
|
||||
h.hover_at(click_pos);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: click_pos, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
|
||||
h.event(egui::Event::PointerButton { pos: click_pos, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
|
||||
// In crop mode and when moved, do not open right click window when clicked on the image (#5835)
|
||||
assert!(h.query_all_by_label("Rotate Clockwise").next().is_none(), "image context menu must not trigger in crop mode");
|
||||
assert!(h.query_all_by_label("Flip horizontal").next().is_none(), "image context menu must not trigger in crop mode");
|
||||
assert!(h.query_all_by_label("Rotate view clockwise").next().is_none(), "canvas background menu must not trigger in crop mode");
|
||||
assert!(h.query_all_by_label("Add bookmark").next().is_none(), "canvas background menu must not trigger in crop mode");
|
||||
assert!(h.query_all_by_label("Take a snapshot").next().is_none(), "canvas background menu must not trigger in crop mode");
|
||||
assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_some()), "crop mode must remain active after right click on image");
|
||||
// Action pills remain visible and interactive
|
||||
assert!(h.query_by_label("Done").is_some());
|
||||
assert!(h.query_by_label("Cancel").is_some());
|
||||
|
||||
// Right clicking outside the image on the page exits crop mode and opens the canvas background menu
|
||||
let outside_pos = r.left_top() + egui::vec2(10.0, 10.0);
|
||||
h.hover_at(outside_pos);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: outside_pos, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
|
||||
h.event(egui::Event::PointerButton { pos: outside_pos, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
|
||||
assert!(h.state().views[0].image_selection.is_none(), "clicking outside clears image selection");
|
||||
assert!(h.query_all_by_label("Rotate view clockwise").next().is_some(), "canvas background menu triggers outside image");
|
||||
}
|
||||
|
||||
fn crop_secondary_click(h: &mut Harness<'static, PdfCraftApp>, p: egui::Pos2) {
|
||||
assert!(h.state().views[0].viewport_rect().contains(p), "test point must be visible: {p:?}");
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn assert_crop_context_preserved(h: &Harness<'static, PdfCraftApp>) {
|
||||
let selection = h.state().views[0].image_selection.as_ref().expect("crop remains selected");
|
||||
assert_eq!(selection.index, 0, "an overlapping image must not steal the crop");
|
||||
assert!(selection.crop.is_some(), "crop mode remains active");
|
||||
for label in ["Flip horizontal", "Rotate view clockwise", "Take a snapshot"] {
|
||||
assert!(h.query_all_by_label(label).next().is_none(), "unexpected menu: {label}");
|
||||
}
|
||||
assert!(h.query_by_label("Done").is_some());
|
||||
assert!(h.query_by_label("Cancel").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_context_menu_crop_overlapping_image_and_handle() {
|
||||
for rotation in [0, 90, 180, 270] {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("overlap.pdf", None, two_image_fixture()).unwrap();
|
||||
app.views[0].rotation = rotation;
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
let id = h.state().views[0].id;
|
||||
let rect = h.state().session.get(id).unwrap().page_images(0)[0].rect;
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 1, change: pdfcraft_engine::ImageEdit::Move(rect) });
|
||||
let images = h.state().session.get(id).unwrap().page_images(0);
|
||||
assert_eq!(images.len(), 2);
|
||||
assert_eq!(images[0].rect, images[1].rect, "fixture images must overlap");
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(4);
|
||||
let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0);
|
||||
selection.crop = Some(pdfcraft_ui_egui::CropMode::new(rect));
|
||||
h.state_mut().views[0].image_selection = Some(selection);
|
||||
h.run_steps(2);
|
||||
let xf = h.state().views[0].page_xform(0).unwrap();
|
||||
let doc = h.state().session.get(id).unwrap();
|
||||
let image_rect = pdfcraft_ui_egui::canvas::image_screen_rect(&doc.page_images(0)[0], &xf, &doc.info, 0);
|
||||
crop_secondary_click(&mut h, image_rect.center());
|
||||
assert_crop_context_preserved(&h);
|
||||
// This point is outside both images, but inside the left handle's grip.
|
||||
crop_secondary_click(&mut h, image_rect.left_center() - egui::vec2(5.0, 0.0));
|
||||
assert_crop_context_preserved(&h);
|
||||
crop_secondary_click(&mut h, image_rect.left_center() - egui::vec2(20.0, 0.0));
|
||||
assert!(h.state().views[0].image_selection.is_none());
|
||||
assert!(h.query_by_label("Rotate view clockwise").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_context_menu_crop_page_edge_pills() {
|
||||
for rotation in [0, 90, 180, 270] {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("edge.pdf", None, two_image_fixture()).unwrap();
|
||||
app.views[0].rotation = rotation;
|
||||
app.views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page;
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
// A small image at a page corner exercises horizontal clamping and above placement.
|
||||
let rect = [1.0, 1.0, 21.0, 21.0];
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change: pdfcraft_engine::ImageEdit::Move(rect) });
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage {
|
||||
page: 0,
|
||||
index: 1,
|
||||
change: pdfcraft_engine::ImageEdit::Move([100.0, 150.0, 120.0, 170.0]),
|
||||
});
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(4);
|
||||
let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0);
|
||||
selection.crop = Some(pdfcraft_ui_egui::CropMode::new([2.0, 2.0, 20.0, 20.0]));
|
||||
h.state_mut().views[0].image_selection = Some(selection);
|
||||
h.run_steps(2);
|
||||
for label in ["Done", "Cancel", "Remove Crop"] {
|
||||
let p = h.get_by_label(label).rect().center();
|
||||
crop_secondary_click(&mut h, p);
|
||||
assert_crop_context_preserved(&h);
|
||||
}
|
||||
// Beyond egui's 5-point interaction radius, empty space beside the rightmost pill is canvas.
|
||||
let beside = h.get_by_label("Done").rect().right_center() + egui::vec2(8.0, 0.0);
|
||||
let view = &h.state().views[0];
|
||||
let doc = h.state().session.get(view.id).unwrap();
|
||||
let xf = view.page_xform(0).unwrap();
|
||||
assert!(
|
||||
doc.page_images(0).iter().all(|img| !pdfcraft_ui_egui::canvas::image_screen_rect(img, &xf, &doc.info, 0).contains(beside)),
|
||||
"background test point must miss both images"
|
||||
);
|
||||
crop_secondary_click(&mut h, beside);
|
||||
assert!(
|
||||
h.state().views[0].image_selection.is_none(),
|
||||
"empty pill-adjacent space, rotation={rotation}, point={beside:?}, viewport={:?}, selection={:?}",
|
||||
h.state().views[0].viewport_rect(),
|
||||
h.state().views[0].image_selection
|
||||
);
|
||||
assert!(h.query_by_label("Rotate view clockwise").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rotated_image_crop_secondary_click_in_outer_bounding_box_clears_selection() {
|
||||
let mut png = Vec::new();
|
||||
image::RgbImage::from_pixel(80, 80, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("picture.png", None, png.clone()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
let id = h.state().views[0].id;
|
||||
let full = h.state().session.get(id).unwrap().page_images(0)[0].rect;
|
||||
let (qw, qh) = ((full[2] - full[0]) / 4.0, (full[3] - full[1]) / 4.0);
|
||||
let (mx, my) = ((full[0] + full[2]) / 2.0, (full[1] + full[3]) / 2.0);
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage {
|
||||
page: 0,
|
||||
index: 0,
|
||||
change: pdfcraft_engine::ImageEdit::Move([mx - qw, my - qh, mx + qw, my + qh]),
|
||||
});
|
||||
h.run_steps(4);
|
||||
// Rotate the image by 45°
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change: pdfcraft_engine::ImageEdit::RotateBy(45.0) });
|
||||
h.run_steps(4);
|
||||
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(2);
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
|
||||
let c = r.center();
|
||||
|
||||
// Select the image and put it into crop mode
|
||||
h.hover_at(c);
|
||||
h.run_steps(1);
|
||||
h.drag_at(c);
|
||||
h.run_steps(1);
|
||||
h.drop_at(c);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().views[0].image_selection.is_some(), "selected");
|
||||
|
||||
let doc_id = h.state().views[0].id;
|
||||
let img = &h.state().session.get(doc_id).unwrap().page_images(0)[0];
|
||||
let (cx, cy) = ((full[0] + full[2]) / 2.0, (full[1] + full[3]) / 2.0);
|
||||
let shrunk_crop = [cx - 5.0, cy - 5.0, cx + 5.0, cy + 5.0];
|
||||
h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new(shrunk_crop));
|
||||
h.run_steps(2);
|
||||
|
||||
let xf = h.state().views[0].page_xform(0).unwrap();
|
||||
let doc_info = &h.state().session.get(doc_id).unwrap().info;
|
||||
let b = pdfcraft_ui_egui::canvas::image_screen_rect(img, &xf, doc_info, 0);
|
||||
|
||||
// The top-left corner of the axis-aligned bounding box `b` is outside the 45°-rotated diamond quad
|
||||
let corner = b.left_top() + egui::vec2(2.0, 2.0);
|
||||
assert!(b.contains(corner), "corner point is inside screen bounding box");
|
||||
|
||||
// Right-clicking at the corner must clear the crop selection and show the canvas context menu (#5900 review)
|
||||
crop_secondary_click(&mut h, corner);
|
||||
assert!(h.state().views[0].image_selection.is_none(), "clicking in outer bounding box of rotated image clears crop selection");
|
||||
assert!(h.query_by_label("Rotate view clockwise").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_action_pills_interactive_and_apply_cancel_reset() {
|
||||
let mut png = Vec::new();
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
# PdfCraft roadmap: milestones and what's next
|
||||
|
||||
> **2026-11-03, M7 / #5835 review:** active image crops retain secondary clicks over overlapping images; crop controls share measured layout with hit detection. Rotation, handle and page-edge regressions added; focused tests, scoped Clippy, formatting, layering and asset checks passed locally. Estimates unchanged.
|
||||
|
||||
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged)
|
||||
@@ -225,7 +227,7 @@ total matches the parity estimate.
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Done since: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Done since: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. Done since: #5835 a right-click on an image in crop mode keeps the crop and opens no menu, also over overlapping and rotated images. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
|
||||
|
||||
@@ -1813,6 +1813,12 @@ evidence = [
|
||||
"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",
|
||||
"crates/ui-egui/tests/it/comments.rs::hotbar_docked_left_does_not_override_quick_tools",
|
||||
"crates/ui-egui/tests/it/comments.rs::hotbar_docked_in_compact_window_keeps_settings_button_visible",
|
||||
"crates/ui-egui/tests/it/comments.rs::hotbar_settings_menu_closes_on_click_outside",
|
||||
"crates/ui-egui/tests/it/comments.rs::hotbar_picker_in_narrow_viewport_fits_within_screen_width",
|
||||
"crates/ui-egui/tests/it/comments.rs::hotbar_floating_picker_does_not_overlap_rendered_bar",
|
||||
"crates/ui-egui/tests/it/comments.rs::hotbar_settings_layout_and_side_controls_are_reachable_by_label",
|
||||
]
|
||||
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."
|
||||
|
||||
|
||||
Reference in new issue
Block a user