Add editable page-range selection to Organize Pages (#1092) (#1174)

* #1092: Add editable Organize page-range selection

Generated with Codex.

* #1092: Leave overlapping main CI cleanup to PR #1167

Generated with Codex.

---------

Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
DWAA1660andBrandon Thomas authored and GitHub committed 2026-10-12 01:25:38 -04:00
1 parent 84a75eae1b
commit c52072b678
9 files changed
+229 -7

No files matched your search

+2
View File
@@ -154,6 +154,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
Newest first: the date, what moved, and the new overall estimate.
- 2026-10-11: Organize Pages shows an editable, synchronized physical page-range selection (#1092), with bounded validation and UI/operation regression coverage; overall estimate unchanged.
- **2026-10-11 (M5.7, scanned signatures, #1110):** Fill & Sign ▸ Add signature has a Scan tab: a phone photo of a signature on paper is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn, the photo turned, and an ink-sensitivity slider picks up faint ink. A morphological paper estimate removes shadows and uneven light, and the trimmed ink is saved and reused like an image signature. Photos and images can also be dropped on the Image and Scan tabs. Photos keep the image-signature limits (#1124: a 4 MiB file, up to 32 megapixels decoded, scaled down to 4 megapixels), so a scan never processes more than 4 megapixels. `fill_sign_add` takes `scan` and `sensitivity`. Synthetic engine, UI and automation tests; checked by hand on real phone photos. Milestone and effort estimates unchanged.
- **2026-10-12 (M10.5, hosted CI):** Integrated current main, including its date-table formatting and static-storage fixes. The Arabic synthetic drawing regression now checks the original serialized-output golden with and without the optional font face: the face-dependent FontName and FontFamily are checked separately and normalized only in the fixture. The hosted FreeBSD fingerprint is reproduced by those two metadata fields alone. Drawing bytes and all other fields retain the original golden; no printing behavior or readiness estimate changes.
- **2026-10-11 (M10.5):** Extracted printer discovery, options and PDF submission into craft-libs/craft-print, shared with DesignCraft. Windows preserves composed sheet size and orientation without fitting into default margins; duplex binding edges are corrected. PdfCraft retains permission checks and imposition. Shared transport and local composition, engine, automation and UI coverage validate the boundary; native Windows CI passes, including paused-queue submission and cleanup; physical printer fidelity remains a hardware check. Rust 1.95 is required by the shared crate. No readiness percentage change.
+12
View File
@@ -384,6 +384,7 @@ pub struct DocView {
pub(crate) auto_scroll: crate::autoscroll::AutoScroll,
/// Pages selected in the organize grid or the Pages panel (0-based). Empty means "the current page".
pub selected: BTreeSet<usize>,
organize_range: crate::page_selection_ui::PageSelectionInput,
/// Anchor for ⇧-click range selection in the organize grid.
select_anchor: Option<usize>,
/// An edit requested by the view (organize toolbar, keys), applied by the app this frame.
@@ -626,6 +627,7 @@ impl DocView {
auto_scroll: Default::default(),
selected: BTreeSet::new(),
select_anchor: None,
organize_range: Default::default(),
pending_edit: None,
edit_lines: HashMap::new(),
line_editor: None,
@@ -731,6 +733,10 @@ impl DocView {
}
/// Select pages in the organize grid (tests, automation). Empty clears the selection.
pub(crate) fn page_count(&self) -> usize {
self.page_count
}
pub fn select_pages(&mut self, pages: &[usize]) {
self.selected = pages.iter().copied().filter(|p| *p < self.page_count).collect();
if let Some(first) = self.selected.first() {
@@ -3875,6 +3881,12 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) {
/// The organize toolbar: page operations on the selection (Acrobat's Organize Pages bar).
fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, unobstructed: bool, ui: &mut egui::Ui, t: &Tokens) {
egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(16, 4)).show(ui, |ui| {
let pages = ui.add_enabled_ui(unobstructed, |ui| view.organize_range.show(ui, &view.selected, info.pages.len())).inner;
if let Some(pages) = pages {
view.select_pages(&pages);
}
});
let targets = view.target_pages();
let n = info.pages.len();
let (first, last) = (targets.first().copied().unwrap_or(0), targets.last().copied().unwrap_or(0));
+3
View File
@@ -29,6 +29,9 @@
//! - `ui.type {text}`, `ui.key {key, modifiers?}`: keyboard input to the focused widget / app.
//! - `ui.command {id}`: run a registry command (as the menu would). `ui.commands` lists them.
//! - `ui.set {key, value}`: view options (`--page`, `--zoom`, …), plus persistent preferences.
//! `select` accepts physical page numbers and ranges such as `1,12,16-18`; blank clears the
//! explicit selection. This is the same bounded parser as the Organize Pages field; invalid
//! input leaves the previous selection unchanged. `ui.state.active.selected_pages` reports it.
//! `default-mode=all|read|edit|convert|sign` applies to future PDF opens; `mode` overrides it
//! for this session without changing the saved preference. `ui.state.default_mode` reports it.
//! `theme=light|dark|system` changes the saved theme preference; `ui.state.theme_preference`
+4 -3
View File
@@ -82,6 +82,7 @@ mod home;
mod icon_data;
pub mod icons;
pub mod last_session;
mod page_selection_ui;
mod pageboxes;
mod palette;
mod panels;
@@ -1999,9 +2000,9 @@ impl PdfCraftApp {
}
("fields", Some(v)) => v.highlight_fields = value != "off",
("select", Some(v)) => {
// `--select 2,3,5` (1-based) selects pages in the organize grid.
let pages: Result<Vec<usize>, _> = value.split(',').map(|p| p.trim().parse::<usize>().map(|n| n.saturating_sub(1))).collect();
v.select_pages(&pages.map_err(|_| "select: comma-separated page numbers")?);
// The same physical page ranges as the Organize Pages selection field.
let pages = crate::page_selection_ui::parse(value, v.page_count()).map_err(|e| e.to_string())?;
v.select_pages(&pages);
}
("notice", Some(v)) => v.notice_dismissed = value == "off",
("quick", _) => {
+124
View File
@@ -0,0 +1,124 @@
//! The Organize Pages selection, as an editable list of physical page numbers.
use std::collections::BTreeSet;
use pdfcraft_engine::print::PrintError;
#[derive(Default)]
pub(crate) struct PageSelectionInput {
text: String,
selection: BTreeSet<usize>,
error: Option<String>,
}
/// Blank clears the explicit selection. Repeated/reversed ranges become a sorted set.
/// Bound both the input and expansion work, including repeated overlapping ranges.
pub(crate) fn parse(text: &str, count: usize) -> Result<Vec<usize>, PrintError> {
if text.len() > 16_384 {
return Err(PrintError::NotAPage(text.chars().take(80).collect()));
}
if text.trim().is_empty() {
return Ok(Vec::new());
}
let mut pages = BTreeSet::new();
let mut budget = 1_000_000usize;
let number = |token: &str| {
let n = token.trim().parse::<usize>().map_err(|_| PrintError::NotAPage(token.into()))?;
if n == 0 || n > count { Err(PrintError::OutOfRange(n, count)) } else { Ok(n - 1) }
};
for part in text.split([',', ';']).map(str::trim) {
let (a, b) = if let Some((a, b)) = part.split_once('-') {
(number(a)?, number(b)?)
} else {
let p = number(part)?;
(p, p)
};
let (a, b) = (a.min(b), a.max(b));
let size = b.saturating_sub(a).saturating_add(1);
budget = budget.checked_sub(size).ok_or_else(|| PrintError::NotAPage(part.into()))?;
pages.extend(a..=b);
}
Ok(pages.into_iter().collect())
}
fn formatted(pages: &BTreeSet<usize>) -> String {
let mut parts = Vec::new();
let mut it = pages.iter().copied().peekable();
while let Some(first) = it.next() {
let mut last = first;
while it.peek().is_some_and(|p| last.checked_add(1) == Some(*p)) {
if let Some(next) = it.next() {
last = next;
}
}
let first = first.saturating_add(1);
let last = last.saturating_add(1);
parts.push(if first == last { first.to_string() } else { format!("{first}-{last}") });
}
parts.join(", ")
}
impl PageSelectionInput {
pub(crate) fn show(&mut self, ui: &mut egui::Ui, selected: &BTreeSet<usize>, count: usize) -> Option<Vec<usize>> {
if self.selection != *selected {
self.selection.clone_from(selected);
self.text = formatted(selected);
self.error = None;
}
let mut commit = false;
let id = ui.make_persistent_id("organize-page-range");
// Egui can surrender focus before this frame's widgets run on Escape.
let cancel = ui.is_enabled()
&& ui.memory(|m| m.has_focus(id) || m.had_focus_last_frame(id))
&& ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape));
if cancel {
self.text = formatted(selected);
self.error = None;
ui.memory_mut(|m| m.surrender_focus(id));
}
ui.horizontal(|ui| {
let label = tl!("Selected pages");
ui.label(label);
let field = ui.add(egui::TextEdit::singleline(&mut self.text).id(id).desired_width(300.0).char_limit(16_384).hint_text("1, 12, 16-18"));
field.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, ui.is_enabled(), label));
let apply = ui.button(tl!("Apply")).clicked();
commit = ui.is_enabled() && !cancel && (field.lost_focus() || apply);
});
if cancel {
self.text = formatted(selected);
self.error = None;
} else if commit {
match parse(&self.text, count) {
Ok(pages) => {
self.selection = pages.iter().copied().collect();
self.text = formatted(&self.selection);
self.error = None;
return Some(pages);
}
Err(e) => self.error = Some(e.said(&|s| crate::i18n::t(s).to_string())),
}
}
if let Some(error) = &self.error {
ui.label(error);
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selection_ranges_are_sorted_unique_and_bounded() {
let pages = parse("1,12,16-18,89,92,93,97,99", 100).unwrap();
assert_eq!(pages, [0, 11, 15, 16, 17, 88, 91, 92, 96, 98]);
assert_eq!(formatted(&pages.into_iter().collect()), "1, 12, 16-18, 89, 92-93, 97, 99");
assert_eq!(parse("3-1,2", 3).unwrap(), [0, 1, 2]);
assert!(parse(" ", 0).unwrap().is_empty());
for text in ["0", "101", "1,", "x", "1--3", "18446744073709551616", "1-100000000"] {
assert!(parse(text, 100).is_err(), "{text}");
}
assert!(parse(&"1,".repeat(10_000), 100).is_err());
assert!(parse("1-1000000,1-1000000", 1_000_000).is_err());
}
}
+6
View File
@@ -990,6 +990,12 @@ fn state_reports_the_selected_pages() {
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["active"]["selected_pages"], json!([]));
ok(&mut h, &c, "ui.set", json!({ "key": "select", "value": "2,4" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["active"]["selected_pages"], json!([2, 4]));
ok(&mut h, &c, "ui.set", json!({ "key": "select", "value": "4-2,3" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["active"]["selected_pages"], json!([2, 3, 4]));
assert!(call(&mut h, &c, "ui.set", json!({ "key": "select", "value": "0,6" })).is_err());
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["active"]["selected_pages"], json!([2, 3, 4]));
ok(&mut h, &c, "ui.set", json!({ "key": "select", "value": "" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["active"]["selected_pages"], json!([]));
}
#[test]
+74 -1
View File
@@ -35,7 +35,11 @@ fn fixture(n: usize) -> Vec<u8> {
}
fn harness(pages: usize, setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut builder = Harness::builder().with_size(egui::vec2(1400.0, 900.0));
if std::env::var_os("PDFCRAFT_ORGANIZE_SNAPSHOT").is_some() {
builder = builder.wgpu();
}
let mut h = builder.build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, fixture(pages)).expect("fixture opens");
@@ -50,6 +54,75 @@ fn organize(pages: usize) -> Harness<'static, PdfCraftApp> {
harness(pages, |app| app.set_option("organize", "on").unwrap())
}
fn type_selection(h: &mut Harness<'static, PdfCraftApp>, text: &str) {
use egui::accesskit::Role;
h.get_by_role_and_label(Role::TextInput, "Selected pages").click();
h.run_steps(1);
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.get_by_role_and_label(Role::TextInput, "Selected pages").type_text(text);
h.run_steps(2);
}
#[test]
fn organize_page_range_tracks_thumbnails_and_applies_typed_selection() {
use egui::accesskit::Role;
let mut h = organize(100);
h.get_by_label("Select page 2").click();
h.run_steps(2);
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Selected pages").value().as_deref(), Some("2"));
h.get_by_label("Select page 3").click();
h.run_steps(2);
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Selected pages").value().as_deref(), Some("2-3"));
let old = h.state().views[0].selected.clone();
type_selection(&mut h, "1,12,16-18,89,92,93,97,99");
assert_eq!(h.state().views[0].selected, old, "typing is not committed until Enter or focus loss");
h.key_press(Key::Enter);
h.run_steps(2);
assert_eq!(h.state().views[0].target_pages(), [0, 11, 15, 16, 17, 88, 91, 92, 96, 98]);
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Selected pages").value().as_deref(), Some("1, 12, 16-18, 89, 92-93, 97, 99"));
if let Some(path) = std::env::var_os("PDFCRAFT_ORGANIZE_SNAPSHOT") {
h.render().unwrap().save(std::path::Path::new(&path)).unwrap();
}
h.get_by_label("Rotate clockwise").click();
h.run_steps(3);
let doc = h.state().session.get(h.state().views[0].id).unwrap();
for (i, page) in doc.info.pages.iter().enumerate() {
assert_eq!(page.rotation, if h.state().views[0].selected.contains(&i) { 90 } else { 0 });
}
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert!(h.state().session.get(h.state().views[0].id).unwrap().info.pages.iter().all(|p| p.rotation == 0));
}
#[test]
fn organize_page_range_rejects_invalid_input_and_escape_cancels_without_clearing() {
use egui::accesskit::Role;
let mut h = organize(5);
h.state_mut().set_option("select", "2-3").unwrap();
h.run_steps(2);
for text in ["0", "6", "1,", "no"] {
type_selection(&mut h, text);
h.key_press(Key::Enter);
h.run_steps(2);
assert_eq!(h.state().views[0].target_pages(), [1, 2]);
}
type_selection(&mut h, "1-5");
h.key_press(Key::Escape);
h.run_steps(2);
assert_eq!(h.state().views[0].target_pages(), [1, 2]);
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Selected pages").value().as_deref(), Some("2-3"));
type_selection(&mut h, "5, 4, 4");
h.get_by_label("Apply").click();
h.run_steps(2);
assert_eq!(h.state().views[0].target_pages(), [3, 4]);
type_selection(&mut h, " ");
h.key_press(Key::Enter);
h.run_steps(2);
assert!(h.state().views[0].selected.is_empty());
assert!(h.state_mut().set_option("select", "0").is_err());
assert!(h.state_mut().set_option("select", "6").is_err());
}
#[test]
fn organize_tool_opens_the_overview_directly_and_can_be_reentered() {
let mut h = harness(12, |_| {});
+2 -1
View File
@@ -78,7 +78,7 @@ total matches the parity estimate.
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | 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), decoded-stream limits including unfiltered data, 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% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | 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<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | 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: Organize opens directly into the page overview, with checkbox selection and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), 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, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | 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: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), 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, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | 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<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | 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: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), 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, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–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 (partial imports preserve existing reply and review-status links); flatten; Fill & Sign; agent tools.<br>Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
@@ -140,6 +140,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | M4: editable Organize page-range selection (#1092), synchronized with thumbnails and available through UI control. Estimates unchanged. |
| 2026-10-11 | minor | M10: Word invoice export regressions (#773); estimates unchanged. |
| 2026-10-12 | minor | M2: short same-line redraws split before placement; estimates unchanged. |
| 2026-10-11 | minor | M13/#971: cap materialized text-search matches to the requested limit plus one continuation match. |
+2 -2
View File
@@ -2590,8 +2590,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", "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. Page checkboxes also toggle selection without modifiers (organize_checkboxes_toggle_multiple_pages_without_modifiers)."
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", "crates/ui-egui/tests/editing.rs::organize_page_range_tracks_thumbnails_and_applies_typed_selection", "crates/ui-egui/tests/editing.rs::organize_page_range_rejects_invalid_input_and_escape_cancels_without_clearing", "crates/ui-egui/tests/control.rs::state_reports_the_selected_pages"]
notes = "Ctrl+A / ⌘A selects every page in the Organize grid, preserving the current page; text inputs and dialogs keep their own keyboard input. Page checkboxes also toggle selection without modifiers (organize_checkboxes_toggle_multiple_pages_without_modifiers). An editable physical page-range list stays synchronized with thumbnails; Enter, focus loss or Apply commits valid input, Escape cancels, and invalid input preserves the selection. UI control select accepts the same bounded ranges."
[[feature]]
id = "organize.rotate-pages"