mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-26 02:58:32 -06:00
Select all pages with Ctrl+A in Organize Pages (#113)
* M4: select all pages in Organize with Ctrl+A Route Select all to the page grid in Organize while preserving viewer text selection and keyboard ownership for text inputs, dialogs and the palette. Keep the current page as the selection anchor for subsequent range selection. Co-Authored-By: Codex <noreply@openai.com> * Use the pdfcraft crate names after the rename Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Codex
Claude Opus 5.5
Brandon Thomas
parent
e15e5ebce0
commit
4454af3742
+3
-1
@@ -93,7 +93,7 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t
|
||||
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
|
||||
| M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches). Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window. |
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages. Missing: Replace pages, page transitions, recovery on the web |
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize. Missing: Replace pages, page transitions, recovery on the web |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 15–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools. Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters. Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M6 | Forms + JavaScript | 160–320 | 45% | 95–185 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, 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. 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 |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 17% | 210–420 | 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). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
@@ -125,6 +125,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
|
||||
|
||||
## Log
|
||||
|
||||
- **2026-10-06 (community contribution):** Ctrl+A / Cmd+A selects every page in the Organize grid, preserving the current page and leaving focused text inputs and dialogs alone. Regression coverage includes bulk rotation, restricted documents, selection anchors and the opt-in UI control channel; viewer text selection remains unchanged.
|
||||
|
||||
Newest first. One line per session: the date, what moved, and the new overall percentage.
|
||||
|
||||
- **2026-10-07 (session 16):** PrintCraft is renamed PdfCraft: repo `storytold/pdfcraft`, crates and binaries `pdfcraft*`, bundle id `ai.storyteller.pdfcraft`. On first launch the app moves the old PrintCraft settings and crash-recovery folders to the new name, so upgrades keep recent files, preferences and unsaved work. No feature change. ≈ 30–35%.
|
||||
|
||||
@@ -548,8 +548,14 @@ impl DocView {
|
||||
true
|
||||
}
|
||||
|
||||
/// Edit ▸ Select all: every word on the current page.
|
||||
/// Select all: every page in Organize, or every word on the current page.
|
||||
pub fn select_all(&mut self) -> bool {
|
||||
if self.organize {
|
||||
self.selected = (0..self.page_count).collect();
|
||||
// Keep the current page as the anchor for the next Shift-click.
|
||||
self.select_anchor = (self.page_count > 0).then_some(self.current);
|
||||
return !self.selected.is_empty();
|
||||
}
|
||||
let page = self.current;
|
||||
let Some(t) = self.texts.get(&page) else { return false };
|
||||
if t.glyphs.is_empty() {
|
||||
@@ -857,9 +863,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context) {
|
||||
if pressed(cmd(Key::G)) {
|
||||
view.find_step(true);
|
||||
}
|
||||
if pressed(cmd(Key::A)) {
|
||||
view.select_all();
|
||||
}
|
||||
if pressed(cmd(Key::OpenBracket)) {
|
||||
view.view_history(false);
|
||||
}
|
||||
@@ -2130,11 +2133,10 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, ui: &mut
|
||||
});
|
||||
// Keys act on the selection unless a text field has focus.
|
||||
if editable && !ui.ctx().egui_wants_keyboard_input() {
|
||||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||||
let (del, all, esc) = ui.input_mut(|i| {
|
||||
use egui::{Key, Modifiers};
|
||||
let (del, esc) = ui.input_mut(|i| {
|
||||
(
|
||||
i.consume_key(Modifiers::NONE, Key::Delete) || i.consume_key(Modifiers::NONE, Key::Backspace),
|
||||
i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::A)),
|
||||
i.consume_key(Modifiers::NONE, Key::Escape),
|
||||
)
|
||||
});
|
||||
@@ -2152,9 +2154,6 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, ui: &mut
|
||||
if del && targets.len() < n {
|
||||
view.pending_edit = Some(Edit::DeletePages { pages: targets });
|
||||
}
|
||||
if all {
|
||||
view.selected = (0..n).collect();
|
||||
}
|
||||
if esc {
|
||||
view.selected.clear();
|
||||
}
|
||||
|
||||
@@ -1175,6 +1175,15 @@ impl PdfCraftApp {
|
||||
self.set_full_screen(ctx, false);
|
||||
}
|
||||
if let Some(i) = self.active {
|
||||
// Select all belongs to the document or page grid, unless a text field or
|
||||
// overlay owns the keyboard. Other canvas shortcuts keep their own handling.
|
||||
if self.dialog.is_none()
|
||||
&& !self.palette_open
|
||||
&& !ctx.egui_wants_keyboard_input()
|
||||
&& ctx.input_mut(|input| input.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, Key::A)))
|
||||
{
|
||||
self.views[i].select_all();
|
||||
}
|
||||
canvas::shortcuts(&mut self.views[i], ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,17 @@ fn commands_keys_and_typing() {
|
||||
assert!(call(&mut h, &c, "ui.frobnicate", json!({})).unwrap_err().contains("unknown method"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_key_selects_every_page_in_organize() {
|
||||
let (mut h, c) = harness();
|
||||
ok(&mut h, &c, "ui.set", json!({ "key": "organize", "value": "on" }));
|
||||
ok(&mut h, &c, "ui.set", json!({ "key": "select", "value": "3" }));
|
||||
ok(&mut h, &c, "ui.key", json!({ "key": "A", "modifiers": ["command"] }));
|
||||
assert_eq!(h.state().views[0].target_pages(), [0, 1, 2, 3, 4]);
|
||||
assert_eq!(h.state().views[0].current, 2);
|
||||
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["dirty"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_a_comment_by_drag_and_its_context_menu() {
|
||||
let (mut h, c) = harness();
|
||||
|
||||
@@ -126,6 +126,111 @@ fn command_click_toggles_and_rotation_applies_to_selection() {
|
||||
assert_eq!(rot, [90, 0, 90]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn organize_select_all_shortcut_preserves_current_page_and_sets_range_anchor() {
|
||||
let mut h = organize(4);
|
||||
h.get_by_label("Page 3").click();
|
||||
h.run_steps(1);
|
||||
// Automation may move the current selection without a click's range anchor.
|
||||
h.state_mut().views[0].select_pages(&[1]);
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
|
||||
h.run_steps(2);
|
||||
h.get_by_label_contains("4 pages selected");
|
||||
assert_eq!(h.state().views[0].target_pages(), [0, 1, 2, 3]);
|
||||
assert_eq!(h.state().views[0].current, 1, "selecting all keeps the reader's place");
|
||||
assert!(!dirty(&h), "selection doesn't edit the document");
|
||||
|
||||
// Repeating Select all is harmless; deleting all pages still keeps the document intact.
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
|
||||
h.run_steps(1);
|
||||
h.key_press(Key::Delete);
|
||||
h.run_steps(2);
|
||||
assert_eq!(page_texts(h.state()).len(), 4);
|
||||
assert!(!dirty(&h));
|
||||
h.get_by_label("Page 4").click_modifiers(Modifiers::SHIFT);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views[0].target_pages(), [1, 2, 3], "Shift-click extends from the current page");
|
||||
h.get_by_label("Page 2").click_modifiers(Modifiers::COMMAND);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views[0].target_pages(), [2, 3], "command-click still toggles a page");
|
||||
h.key_press(Key::Escape);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().views[0].selected.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn organize_select_all_applies_operations_to_every_page() {
|
||||
let mut h = organize(3);
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views[0].target_pages(), [0, 1, 2]);
|
||||
h.get_by_label("Rotate clockwise").click();
|
||||
h.run_steps(3);
|
||||
let app = h.state();
|
||||
let rotations: Vec<_> = app.session.get(app.views[0].id).unwrap().info.pages.iter().map(|p| p.rotation).collect();
|
||||
assert_eq!(rotations, [90, 90, 90]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn organize_select_all_works_without_page_editing_permission() {
|
||||
let mut h = harness(3, |app| {
|
||||
app.apply_edit(pdfcraft_engine::Edit::Protect(pdfcraft_engine::Protection {
|
||||
permissions_password: Some("owner".into()),
|
||||
changes: pdfcraft_engine::Changes::None,
|
||||
..Default::default()
|
||||
}));
|
||||
let bytes = app.session.save_bytes(app.views[0].id).unwrap();
|
||||
app.open_bytes("restricted.pdf", None, bytes.as_ref().clone()).unwrap();
|
||||
app.set_option("organize", "on").unwrap();
|
||||
});
|
||||
let index = h.state().active.unwrap();
|
||||
assert!(!h.state().session.get(h.state().views[index].id).unwrap().allows_assembly());
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views[index].target_pages(), [0, 1, 2]);
|
||||
assert!(!h.state().session.get(h.state().views[index].id).unwrap().dirty);
|
||||
|
||||
let mut h = organize(1);
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views[0].target_pages(), [0]);
|
||||
assert_eq!(h.state().views[0].selected.len(), 1);
|
||||
|
||||
// The opener refuses zero-page PDFs, but the view method also handles empty geometry.
|
||||
let mut empty = pdfcraft_ui_egui::canvas::DocView::new(pdfcraft_engine::DocId(0), &Default::default());
|
||||
empty.organize = true;
|
||||
assert!(!empty.select_all());
|
||||
assert!(empty.selected.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn organize_select_all_leaves_focused_text_input_and_dialogs_alone() {
|
||||
let mut h = organize(3);
|
||||
h.query_all_by_value("1").next().expect("current page input").focus();
|
||||
h.run_steps(1);
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
|
||||
h.run_steps(1);
|
||||
h.query_all_by_value("1").next().expect("current page input").type_text("2");
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views[0].page_input, "2", "Ctrl/Cmd+A selected the field's text");
|
||||
assert!(h.state().views[0].selected.is_empty());
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views[0].current, 1);
|
||||
|
||||
h.state_mut().execute("help.about");
|
||||
h.run_steps(3);
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().views[0].selected.is_empty(), "a modal dialog isolates the page selection");
|
||||
h.state_mut().dialog = None;
|
||||
h.state_mut().execute("view.palette");
|
||||
h.run_steps(3);
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().views[0].selected.is_empty(), "the palette keeps its own keyboard input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_is_refused_when_it_would_remove_every_page() {
|
||||
let mut h = organize(2);
|
||||
|
||||
@@ -98,6 +98,11 @@ fn view_history_select_all_and_find_options() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("Page 4 pages PAGE"));
|
||||
h.state_mut().views[0].select_text(3, 0, 0);
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("Page 4 pages PAGE"), "the viewer shortcut still selects text");
|
||||
assert!(h.state().views[0].selected.is_empty(), "the viewer shortcut doesn't select pages");
|
||||
// Find: "page" matches three times a page; whole words, case-sensitive narrows it.
|
||||
h.state_mut().views[0].open_find();
|
||||
h.state_mut().views[0].find.as_mut().unwrap().query = "page".into();
|
||||
|
||||
@@ -2554,8 +2554,8 @@ milestone = "M4"
|
||||
status = "shipped"
|
||||
commands = ["page.organize"]
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/editing.rs::command_click_toggles_and_rotation_applies_to_selection", "crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it"]
|
||||
notes = ""
|
||||
evidence = ["crates/ui-egui/tests/editing.rs::command_click_toggles_and_rotation_applies_to_selection", "crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/editing.rs::organize_select_all_shortcut_preserves_current_page_and_sets_range_anchor", "crates/ui-egui/tests/control.rs::select_all_key_selects_every_page_in_organize"]
|
||||
notes = "Ctrl+A / ⌘A selects every page in the Organize grid, preserving the current page; text inputs and dialogs keep their own keyboard input."
|
||||
|
||||
[[feature]]
|
||||
id = "organize.rotate-pages"
|
||||
|
||||
Reference in New Issue
Block a user