M14: side panels keep their width after a resize (#704)

A resizable egui panel stores the width its content used at the end of
every frame and starts the next frame there, so a row wider than the panel
widens it on every repaint (any pointer move) until it reaches 520 px.

- Bookmarks: the search row reserved 32 px for an 8 px gap and a 26 px
  clear button, overflowing by 2 px. The field now reserves the button's
  size plus the theme's item spacing.
- Comments: the reply row reserved 56 px for the gap and "Post", which only
  fits English. The field now fills its line and Post sits right-aligned
  under it, keeping the field first in Tab order.
- Tests read egui's PanelState and hold each panel's width across 30
  repaints (Bookmarks with a bookmark; Comments with a reply open in
  Russian with Post hovered). On main they grow 358 → 418 and to 520 px.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Todd Pederson
2026-10-10 06:15:06 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 06a7b9a7ca
commit c5d886488c
5 changed files with 74 additions and 17 deletions
+1
View File
@@ -131,6 +131,7 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
## Log
- **2026-10-10 (M14, side panel width):** The side panel keeps the width it is dragged to. The Bookmarks search row was 2 px wider than the panel, and a resizable egui panel keeps the width its content used, so every repaint (any pointer move) widened it up to 520 px. The Comments reply row did the same in languages with a longer "Post" (Russian, Spanish, …); Post now sits under a full-width reply field. Tests hold both panels' widths steady across 30 repaints. Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M6, #95):** Prepare a form edits shared General, Appearance and applicable Options properties in one atomic undo step. Explicit opt-in controls keep mixed values unchanged; partial appearance patches preserve custom font resources, each widget's style, indirect dictionary contents and unknown keys. Ctrl/Command+A selects the current page; rectangle selection and the Fields panel support multi-selection, with panel double-click opening properties. CLI/MCP `form_set_props` accepts a bounded, unique `fields` list. Regression tests cover save/reopen, undo/redo, locked-field rollback, stale dialogs, invalid arguments and selection. Names, positions, choices/defaults and script tabs remain individual GUI edits. Integrated upstream 0.5.0 and completed Portuguese/Italian catalogs without losing translator credit; shared-property labels now cover all 14 bundled translations. Kept the new Italian interface and Fill & Sign date-language registries aligned, including accented date output; the About-dialog regression waits for the larger contributor list to settle before clicking. Milestone and overall effort estimates unchanged (about 30–35%).
- **2026-10-09 (M5, line-ending labels):** Comment properties names each line ending in words ("Reverse open arrow", "Butt", …) and those labels are translated. The PDF `/LE` token is unchanged. Overall estimate unchanged (about 30–35%).
+17 -14
View File
@@ -353,20 +353,23 @@ fn card(
if selected && allowed {
ui.add_space(8.0);
egui::Frame::NONE.inner_margin(egui::Margin { left: 34, right: 0, top: 0, bottom: 0 }).show(ui, |ui| {
ui.horizontal(|ui| {
let r = ui.add(
egui::TextEdit::singleline(&mut view.comments.reply)
.hint_text(tl!("Add a reply"))
.desired_width(ui.available_width() - 56.0)
.id_salt(("comment-reply", key)),
);
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let ok = !view.comments.reply.trim().is_empty();
if (ui.add_enabled(ok, egui::Button::new(tl!("Post")).corner_radius(12)).clicked() || enter) && ok {
let text = std::mem::take(&mut view.comments.reply).trim().to_string();
edit = Some(Edit::ReplyToAnnotation { page: a.page, index: a.index, text, author: prefs.author.clone() });
}
});
// The field fills its line and Post sits right-aligned under it: a field sized
// around the button can't fit every language's "Post" exactly, and any overflow
// would widen the resizable panel again on every repaint.
let r = ui.add(
egui::TextEdit::singleline(&mut view.comments.reply)
.hint_text(tl!("Add a reply"))
.desired_width(f32::INFINITY)
.id_salt(("comment-reply", key)),
);
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let ok = !view.comments.reply.trim().is_empty();
let post =
ui.with_layout(Layout::right_to_left(Align::Min), |ui| ui.add_enabled(ok, egui::Button::new(tl!("Post")).corner_radius(12)));
if (post.inner.clicked() || enter) && ok {
let text = std::mem::take(&mut view.comments.reply).trim().to_string();
edit = Some(Edit::ReplyToAnnotation { page: a.page, index: a.index, text, author: prefs.author.clone() });
}
});
}
// The header selects the card, except where its buttons are.
+6 -3
View File
@@ -493,16 +493,19 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let mut bookmark_query = ui.data(|d| d.get_temp::<String>(search_id)).unwrap_or_default();
if panel == RightPanel::Bookmarks && !info.outline.is_empty() {
ui.horizontal(|ui| {
const CLEAR: f32 = 26.0;
let label = ui.label(tl!("Search"));
let previous = bookmark_query.clone();
let response =
ui.add(egui::TextEdit::singleline(&mut bookmark_query).desired_width(ui.available_width() - 32.0)).labelled_by(label.id);
// The panel keeps whatever width its content used, so this row must fit
// exactly: any overflow would widen the panel again on every repaint.
let width = ui.available_width() - CLEAR - ui.spacing().item_spacing.x;
let response = ui.add(egui::TextEdit::singleline(&mut bookmark_query).desired_width(width)).labelled_by(label.id);
response.widget_info(|| {
let mut info = egui::WidgetInfo::text_edit(ui.is_enabled(), &previous, &bookmark_query, "");
info.label = Some(tl!("Search").to_string());
info
});
if icons::button(ui, "x", 26.0, false, tl!("Clear")).clicked() {
if icons::button(ui, "x", CLEAR, false, tl!("Clear")).clicked() {
bookmark_query.clear();
}
});
+20
View File
@@ -98,6 +98,26 @@ fn new_rename_reorder_indent_delete_and_undo() {
assert!(h.state().session.get(h.state().views[0].id).unwrap().dirty);
}
/// The side panel's width as egui keeps it from one frame to the next.
fn panel_width(h: &Harness<'static, PdfCraftApp>) -> f32 {
egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("right_panel")).expect("the side panel is shown").size().x
}
#[test]
fn the_panel_keeps_its_width_while_the_pointer_moves() {
// The search row was 2 px wider than the panel, and a resizable panel keeps the width its
// content used, so every repaint (any pointer move) widened it until it reached its maximum.
let mut h = harness();
add(&mut h, "Intro");
h.get_by_label("Search"); // the search row shows once there are bookmarks
let before = panel_width(&h);
for i in 0..30 {
h.hover_at(egui::pos2(300.0 + 10.0 * i as f32, 400.0));
h.run_steps(1);
}
assert_eq!(panel_width(&h), before, "the Bookmarks panel drifted wider");
}
#[test]
fn escape_cancels_a_rename() {
let mut h = harness();
+30
View File
@@ -264,6 +264,36 @@ fn the_panel_posts_comments_and_replies() {
h.get_by_label_contains("Agreed");
}
/// The side panel's width as egui keeps it from one frame to the next.
fn panel_width(h: &Harness<'static, PdfCraftApp>) -> f32 {
egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("right_panel")).expect("the side panel is shown").size().x
}
#[test]
fn the_panel_keeps_its_width_with_a_reply_box_open() {
// The reply row reserved 48 px for "Post"; Russian's wider button overflowed it, and a
// resizable panel keeps the width its content used, so every repaint widened the panel.
let mut h = harness(|app| app.set_option("panel", "comments").unwrap());
field(&h, "Add a comment").click();
h.run_steps(2);
field(&h, "Add a comment").type_text("General remark");
h.run_steps(1);
h.key_press(egui::Key::Enter);
h.run_steps(3);
field(&h, "Add a reply").click(); // the new comment is selected
h.run_steps(2);
field(&h, "Add a reply").type_text("Agreed");
h.run_steps(1);
h.state_mut().set_option("language", "ru").unwrap();
h.run_steps(3);
let before = panel_width(&h);
h.get_by_label("Опубликовать").hover();
for _ in 0..30 {
h.run_steps(1);
}
assert_eq!(panel_width(&h), before, "the Comments panel drifted wider");
}
#[test]
fn line_ending_properties_change_and_undo() {
let mut h = harness(|app| app.set_option("quick", "line").unwrap());