mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
Merge PR #2070: M3: full screen shows one fitted page at a time, as the reference app's Full Screen Mode (#1459)
Conflicts: - docs/ui-parity.md: one status line naming both changes (#3808, #1459); both revision rows, newest first
This commit is contained in:
@@ -270,6 +270,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.
|
||||
|
||||
+173
-10
@@ -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>,
|
||||
@@ -456,6 +470,9 @@ pub struct DocView {
|
||||
clicks: u32,
|
||||
last_press_time: f64,
|
||||
last_press_pos: Option<Pos2>,
|
||||
/// Full screen: a click's page turn, when it falls due (input time) and which way. It waits
|
||||
/// out the double-click time, as the click may be the first of a double-click selecting a word.
|
||||
present_turn: Option<(f64, bool)>,
|
||||
last_queue: Vec<RenderRequest>,
|
||||
/// Finished renders wait in the pool instead of reaching the view (tests): what the view
|
||||
/// shows before a raster arrives can be looked at however fast the renderer is.
|
||||
@@ -729,6 +746,7 @@ impl DocView {
|
||||
single_edges: None,
|
||||
flash: None,
|
||||
compare_marks: Vec::new(),
|
||||
presentation: None,
|
||||
pages: HashMap::new(),
|
||||
errors: HashMap::new(),
|
||||
waiting_since: HashMap::new(),
|
||||
@@ -752,6 +770,7 @@ impl DocView {
|
||||
clicks: 0,
|
||||
last_press_time: 0.0,
|
||||
last_press_pos: None,
|
||||
present_turn: None,
|
||||
last_queue: Vec::new(),
|
||||
hold_rasters: false,
|
||||
viewport_w: 800.0,
|
||||
@@ -852,9 +871,9 @@ impl DocView {
|
||||
}
|
||||
|
||||
/// The document was replaced by a newer version of its file (#431). The reader's place
|
||||
/// stays: zoom, layout, rotation, page, scroll, view history and the find bar, the snapshot
|
||||
/// marker and its pending close, and the page images on screen until their replacements
|
||||
/// are drawn, so nothing flashes. Everything that pointed into the old version (a selected
|
||||
/// stays: zoom, layout, rotation, page, scroll, full screen, view history and the find bar,
|
||||
/// the snapshot marker and its pending close, and the page images on screen until their
|
||||
/// replacements are drawn, so nothing flashes. Everything that pointed into the old version (a selected
|
||||
/// comment, a field being typed in, text being edited, a drag, page selection) starts
|
||||
/// afresh, so no stale index reaches the new one.
|
||||
pub fn document_replaced(&mut self, info: &DocInfo) {
|
||||
@@ -875,6 +894,8 @@ impl DocView {
|
||||
fresh.snapshot = self.snapshot.clone();
|
||||
fresh.close_requested = self.close_requested;
|
||||
fresh.cover = self.cover;
|
||||
// Still presenting, with the display to go back to on leaving.
|
||||
fresh.presentation = self.presentation.take();
|
||||
fresh.back = std::mem::take(&mut self.back);
|
||||
fresh.forward = std::mem::take(&mut self.forward);
|
||||
fresh.find = self.find.take();
|
||||
@@ -1284,8 +1305,7 @@ impl DocView {
|
||||
let last = self.page_count.saturating_sub(1);
|
||||
let (target, first) = match self.layout {
|
||||
PageLayout::TwoUp => {
|
||||
// Spreads are [0, 1], [2, 3], … or, with a cover page, [0], [1, 2], [3, 4], ….
|
||||
let first = if self.cover { c.saturating_sub((c + 1) % 2) } else { c - c % 2 };
|
||||
let first = self.spread_first(c);
|
||||
let target = match (forward, self.cover && c == 0) {
|
||||
(true, true) => 1,
|
||||
(true, false) => first.saturating_add(2),
|
||||
@@ -1381,6 +1401,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.
|
||||
@@ -1967,14 +2018,30 @@ impl DocView {
|
||||
}
|
||||
}
|
||||
|
||||
/// The first page of the two-page spread page `c` is in: spreads are [0, 1], [2, 3], … or,
|
||||
/// with a cover page, [0], [1, 2], [3, 4], ….
|
||||
fn spread_first(&self, c: usize) -> usize {
|
||||
if self.cover { c.saturating_sub((c + 1) % 2) } else { c - c % 2 }
|
||||
}
|
||||
|
||||
fn fit_zoom(&mut self, info: &DocInfo) {
|
||||
let largest = |side: fn((f32, f32)) -> f32| info.pages.iter().map(|p| side(self.display_size(p))).fold(1.0, f32::max);
|
||||
let max_w = largest(|s| s.0);
|
||||
// A single page shown on its own fits that page. Every other display (scrolling, or a
|
||||
// two-page spread) fits its largest page, so the zoom holds still while pages of other
|
||||
// sizes scroll or page past.
|
||||
// A single page shown on its own fits that page, and full screen in two-page view fits
|
||||
// the spread it shows (review on #2070). Every other display (scrolling, or two-page
|
||||
// view in the window) fits its largest page, so the zoom holds still while pages of
|
||||
// other sizes scroll or page past.
|
||||
let (w, h) = if self.layout == PageLayout::Single && !self.continuous {
|
||||
info.pages.get(self.current).map_or_else(|| (max_w, largest(|s| s.1)), |p| self.display_size(p))
|
||||
} else if self.presentation.is_some() && self.layout == PageLayout::TwoUp && !self.continuous {
|
||||
let first = self.spread_first(self.current);
|
||||
let last = if self.cover && first == 0 { 0 } else { first.saturating_add(1) };
|
||||
let spread: Vec<(f32, f32)> = info.pages.iter().skip(first).take(last - first + 1).map(|p| self.display_size(p)).collect();
|
||||
if spread.is_empty() {
|
||||
(max_w, largest(|s| s.1))
|
||||
} else {
|
||||
(spread.iter().map(|s| s.0).fold(1.0, f32::max), spread.iter().map(|s| s.1).fold(1.0, f32::max))
|
||||
}
|
||||
} else {
|
||||
(max_w, largest(|s| s.1))
|
||||
};
|
||||
@@ -2326,6 +2393,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);
|
||||
@@ -2501,6 +2578,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()) {
|
||||
@@ -4000,6 +4093,26 @@ 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 turns the page unless it did something else first. It waits out the
|
||||
// double-click time: turning on the first click's release sent a double-click's second click,
|
||||
// meant to select a word, to the next page (review on #2070). A selection cancels it, and so
|
||||
// do a link followed meanwhile and leaving full screen; a double-click elsewhere still turns
|
||||
// once.
|
||||
let now = ui.input(|i| i.time);
|
||||
if present_click && clicked_link.is_none() && view.selection.is_none() && view.clicks < 2 {
|
||||
let delay = ui.ctx().options(|o| o.input_options.max_double_click_delay);
|
||||
view.present_turn = Some((now + delay, !present_back));
|
||||
}
|
||||
if let Some((due, forward)) = view.present_turn {
|
||||
if clicked_link.is_some() || view.selection.is_some() || view.presentation.is_none() {
|
||||
view.present_turn = None;
|
||||
} else if now >= due {
|
||||
view.present_turn = None;
|
||||
view.step_page(forward);
|
||||
} else {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((due - now).max(0.0)));
|
||||
}
|
||||
}
|
||||
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),
|
||||
@@ -4118,8 +4231,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 {
|
||||
@@ -6412,6 +6526,55 @@ trailer << /Root 1 0 R >>
|
||||
}
|
||||
}
|
||||
|
||||
/// Review on #2070: full screen in two-page view fits the spread it shows, the cover page
|
||||
/// on its own included; two-page view in the window keeps one zoom for the document.
|
||||
#[test]
|
||||
fn full_screen_two_page_view_fits_the_spread_it_shows() {
|
||||
let page =
|
||||
|width: f32, height: f32| pdfcraft_render::PageInfo { width, height, label: String::new(), crop: [0.0, 0.0, width, height], rotation: 0 };
|
||||
// Spreads [0, 1] small, [2, 3] large; with a cover page [0], [1, 2] mixed, [3].
|
||||
let info = DocInfo { pages: vec![page(300.0, 400.0), page(300.0, 400.0), page(600.0, 800.0), page(600.0, 800.0)], ..Default::default() };
|
||||
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
|
||||
(v.layout, v.continuous, v.viewport_w, v.viewport_h) = (PageLayout::TwoUp, false, 1000.0, 800.0);
|
||||
v.fit = Fit::Page;
|
||||
v.fit_zoom(&info);
|
||||
let windowed = v.zoom;
|
||||
v.enter_presentation();
|
||||
v.fit_zoom(&info);
|
||||
let small = v.zoom;
|
||||
assert!(small > windowed * 1.9, "the small spread fills the screen: {small} vs {windowed}");
|
||||
v.current = 3;
|
||||
v.fit_zoom(&info);
|
||||
assert!((v.zoom - windowed).abs() < 1e-4, "the large spread fits as before");
|
||||
v.current = 1;
|
||||
v.fit_zoom(&info);
|
||||
assert_eq!(v.zoom, small, "page 2 is in the first spread");
|
||||
// With a cover page: [0] alone, then [1, 2] (the large page decides), then [3].
|
||||
v.cover = true;
|
||||
v.current = 0;
|
||||
v.fit_zoom(&info);
|
||||
assert_eq!(v.zoom, small, "the cover page alone");
|
||||
v.current = 1;
|
||||
v.fit_zoom(&info);
|
||||
assert!((v.zoom - windowed).abs() < 1e-4, "page 2 shares a spread with the large page 3");
|
||||
// Scrolling while in full screen fits the document, as scrolling does in the window.
|
||||
(v.continuous, v.current) = (true, 0);
|
||||
v.fit_zoom(&info);
|
||||
assert!((v.zoom - windowed).abs() < 1e-4, "continuous full screen keeps one zoom");
|
||||
v.continuous = false;
|
||||
// The cover page is a spread of its own even when the next page is larger.
|
||||
let mixed = DocInfo { pages: vec![page(300.0, 400.0), page(600.0, 800.0), page(600.0, 800.0)], ..Default::default() };
|
||||
v.current = 0;
|
||||
v.fit_zoom(&mixed);
|
||||
assert_eq!(v.zoom, small, "the small cover alone, not with page 2");
|
||||
// Leaving full screen brings the document-wide fit back.
|
||||
v.leave_presentation();
|
||||
(v.fit, v.current) = (Fit::Page, 0);
|
||||
(v.layout, v.continuous) = (PageLayout::TwoUp, false);
|
||||
v.fit_zoom(&info);
|
||||
assert!((v.zoom - windowed).abs() < 1e-4, "in the window, one zoom for the document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_width_fits_the_shown_page_in_single_page_view_only() {
|
||||
let page =
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+43
-14
@@ -525,6 +525,8 @@ pub struct PdfCraftApp {
|
||||
pub quick_tool: QuickTool,
|
||||
/// The tool to go back to when Space, held for a temporary Hand, is released.
|
||||
space_hand: Option<QuickTool>,
|
||||
/// The tool before full screen, given back on leaving it (see [`Self::apply_full_screen`]).
|
||||
full_screen_tool: Option<QuickTool>,
|
||||
/// Comment author, per-tool colours and widths, pin.
|
||||
pub comment_prefs: comments::CommentPrefs,
|
||||
/// Resolved colours, including the current OS theme when following the system.
|
||||
@@ -919,6 +921,7 @@ impl PdfCraftApp {
|
||||
right_panel_closed: false,
|
||||
quick_tool: QuickTool::Select,
|
||||
space_hand: None,
|
||||
full_screen_tool: None,
|
||||
comment_prefs: Default::default(),
|
||||
theme: ThemeKind::Light,
|
||||
theme_preference: ThemePreference::Light,
|
||||
@@ -1351,11 +1354,18 @@ impl PdfCraftApp {
|
||||
};
|
||||
}
|
||||
let initial = doc.initial_view();
|
||||
self.views.push(DocView::new(id, &doc.info, self.view_defaults));
|
||||
let view = DocView::new(id, &doc.info, self.view_defaults);
|
||||
self.views.push(view);
|
||||
if activate {
|
||||
self.active = Some(self.views.len() - 1);
|
||||
}
|
||||
self.apply_initial_view(self.views.len() - 1, &initial);
|
||||
// After the document's own initial view, so leaving full screen comes back to it.
|
||||
if self.full_screen
|
||||
&& let Some(view) = self.views.last_mut()
|
||||
{
|
||||
view.enter_presentation();
|
||||
}
|
||||
// Full screen is asked for, never taken: a document can't put the app in full screen on
|
||||
// its own (the prompt is `dialogs::full_screen_prompt`).
|
||||
if activate && initial.full_screen {
|
||||
@@ -1595,10 +1605,32 @@ 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) {
|
||||
// A click turns the page only with the Select tool, and full screen hides the tools:
|
||||
// select it there, and give the tool back on leaving (review on #2070).
|
||||
if on && !self.full_screen {
|
||||
self.full_screen_tool = Some(self.quick_tool);
|
||||
self.quick_tool = QuickTool::Select;
|
||||
} else if !on && let Some(tool) = self.full_screen_tool.take() {
|
||||
self.quick_tool = tool;
|
||||
}
|
||||
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) {
|
||||
@@ -3168,19 +3200,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);
|
||||
|
||||
@@ -544,3 +544,84 @@ fn coalesced_primary_clicks_select_line_then_spaced_click_clears() {
|
||||
assert_eq!(doc.saved_bytes(), saved);
|
||||
}
|
||||
}
|
||||
|
||||
/// Review on #2070: a click on the page in full screen waits out the double-click time before
|
||||
/// it turns the page, and a link clicked meanwhile used to leave that turn pending: the link
|
||||
/// jumped to page 3, then the page moved on to page 4.
|
||||
#[test]
|
||||
fn full_screen_link_click_cancels_the_pending_page_turn() {
|
||||
let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>";
|
||||
let four_pages = String::from_utf8(two_lines())
|
||||
.unwrap()
|
||||
.replace("/Kids [3 0 R] /Count 1", "/Kids [3 0 R 6 0 R 7 0 R 8 0 R] /Count 4")
|
||||
.replace("/Contents 4 0 R /Resources", "/Annots [9 0 R] /Contents 4 0 R /Resources")
|
||||
.replace(
|
||||
"trailer",
|
||||
&format!(
|
||||
"6 0 obj {page} endobj\n7 0 obj {page} endobj\n8 0 obj {page} endobj\n9 0 obj << /Type /Annot /Subtype /Link /Rect [200 20 280 60] /Border [0 0 0] /Dest [7 0 R /Fit] >> endobj\ntrailer"
|
||||
),
|
||||
)
|
||||
.into_bytes();
|
||||
let (mut h, _) = harness_with(four_pages, |_| {});
|
||||
h.state_mut().apply_full_screen(true);
|
||||
crate::support::wait_until(&mut h, "the page in full screen", |app| app.views[0].glyph_screen_pos(0, 5).is_some() && !app.render_pending());
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen");
|
||||
let at = |x: f32, y: f32| egui::pos2(r.left() + x / 300.0 * r.width(), r.top() + (200.0 - y) / 200.0 * r.height());
|
||||
// A click on blank page area, then, within the double-click time, a click on the link.
|
||||
h.hover_at(at(150.0, 90.0));
|
||||
h.run_steps(1);
|
||||
click(&mut h, at(150.0, 90.0));
|
||||
h.run_steps(3);
|
||||
h.hover_at(at(240.0, 40.0));
|
||||
h.run_steps(1);
|
||||
click(&mut h, at(240.0, 40.0));
|
||||
h.run_steps(30);
|
||||
assert_eq!(h.state().views[0].current, 2, "the link's page, and no turn after it");
|
||||
}
|
||||
|
||||
/// Review on #2070: in full screen a click goes forward a page, but a double-click selects the
|
||||
/// word and stays: the click waits out the double-click time before turning. Turning on the first
|
||||
/// click's release had sent the second click to the next page. Leaving full screen meanwhile
|
||||
/// drops the pending turn.
|
||||
#[test]
|
||||
fn full_screen_double_click_selects_a_word_and_stays_on_the_page() {
|
||||
// The same page twice.
|
||||
let two_pages = String::from_utf8(two_lines())
|
||||
.unwrap()
|
||||
.replace("/Kids [3 0 R] /Count 1", "/Kids [3 0 R 6 0 R] /Count 2")
|
||||
.replace(
|
||||
"trailer",
|
||||
"6 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\ntrailer",
|
||||
)
|
||||
.into_bytes();
|
||||
let (mut h, _) = harness_with(two_pages, |_| {});
|
||||
h.state_mut().apply_full_screen(true);
|
||||
crate::support::wait_until(&mut h, "the page in full screen", |app| app.views[0].glyph_screen_pos(0, 5).is_some() && !app.render_pending());
|
||||
let at = h.state().views[0].glyph_screen_pos(0, 5).expect("text layer");
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
|
||||
// A double-click selects "quick" and stays on page 1, also once the double-click time is over.
|
||||
click(&mut h, at);
|
||||
click(&mut h, at);
|
||||
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"));
|
||||
h.run_steps(30);
|
||||
assert_eq!(h.state().views[0].current, 0, "the double-click didn't turn the page");
|
||||
|
||||
// A single click turns it once the double-click time is over.
|
||||
// Entering full screen again clears the selection.
|
||||
h.state_mut().apply_full_screen(false);
|
||||
h.state_mut().apply_full_screen(true);
|
||||
h.run_steps(30);
|
||||
click(&mut h, at);
|
||||
h.run_steps(30);
|
||||
assert_eq!(h.state().views[0].current, 1, "a click goes forward");
|
||||
|
||||
// A click right before leaving full screen doesn't turn the page after it.
|
||||
h.state_mut().views[0].go_to_page(0);
|
||||
h.run_steps(30);
|
||||
click(&mut h, at);
|
||||
h.state_mut().apply_full_screen(false);
|
||||
h.run_steps(30);
|
||||
assert_eq!(h.state().views[0].current, 0, "no page turn after leaving");
|
||||
}
|
||||
|
||||
@@ -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,139 @@ 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");
|
||||
}
|
||||
|
||||
/// #2070 review: full screen survives the file being reloaded (Esc still brings the display from
|
||||
/// before back), and a document opened in full screen takes its own initial view first, so Esc
|
||||
/// comes back to that and full screen stays one spread at a time.
|
||||
#[test]
|
||||
fn full_screen_survives_a_reload_and_keeps_a_new_documents_own_view() {
|
||||
use pdfcraft_ui_egui::canvas::{Fit, PageLayout};
|
||||
let mut h = harness(&[("layout", "continuous"), ("zoom", "50")]);
|
||||
h.run_steps(4);
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::L);
|
||||
h.run_steps(6);
|
||||
{
|
||||
let app = h.state_mut();
|
||||
let info = app.session.get(app.views[0].id).unwrap().info.clone();
|
||||
app.views[0].document_replaced(&info);
|
||||
}
|
||||
h.run_steps(4);
|
||||
assert!(h.state().views[0].presenting(), "still presenting after the reload");
|
||||
h.key_press(Key::Escape);
|
||||
h.run_steps(4);
|
||||
let v = &h.state().views[0];
|
||||
assert!(v.continuous() && v.fit == Fit::None && (v.zoom - 0.5).abs() < 1e-3, "the display from before comes back");
|
||||
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::L);
|
||||
h.run_steps(6);
|
||||
let two_columns = String::from_utf8_lossy(PAGES).replace("/Type /Catalog /Pages 2 0 R", "/Type /Catalog /Pages 2 0 R /PageLayout /TwoColumnLeft");
|
||||
h.state_mut().open_bytes("two.pdf", None, two_columns.into_bytes()).expect("opens");
|
||||
h.run_steps(6);
|
||||
let v = h.state().views.last().unwrap();
|
||||
assert!(v.presenting() && v.layout == PageLayout::TwoUp && !v.continuous(), "one spread at a time");
|
||||
h.key_press(Key::Escape);
|
||||
h.run_steps(4);
|
||||
let v = h.state().views.last().unwrap();
|
||||
assert!(v.layout == PageLayout::TwoUp && v.continuous(), "the document's own two-column view comes 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");
|
||||
}
|
||||
|
||||
/// Review on #2070: full screen hides the tools and a click turns the page only with Select, so
|
||||
/// entering it from another tool selects Select, and leaving gives the tool back.
|
||||
#[test]
|
||||
fn full_screen_clicks_turn_pages_whatever_tool_was_on() {
|
||||
use pdfcraft_ui_egui::QuickTool;
|
||||
let mut h = harness(&[]);
|
||||
h.run_steps(4);
|
||||
h.state_mut().quick_tool = QuickTool::Hand;
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::L);
|
||||
h.run_steps(6);
|
||||
assert!(h.state().full_screen);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Select, "Select in full screen");
|
||||
// Entering again (a second document asking for it) keeps the tool to give back.
|
||||
h.state_mut().apply_full_screen(true);
|
||||
let r = rect(&h, 0).expect("page 1 on screen");
|
||||
click_with(&mut h, r.center(), Modifiers::NONE);
|
||||
assert_eq!(h.state().views[0].current, 1, "a click goes forward");
|
||||
h.key_press(Key::Escape);
|
||||
h.run_steps(4);
|
||||
assert!(!h.state().full_screen);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Hand, "the tool from before is back");
|
||||
}
|
||||
|
||||
+3
-4
@@ -1,8 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.)
|
||||
|
||||
> **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-21 · **Last updated:** 2026-10-21 · **Change:** minor (Pages inspector thumbnail size slider and buttons #3808; 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
|
||||
@@ -33,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); the Pages inspector has a 50–150% thumbnail size slider and buttons (#3808) | shipped |
|
||||
| Destinations, articles, model tree, Tags, Order, Content panels | yes | no | planned |
|
||||
@@ -84,5 +82,6 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24)
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-21 | minor | Pages inspector thumbnail sizing (#3808), separate from page and Organize zoom; view setting retained on reload and exposed through UI control. Not persisted between sessions. |
|
||||
| 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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user