M3: full screen shows one fitted page at a time, as Acrobat's Full Screen Mode (#1459)

Full screen is the page on black and nothing else: one page (or one spread,
when two-page view was on, #1353) fitted to the screen, from the current
page; a click, Space, Enter, the arrows and Page Up/Down turn pages, a
shift-click and Backspace go back; Esc brings the display from before
back at the page reached. The quick tools are not drawn in full screen.

Transitions, auto-advance, loop and the cursor hiding stay planned.

Refs #379.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
This commit is contained in:
mtlcl410
2026-10-19 15:02:20 +08:00
co-authored by Claude Fable 5.1
parent 43550c448d
commit 1e2e7cc79f
9 changed files with 208 additions and 27 deletions
+1
View File
@@ -249,6 +249,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-14 (M3, control-click candidate):** Coordinate clicks retain hover warmup and pair press/release in one input pass so slow frames do not turn a click into a hold. Text multi-click selection accepts the actual press event when egui has already cleared its origin. Regression cases cover slow-frame button activation, disabled controls and coalesced text selection; estimates unchanged.
- **2026-10-14 (community, M7, #128 Hebrew in Add text):** Add text and `page_add_text` draw Hebrew with the craft-fonts Hebr face (Noto Sans Hebrew), through the same shaped path as Arabic and Thai: right-to-left display order with numbers and Latin in place, points and cantillation kept on their letter as one Type 3 glyph, ToUnicode and per-line ActualText so extraction, search and the editor get typing order. Builds without the face refuse Hebrew rather than write `?`. Regular style only. Estimates unchanged.
- **2026-10-14 (M9, #1787):** Changes after signing are judged by the role an object held when signed for a page's resources too: a rewritten font, XObject, graphics state or resources dictionary is page content however its new version is labelled, so a `/FT` or `/Subtype /Widget` added by the attacker no longer reads as a permitted form fill. A category dictionary that only gained a name is judged as before.
- **2026-10-14 (M3, full screen as presentation, #379/#1459/#1353):** Full Screen Mode shows one page (or one spread) at a time fitted to the screen on black, with no tools; click, Space, Enter and the arrows turn pages; Esc restores the display from before. Transitions, auto-advance and loop remain planned.
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged.
- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged.
+79 -2
View File
@@ -81,6 +81,17 @@ pub enum Fit {
None,
}
/// The page display as it was before full screen, put back on leaving it
/// ([`DocView::enter_presentation`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SavedDisplay {
pub layout: PageLayout,
pub continuous: bool,
pub cover: bool,
pub fit: Fit,
pub zoom: f32,
}
/// The base page display: one page across, or a two-page spread. Whether it scrolls continuously
/// is a separate flag (`continuous`), as in Acrobat, so every combination is reachable (#752).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -414,6 +425,9 @@ pub struct DocView {
pub flash: Option<(usize, [f32; 4], f64)>,
/// Compare files: differences shaded on this document's pages (page, user-space box, colour).
pub compare_marks: Vec<(usize, [f32; 4], Color32)>,
/// Full screen (Acrobat's Full Screen Mode): the display saved on entering it, while the view
/// shows one page or spread at a time, fitted to the screen. `None` outside full screen.
presentation: Option<SavedDisplay>,
pages: HashMap<usize, PageTex>,
/// Pages the renderer could not draw, with the reason (never re-requested).
errors: HashMap<usize, String>,
@@ -723,6 +737,7 @@ impl DocView {
single_edges: None,
flash: None,
compare_marks: Vec::new(),
presentation: None,
pages: HashMap::new(),
errors: HashMap::new(),
waiting_since: HashMap::new(),
@@ -1372,6 +1387,37 @@ impl DocView {
self.reset_coarse_state();
}
/// Full screen, as Acrobat's Full Screen Mode (#379, #1459): one page at a time (or one
/// spread, when two-page view was on: #1353), fitted to the screen, from the current page.
/// The display it replaces is kept for [`Self::leave_presentation`]. A no-op while presenting.
pub fn enter_presentation(&mut self) {
if self.presentation.is_some() {
return;
}
self.presentation =
Some(SavedDisplay { layout: self.layout, continuous: self.continuous, cover: self.cover, fit: self.fit, zoom: self.zoom });
self.continuous = false;
self.fit = Fit::Page;
self.selection = None;
self.goto = Some((self.current, 0.0));
}
/// Leave full screen: the display from before it comes back, at the page reached meanwhile.
pub fn leave_presentation(&mut self) {
let Some(saved) = self.presentation.take() else { return };
self.layout = saved.layout;
self.continuous = saved.continuous;
self.cover = saved.cover;
self.fit = saved.fit;
self.zoom = saved.zoom;
self.goto = Some((self.current, 0.0));
}
/// Whether the view is in full screen's one-page-at-a-time display.
pub fn presenting(&self) -> bool {
self.presentation.is_some()
}
/// Switch the base page display (Single or Two-page), leaving continuous scrolling as it is.
/// A no-op when the layout is already current, so re-selecting it never yanks a scrolled view
/// back to the top.
@@ -2300,6 +2346,16 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
if key(Key::End) {
view.go_to_page(usize::MAX);
}
// Full screen: Space and Enter go forward a page and Backspace back, as in Acrobat (the
// arrows and Page Up / Page Down already turn pages where nothing scrolls, below).
if view.presentation.is_some() {
if key(Key::Space) || key(Key::Enter) {
view.step_page(true);
}
if key(Key::Backspace) {
view.step_page(false);
}
}
// As in Acrobat: → / ← go to the next / previous page in every layout (#185), and so do
// ⌘Page Down / ⌘Page Up, or plain Page Down / Page Up in single-page view.
let command = ctx.input(|i| i.modifiers.command);
@@ -2469,6 +2525,22 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// Exclude the previous frame's scrollbar tracks from gesture activation.
let navigation_rect = if view.viewport_screen.is_positive() { avail.intersect(view.viewport_screen) } else { avail };
let navigation_enabled = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open && ui.is_enabled() && ui.input(|i| i.focused);
// Full screen: a click on the document goes forward a page and a shift-click back, as in
// Acrobat, unless the click did something else (a link, a word selected). Decided below,
// once the pages have had the click.
let present_click = view.presentation.is_some()
&& navigation_enabled
&& ui.rect_contains_pointer(navigation_rect)
&& ui.input(|i| i.pointer.primary_clicked())
&& app.quick_tool == QuickTool::Select;
// Shift as held now, or as the click's own event carried it (a synthetic click).
let present_back = present_click
&& ui.input(|i| {
i.modifiers.shift
|| i.events.iter().any(|e| {
matches!(e, egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, modifiers, .. } if modifiers.shift)
})
});
if zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0 && navigation_enabled && ui.rect_contains_pointer(navigation_rect) {
let z = view.zoom * zoom_delta;
match ui.input(|i| i.pointer.hover_pos()) {
@@ -3953,6 +4025,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
Some(comments::CanvasAction::CopyImageFile(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::CopyFile(p, i)),
None => {}
}
// Full screen: the click turned the page unless it did something else first.
if present_click && clicked_link.is_none() && view.selection.is_none() && view.clicks < 2 {
view.step_page(!present_back);
}
match clicked_link {
Some(LinkTarget::Page(p, dest)) => view.go_to_dest(p, dest, info),
Some(LinkTarget::Uri(u)) => app.request_document_url(&u, crate::LinkOrigin::Link),
@@ -4071,8 +4147,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if copy_selected {
app.execute("edit.copy");
}
// Read mode hides the tools (#2646).
if app.mode != crate::Mode::Read {
// Read mode hides the tools (#2646), and full screen is the page and nothing else (#1459);
// the quick tools come back with the window.
if app.mode != crate::Mode::Read && !app.full_screen {
quick_bar(app, avail, ui);
}
if let Some((source, file, destination)) = remote_open {
+1 -1
View File
@@ -270,7 +270,7 @@ impl PdfCraftApp {
let on = !self.full_screen;
match self.ctx.clone() {
Some(ctx) => self.set_full_screen(&ctx, on),
None => self.full_screen = on,
None => self.apply_full_screen(on),
}
}
"view.read_mode" => self.switch_mode(if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read }, false),
+5 -1
View File
@@ -103,7 +103,11 @@ impl PdfCraftApp {
// The reading-colours preference is app-wide: the newly created document starts with it.
self.session.set_reading_colors(id, self.reading_colors);
let info = &self.session.get(id).ok_or("the new document could not be opened")?.info;
self.views.push(crate::DocView::new(id, info, self.view_defaults));
let mut view = crate::DocView::new(id, info, self.view_defaults);
if self.full_screen {
view.enter_presentation();
}
self.views.push(view);
self.active = Some(self.views.len() - 1);
Ok(())
}
+5 -1
View File
@@ -816,7 +816,11 @@ impl PdfCraftApp {
// The reading-colours preference is app-wide: the newly created document starts with it.
self.session.set_reading_colors(id, self.reading_colors);
let Some(doc) = self.session.get(id) else { return };
self.views.push(crate::DocView::new(id, &doc.info, self.view_defaults));
let mut view = crate::DocView::new(id, &doc.info, self.view_defaults);
if self.full_screen {
view.enter_presentation();
}
self.views.push(view);
self.active = Some(self.views.len() - 1);
self.notify_tr(message);
}
+29 -14
View File
@@ -1338,7 +1338,11 @@ impl PdfCraftApp {
};
}
let initial = doc.initial_view();
self.views.push(DocView::new(id, &doc.info, self.view_defaults));
let mut view = DocView::new(id, &doc.info, self.view_defaults);
if self.full_screen {
view.enter_presentation();
}
self.views.push(view);
if activate {
self.active = Some(self.views.len() - 1);
}
@@ -1582,10 +1586,24 @@ impl PdfCraftApp {
/// Enter or leave full-screen reading (Acrobat: View ▸ Full Screen Mode, ⌘L).
pub fn set_full_screen(&mut self, ctx: &egui::Context, on: bool) {
self.full_screen = on;
self.apply_full_screen(on);
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(on));
}
/// The full-screen state without the window: every document shows one page (or spread) at a
/// time fitted to the screen while it is on (Acrobat's Full Screen Mode, #379, #1459), and
/// gets its display back when it is off.
pub fn apply_full_screen(&mut self, on: bool) {
self.full_screen = on;
for view in &mut self.views {
if on {
view.enter_presentation();
} else {
view.leave_presentation();
}
}
}
/// Answer the Full Screen mode prompt a document raised on opening: enter it, or leave the
/// window as it is.
pub fn resolve_full_screen_prompt(&mut self, ctx: &egui::Context, allow: bool) {
@@ -3131,19 +3149,16 @@ impl eframe::App for PdfCraftApp {
self.window_title = title;
}
if self.full_screen && self.active.is_some() {
// Full screen: the page, nothing else (Esc or ⌘L to leave).
let t = theme::Tokens::get(&ctx);
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(if t.dark() { t.pasteboard } else { egui::Color32::from_gray(32) })).show(
ui,
|ui| {
if let Some(i) = self.active {
match compare_ui::side_by_side(self, i) {
Some((old, new)) => compare_ui::compare_area(self, old, new, ui),
None => canvas::document_area(self, i, ui),
}
// Full screen: the page on black, nothing else, as Acrobat's Full Screen Mode (Esc
// or ⌘L to leave).
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK)).show(ui, |ui| {
if let Some(i) = self.active {
match compare_ui::side_by_side(self, i) {
Some((old, new)) => compare_ui::compare_area(self, old, new, ui),
None => canvas::document_area(self, i, ui),
}
},
);
}
});
dialogs::show(self, &ctx);
// Notices too: a refused field value or a failed save must be seen in full screen.
self.show_progress(&ctx);
+79
View File
@@ -2,6 +2,7 @@
use egui::{Key, Modifiers, MouseWheelUnit, TouchPhase};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_ui_egui::PdfCraftApp;
/// Three 300×400 pt pages. Page 1 links to page 3.
@@ -1645,3 +1646,81 @@ fn long_view_menu_fits_a_short_window() {
h.run_steps(4);
assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks));
}
fn click_with(h: &mut Harness<'static, PdfCraftApp>, pos: egui::Pos2, modifiers: Modifiers) {
h.hover_at(pos);
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers });
}
h.run_steps(4);
}
/// Full screen is Acrobat's Full Screen Mode (#379, #1459): one page at a time, fitted to the
/// screen, with no tools; a click, Space, Enter or → go forward a page and a ⇧-click, Backspace
/// or ← back; Esc brings the display from before it back, at the page reached.
#[test]
fn full_screen_shows_one_fitted_page_and_clicks_turn_it() {
use pdfcraft_ui_egui::canvas::Fit;
let mut h = harness(&[("layout", "continuous"), ("zoom", "50")]);
h.run_steps(4);
assert!(h.state().views[0].continuous(), "continuous before");
h.key_press_modifiers(Modifiers::COMMAND, Key::L);
h.run_steps(6);
assert!(h.state().full_screen);
let v = &h.state().views[0];
assert!(!v.continuous(), "one page at a time");
assert_eq!(v.fit, Fit::Page, "fitted to the screen");
assert!(rect(&h, 0).is_some() && rect(&h, 1).is_none(), "only page 1 is on screen");
let r = rect(&h, 0).unwrap();
let vp = h.state().views[0].viewport_rect();
assert!(r.height() <= vp.height() + 1.0 && r.width() <= vp.width() + 1.0, "the page fits: {r:?} in {vp:?}");
assert!(h.query_by_label("Select (V)").is_none(), "no quick tools in full screen");
click_with(&mut h, r.center(), Modifiers::NONE);
assert_eq!(h.state().views[0].current, 1, "a click goes forward a page");
h.key_press(Key::ArrowRight);
h.run_steps(4);
assert_eq!(h.state().views[0].current, 2, "→ goes forward");
h.key_press(Key::Backspace);
h.run_steps(4);
assert_eq!(h.state().views[0].current, 1, "Backspace goes back");
let r = rect(&h, 1).expect("page 2 on screen");
click_with(&mut h, r.center(), Modifiers::SHIFT);
assert_eq!(h.state().views[0].current, 0, "a shift-click goes back");
h.key_press(Key::Space);
h.run_steps(4);
assert_eq!(h.state().views[0].current, 1, "Space goes forward");
h.key_press(Key::Escape);
h.run_steps(4);
assert!(!h.state().full_screen, "Escape leaves full screen");
let v = &h.state().views[0];
assert!(v.continuous(), "the display from before comes back");
assert_eq!(v.fit, Fit::None);
assert!((v.zoom - 0.5).abs() < 1e-3, "zoom back to 50%: {}", v.zoom);
assert_eq!(v.current, 1, "at the page reached in full screen");
h.run_steps(2);
assert!(h.query_by_label("Select (V)").is_some(), "the quick tools are back");
}
/// Two-page view stays a spread in full screen (#1353): both pages of the spread, fitted, and
/// → turns the spread.
#[test]
fn full_screen_keeps_two_page_view_as_spreads() {
use pdfcraft_ui_egui::canvas::PageLayout;
let mut h = harness(&[("layout", "two-up")]);
h.run_steps(4);
h.key_press_modifiers(Modifiers::COMMAND, Key::L);
h.run_steps(6);
let v = &h.state().views[0];
assert_eq!(v.layout, PageLayout::TwoUp);
assert!(!v.continuous(), "one spread at a time");
assert!(rect(&h, 0).is_some() && rect(&h, 1).is_some() && rect(&h, 2).is_none(), "the first spread only");
h.key_press(Key::ArrowRight);
h.run_steps(4);
assert_eq!(h.state().views[0].current, 2, "→ turns the spread");
assert!(rect(&h, 2).is_some() && rect(&h, 0).is_none());
h.key_press(Key::Escape);
h.run_steps(4);
let v = &h.state().views[0];
assert!(v.continuous() && v.layout == PageLayout::TwoUp, "two-page continuous view comes back");
}
+3 -2
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (column select #740, multi-object move #844, Shift-⌘-G #810 fixed) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (full screen presents one page at a time #1459) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
How PdfCraft's tools, panels, shortcuts and feel compare with Acrobat Pro's current interface (the
2023+ "new Acrobat": All tools pane on the left, quick-action toolbar, Home view). Behaviour is
@@ -31,7 +31,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24)
| Document tabs | yes | yes, with unsaved marker | shipped |
| Multiple windows, drag a tab out | yes | no | planned |
| Split window, spreadsheet split | yes | no | planned |
| Read mode, full screen | yes | yes | shipped; presentation options planned |
| Read mode, full screen | yes | yes; full screen is one fitted page at a time on black, click/keys turn pages (#1459) | shipped; auto-advance, loop and transitions planned |
| Light, dark, system theme | yes | yes | shipped |
| Side panels: thumbnails, bookmarks, attachments, layers, comments, fields, signatures | yes | yes; panels keep their width (#704) | shipped |
| Destinations, articles, model tree, Tags, Order, Content panels | yes | no | planned |
@@ -81,5 +81,6 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24)
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Full screen shows one fitted page (or spread) at a time on black with click and key paging, as Acrobat's Full Screen Mode (#379, #1459, #1353) |
| 2026-10-11 | minor | Column select shipped (#740, PR #765); several objects move together on one page (#844, PR #907); Shift-⌘-G goes back (#810, PR #835) |
| 2026-10-10 | major | Created from the command registry, automation tools, the View area of the checklist and open UI issues |
+6 -6
View File
@@ -1106,8 +1106,8 @@ milestone = "M3"
status = "shipped"
commands = ["view.full_screen"]
tools = []
evidence = ["crates/ui-egui/tests/commands.rs::full_screen_toggles_by_shortcut_and_command_and_escape_leaves"]
notes = ""
evidence = ["crates/ui-egui/tests/commands.rs::full_screen_toggles_by_shortcut_and_command_and_escape_leaves", "crates/ui-egui/tests/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it", "crates/ui-egui/tests/view.rs::full_screen_keeps_two_page_view_as_spreads"]
notes = "Acrobat's Full Screen Mode (#1459): the page on black and nothing else, one page at a time fitted to the screen (one spread when two-page view was on, #1353), from the current page; a click, Space, Enter, \u2192 or Page Down go forward, a shift-click, Backspace, \u2190 or Page Up back, Home/End to the ends; Esc or \u2318L bring the display from before back at the page reached. Links still open on click. Missing: the cursor is not hidden, no transitions, auto-advance or loop (view.full-screen-presentation), and no full-screen preferences such as the background colour"
[[feature]]
id = "view.full-screen-presentation"
@@ -1115,11 +1115,11 @@ title = "Full-screen presentation options (auto-advance, loop, click to advance)
area = "B"
tier = "P2"
milestone = "M12"
status = "planned"
commands = []
status = "partial"
commands = ["view.full_screen"]
tools = []
evidence = []
notes = ""
evidence = ["crates/ui-egui/tests/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it"]
notes = "Click to advance (and shift-click back) ships with view.full-screen; auto-advance every N seconds, loop after the last page, page transitions and the full-screen preferences are not done"
[[feature]]
id = "view.reflow"