Rail: scroll the panel buttons above the page navigation in short windows (#3282)

The rail laid its panel buttons out top-down and the page navigation
bottom-up in the same space, so in a window too short for both they were
drawn on top of each other, with the page number field over the icons.

The navigation cluster is now its own bottom panel and the panel buttons
sit in a vertical scroll area in the height left above it.
This commit is contained in:
majidninja
2026-10-18 19:13:19 +00:00
parent a8cd797a91
commit df591ff88d
2 changed files with 172 additions and 133 deletions
+136 -132
View File
@@ -638,144 +638,148 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
.exact_size(48.0)
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(6, 8)).stroke(Stroke::new(1.0, t.divider)))
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 4.0;
let mut rail_button = |ui: &mut egui::Ui, panel: RightPanel, icon: &str, tip: &str, has: bool| {
let selected = app.right == Some(panel) && (app.mode == Mode::Read || app.docking.exposed(crate::docking::Panel::Inspector));
let r = icons::button(ui, icon, 34.0, selected, tl!(tip));
if has && !selected {
let c = r.rect.right_top() + vec2(-8.0, 8.0);
ui.painter().circle_filled(c, 3.0, t.accent);
}
if r.clicked() {
app.choose_right_panel(if selected { None } else { Some(panel) });
}
};
rail_button(ui, RightPanel::Comments, "message-square-text", "Comments", has_comments);
rail_button(ui, RightPanel::Bookmarks, "bookmark", "Bookmarks", has_outline);
rail_button(ui, RightPanel::Pages, "files", "Page thumbnails", false);
rail_button(ui, RightPanel::Fields, "text-cursor-input", "Form fields", has_fields);
rail_button(ui, RightPanel::Layers, "layers", "Layers", has_layers);
rail_button(ui, RightPanel::Attachments, "paperclip", "Attachments", has_files);
rail_button(ui, RightPanel::Signatures, "signature", "Signatures", has_signatures);
if has_check {
rail_button(ui, RightPanel::Accessibility, "accessibility", "Accessibility Checker", false);
}
rail_button(ui, RightPanel::Tags, "tag", "Tags", false);
// Page navigation cluster at the bottom (as in Acrobat's rail).
// Page navigation cluster at the bottom (as in Acrobat's rail). It is a panel of its own so
// that in a short window the buttons above scroll instead of running into it (#3282).
let view = &mut app.views[index];
let mut page_display = None;
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);
page_display = egui::Popup::menu(&resp)
.show(|ui| {
ui.set_min_width(220.0);
rail_view_menu(ui, view)
})
.and_then(|r| r.inner);
let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW);
if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() {
view.rotate_view(true);
}
// Not `columns-2` while fitting the page: that is the two-page view's icon.
let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" };
if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() {
view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width };
view.goto = Some((view.current, 0.0));
}
let id = egui::Id::new("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)
.horizontal_align(Align::Center)
.font(theme::medium(11.0))
.text_color(t.text_muted)
.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"));
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();
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count()));
state.cursor.set_char_range(Some(all));
state.store(ui.ctx(), id);
}
if r.has_focus() {
ui.data_mut(|d| d.insert_temp(draft, typed.clone()));
} else {
ui.data_mut(|d| d.remove::<String>(draft));
if r.lost_focus()
&& ui.input(|i| i.key_pressed(egui::Key::Enter))
&& let Some(zoom) = DocView::parse_zoom_percent(&typed)
{
view.set_zoom(zoom);
egui::Panel::bottom("rail-nav").frame(egui::Frame::NONE).show_separator_line(false).show(ui, |ui| {
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);
}
}
ui.add_space(6.0);
if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() {
view.step_page(true);
}
if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() {
view.step_page(false);
}
// History is distinct from stepping to the adjacent page, and belongs to this tab.
for (forward, icon, label, shortcut, enabled) in [
(true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()),
(false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()),
] {
let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut));
if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() {
view.view_history(forward);
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);
}
}
ui.label(egui::RichText::new(page_count.to_string()).font(theme::regular(11.0)).color(t.text_muted));
let page_input_id = view.page_input_id();
// Consume replacement selection once, including when the editor has lost focus.
let reselect = std::mem::take(&mut view.page_input_reselect);
// Navigation updates the idle display, never the text being edited.
if !ui.memory(|m| m.has_focus(page_input_id)) {
view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str));
} else if reselect {
// The replacement changed the label while egui retained this editor's cursor.
let mut state = egui::TextEdit::load_state(ui.ctx(), page_input_id).unwrap_or_default();
let end = egui::text::CCursor::new(view.page_input.chars().count());
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end)));
state.store(ui.ctx(), page_input_id);
}
let edit = egui::TextEdit::singleline(&mut view.page_input)
.id(page_input_id)
.desired_width(34.0)
.horizontal_align(Align::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
.on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter"));
if r.lost_focus() {
if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
// A page label first (logical page numbers, as Acrobat), then a number.
let typed = view.page_input.clone();
view.go_to_typed(&typed, &labels);
// 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);
page_display = egui::Popup::menu(&resp)
.show(|ui| {
ui.set_min_width(220.0);
rail_view_menu(ui, view)
})
.and_then(|r| r.inner);
let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW);
if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() {
view.rotate_view(true);
}
view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str));
// Not `columns-2` while fitting the page: that is the two-page view's icon.
let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" };
if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() {
view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width };
view.goto = Some((view.current, 0.0));
}
let id = egui::Id::new("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)
.horizontal_align(Align::Center)
.font(theme::medium(11.0))
.text_color(t.text_muted)
.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"));
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();
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count()));
state.cursor.set_char_range(Some(all));
state.store(ui.ctx(), id);
}
if r.has_focus() {
ui.data_mut(|d| d.insert_temp(draft, typed.clone()));
} else {
ui.data_mut(|d| d.remove::<String>(draft));
if r.lost_focus()
&& ui.input(|i| i.key_pressed(egui::Key::Enter))
&& let Some(zoom) = DocView::parse_zoom_percent(&typed)
{
view.set_zoom(zoom);
}
}
ui.add_space(6.0);
if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() {
view.step_page(true);
}
if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() {
view.step_page(false);
}
// History is distinct from stepping to the adjacent page, and belongs to this tab.
for (forward, icon, label, shortcut, enabled) in [
(true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()),
(false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()),
] {
let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut));
if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() {
view.view_history(forward);
}
}
ui.label(egui::RichText::new(page_count.to_string()).font(theme::regular(11.0)).color(t.text_muted));
let page_input_id = view.page_input_id();
// Consume replacement selection once, including when the editor has lost focus.
let reselect = std::mem::take(&mut view.page_input_reselect);
// Navigation updates the idle display, never the text being edited.
if !ui.memory(|m| m.has_focus(page_input_id)) {
view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str));
} else if reselect {
// The replacement changed the label while egui retained this editor's cursor.
let mut state = egui::TextEdit::load_state(ui.ctx(), page_input_id).unwrap_or_default();
let end = egui::text::CCursor::new(view.page_input.chars().count());
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end)));
state.store(ui.ctx(), page_input_id);
}
let edit = egui::TextEdit::singleline(&mut view.page_input)
.id(page_input_id)
.desired_width(34.0)
.horizontal_align(Align::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
.on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter"));
if r.lost_focus() {
if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
// A page label first (logical page numbers, as Acrobat), then a number.
let typed = view.page_input.clone();
view.go_to_typed(&typed, &labels);
}
view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str));
}
});
});
egui::ScrollArea::vertical().id_salt("rail-panels").show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 4.0;
let mut rail_button = |ui: &mut egui::Ui, panel: RightPanel, icon: &str, tip: &str, has: bool| {
let selected = app.right == Some(panel) && (app.mode == Mode::Read || app.docking.exposed(crate::docking::Panel::Inspector));
let r = icons::button(ui, icon, 34.0, selected, tl!(tip));
if has && !selected {
let c = r.rect.right_top() + vec2(-8.0, 8.0);
ui.painter().circle_filled(c, 3.0, t.accent);
}
if r.clicked() {
app.choose_right_panel(if selected { None } else { Some(panel) });
}
};
rail_button(ui, RightPanel::Comments, "message-square-text", "Comments", has_comments);
rail_button(ui, RightPanel::Bookmarks, "bookmark", "Bookmarks", has_outline);
rail_button(ui, RightPanel::Pages, "files", "Page thumbnails", false);
rail_button(ui, RightPanel::Fields, "text-cursor-input", "Form fields", has_fields);
rail_button(ui, RightPanel::Layers, "layers", "Layers", has_layers);
rail_button(ui, RightPanel::Attachments, "paperclip", "Attachments", has_files);
rail_button(ui, RightPanel::Signatures, "signature", "Signatures", has_signatures);
if has_check {
rail_button(ui, RightPanel::Accessibility, "accessibility", "Accessibility Checker", false);
}
rail_button(ui, RightPanel::Tags, "tag", "Tags", false);
});
if let Some(id) = page_display {
app.execute(id);
+36 -1
View File
@@ -3,7 +3,7 @@
mod support;
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use egui_kittest::kittest::{By, NodeT, Queryable};
use pdfcraft_ui_egui::PdfCraftApp;
/// A tiny PDF with two pages, two bookmarks and one sticky note.
@@ -1404,3 +1404,38 @@ fn the_edit_tab_turns_on_edit_text_and_images() {
h.run_steps(3);
assert_eq!(h.state().quick_tool, QuickTool::Hand);
}
/// #3282: in a short window the rail's panel buttons scroll above the page navigation instead of
/// being drawn under the page number field.
#[test]
fn short_window_rail_buttons_scroll_clear_of_page_navigation() {
let (width, height) = (1000.0, 560.0);
let mut h = Harness::builder().with_size(egui::vec2(width, height)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("short.pdf", None, FIXTURE.to_vec()).unwrap();
app
});
settle(&mut h);
let rail = |h: &Harness<'static, PdfCraftApp>, label: &str| {
h.query_all_by_label(label).map(|n| n.rect()).find(|r| r.left() >= width - 48.0).unwrap_or_else(|| panic!("{label} in the rail"))
};
let page = h.get(By::new().role(egui::accesskit::Role::TextInput).value("1")).rect();
assert!(page.left() >= width - 48.0 && page.bottom() <= height, "page field {page:?} in the rail");
assert!(rail(&h, "Comments").bottom() <= page.top(), "the first button sits above the page field");
assert!(rail(&h, "Tags").top() >= page.top(), "the window is short enough that the buttons must scroll");
let over = rail(&h, "Comments").center();
h.event(egui::Event::PointerMoved(over));
for _ in 0..8 {
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -200.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
});
h.run_steps(4);
}
let tags = rail(&h, "Tags");
assert!(tags.bottom() <= page.top(), "Tags {tags:?} scrolls into view above the page field {page:?}");
assert_eq!(h.get(By::new().role(egui::accesskit::Role::TextInput).value("1")).rect(), page, "navigation stays put");
}