Skip to content
19 changes: 14 additions & 5 deletions crates/ui-egui/src/canvas/navigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,17 @@ impl DocView {
// of pages of different heights, whose shorter page can start below it), else the
// nearest one below.
let top = self.viewport_screen.top();
let showing = || self.screen_xforms.iter().filter(|(_, xf)| xf.rect.bottom() > top);
let (page, xf) = showing()
.filter(|(_, xf)| xf.rect.top() <= top)
.min_by_key(|(p, _)| *p)
.or_else(|| showing().min_by(|a, b| a.1.rect.top().total_cmp(&b.1.rect.top()).then(a.0.cmp(&b.0))))?;
// A page showing less than SLIVER is rounding, not what the reader is looking at: a jump
// to the next page leaves the previous one's bottom edge within a pixel of the top. Such a
// sliver still counts when nothing else shows (the last page at the very end).
let pick = |slack: f32| {
let showing = || self.screen_xforms.iter().filter(move |(_, xf)| xf.rect.bottom() > top + slack);
showing()
.filter(|(_, xf)| xf.rect.top() <= top)
.min_by_key(|(p, _)| *p)
.or_else(|| showing().min_by(|a, b| a.1.rect.top().total_cmp(&b.1.rect.top()).then(a.0.cmp(&b.0))))
};
let (page, xf) = pick(SLIVER).or_else(|| pick(0.0))?;
let p = info.pages.get(*page)?;
// Above the page (in the gap before it) is the page's own top. The window's vertical is
// the view's y, or its x when the view is turned a quarter.
Expand Down Expand Up @@ -454,3 +460,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
/// How far ↓ / ↑ scroll at Normal speed (Scroll speed halves or doubles it). Page Down / Page Up
/// keep this much of the previous screen in view.
const KEY_SCROLL_LINE: f32 = 40.0;
/// How much of a page must show at the window's top edge to count as the page there, in points: less
/// is rounding, since page rectangles snap to whole pixels.
const SLIVER: f32 = 1.0;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
69 changes: 45 additions & 24 deletions crates/ui-egui/src/chrome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,26 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
});
}

/// The size of the rail's page and zoom fields: wide enough for "6400%", the largest zoom, in the
/// 12 pt font, and inside the 45 pt the rail leaves.
const RAIL_FIELD_SIZE: [f32; 2] = [42.0, 22.0];
/// Padding inside the rail's page and zoom fields.
const RAIL_FIELD_MARGIN: egui::Vec2 = egui::vec2(1.0, 4.0);

/// A field the rail can type in: boxed, and the same size whatever it holds, so the page and zoom
/// fields match and fit the rail's width. Sized outright: a text field is at least a control wide.
fn rail_field(ui: &mut egui::Ui, t: &Tokens, edit: egui::TextEdit<'_>) -> egui::Response {
egui::Frame::NONE
.fill(t.field)
.stroke(Stroke::new(1.0, t.border))
.corner_radius(CornerRadius::same(5))
.show(ui, |ui| {
ui.spacing_mut().interact_size.x = 0.0;
ui.add_sized(RAIL_FIELD_SIZE, edit)
})
.inner
}

/// Draw the right tool rail with page navigation, history, zoom, rotation and page-display controls for the
/// active view.
pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
Expand Down Expand Up @@ -573,20 +593,13 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let mut page_display = None;
let rail = Rail::new(egui::Id::unique("rail"))
.edge(Edge::End)
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(6, 8)).stroke(Stroke::new(1.0, t.divider)))
.width(49.0)
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(1, 8)).stroke(Stroke::new(1.0, t.divider)))
.show(ui, &items, app.right.as_ref(), |ui| {
// Page navigation cluster at the bottom, a panel of its own so that in a short
// window the buttons above scroll instead of running into it (#3282).
ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT);
if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() {
view.zoom_step(false);
}
let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN);
if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() {
view.zoom_step(true);
}
// Between rotate and zoom, as in Acrobat. Its command runs once `view` is free.
let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]);
let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip);
Expand All @@ -606,18 +619,31 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width };
view.goto = Some((view.current, 0.0));
}
// Out and in side by side right under the zoom field, so they are found with it.
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
// Two buttons across the rail, each leaving a pixel to the rail's edge.
let side = (ui.available_width() / 2.0).floor();
let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT);
if icons::button(ui, "zoom-out", side, false, &tip).clicked() {
view.zoom_step(false);
}
let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN);
if icons::button(ui, "zoom-in", side, false, &tip).clicked() {
view.zoom_step(true);
}
});
let id = egui::Id::unique("zoom-input");
let draft = id.with("draft");
let mut typed = ui.data(|d| d.get_temp::<String>(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0));
let edit = egui::TextEdit::singleline(&mut typed)
.id(id)
.desired_width(40.0)
.align(egui::Align2::CENTER_TOP)
.font(theme::medium(11.0))
.text_color(t.text_muted)
.align(egui::Align2::CENTER_CENTER)
.font(theme::medium(12.0))
.text_color(t.text)
.frame(egui::Frame::NONE)
.margin(vec2(2.0, 2.0));
let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter"));
.margin(RAIL_FIELD_MARGIN);
let r = rail_field(ui, &t, edit).on_hover_text(tl!("Zoom — type a percentage and press Enter"));
ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom")));
if r.gained_focus() {
let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
Expand Down Expand Up @@ -677,16 +703,11 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
}
let edit = egui::TextEdit::singleline(&mut view.page_input)
.id(page_input_id)
.desired_width(34.0)
.align(egui::Align2::CENTER_TOP)
.align(egui::Align2::CENTER_CENTER)
.font(theme::medium(12.0))
.margin(vec2(2.0, 4.0));
let r = egui::Frame::NONE
.fill(t.field)
.stroke(Stroke::new(1.0, t.border))
.corner_radius(CornerRadius::same(5))
.show(ui, |ui| ui.add(edit))
.inner
.frame(egui::Frame::NONE)
.margin(RAIL_FIELD_MARGIN);
let r = rail_field(ui, &t, edit)
.on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter"))
.accessible_name(tl!("Current page"));
if r.lost_focus() {
Expand Down
20 changes: 20 additions & 0 deletions crates/ui-egui/tests/it/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1425,3 +1425,23 @@ fn typing_in_one_tab_never_reaches_another_tabs_added_text() {
h.run_steps(3);
assert_eq!(h.state().views[1].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "the other tab's typing stays out");
}

/// The rail's page and zoom fields are the same size, and each sits wholly inside the rail, so
/// neither box is cut off at the edge (the page box lost its right side once the rail's contents
/// changed width).
#[test]
fn rail_page_and_zoom_fields_match_and_fit_the_rail() {
let mut h = harness();
h.run_steps(3);
// The rail is 49 pt wide at the window's right edge (1400 pt wide), inside a 1 pt border.
let (rail_left, rail_right) = (1400.0 - 49.0 + 1.0, 1400.0 - 1.0);
let rail = h.get_by_label("Bookmarks").rect();
let (page, zoom) = (h.get_by_label("Current page").rect(), h.get_by_label("Zoom").rect());
assert_eq!((page.width(), page.height()), (zoom.width(), zoom.height()), "{page:?} vs {zoom:?}");
// The size `RAIL_FIELD_SIZE` sets: wide enough for "6400%", the largest zoom.
assert_eq!((page.width(), page.height()), (42.0, 22.0), "{page:?}");
for r in [page, zoom] {
assert!(r.left() >= rail_left && r.right() <= rail_right, "{r:?} is cut off by the rail ({rail_left}..{rail_right})");
assert!((r.center().x - rail.center().x).abs() < 1.0, "{r:?} is off the rail's centre ({rail:?})");
}
}
8 changes: 6 additions & 2 deletions crates/ui-egui/tests/it/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1909,7 +1909,8 @@ fn sidebar_collapse_and_resize_preserves_scroll_position() {
let rect_after = v.page_screen_rect(2).expect("page 2 still on screen");
let vp_after = v.viewport_rect();
let rel_y_after = rect_after.min.y - vp_after.min.y;
assert!((rel_y_after - rel_y_before).abs() < 1.0, "page 2 scroll position preserved: before {rel_y_before}, after {rel_y_after}");
// Page rectangles snap to whole pixels, so a different width can move the page by one.
assert!((rel_y_after - rel_y_before).abs() <= 1.0, "page 2 scroll position preserved: before {rel_y_before}, after {rel_y_after}");

// Reopen bookmarks panel: available width decreases, so fit-width zoom decreases.
h.state_mut().set_option("panel", "bookmarks").unwrap();
Expand All @@ -1926,7 +1927,10 @@ fn sidebar_collapse_and_resize_preserves_scroll_position() {
let rect_reopened = v.page_screen_rect(2).expect("page 2 still on screen");
let vp_reopened = v.viewport_rect();
let rel_y_reopened = rect_reopened.min.y - vp_reopened.min.y;
assert!((rel_y_reopened - rel_y_before).abs() < 1.0, "page 2 scroll position preserved on reopen: before {rel_y_before}, after {rel_y_reopened}");
assert!(
(rel_y_reopened - rel_y_before).abs() <= 1.0,
"page 2 scroll position preserved on reopen: before {rel_y_before}, after {rel_y_reopened}"
);
}

/// Synthetic unequal-height spreads, generated without font or image assets.
Expand Down
Loading