diff --git a/ROADMAP.md b/ROADMAP.md index 0df694934..74de01c99 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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%). diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index 7ecb4b597..d49582882 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -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. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 5fa525c85..fff91f211 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -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::(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(); } }); diff --git a/crates/ui-egui/tests/bookmarks.rs b/crates/ui-egui/tests/bookmarks.rs index 0477c306f..7630b509d 100644 --- a/crates/ui-egui/tests/bookmarks.rs +++ b/crates/ui-egui/tests/bookmarks.rs @@ -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(); diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index ff85148ab..ae5eb1c76 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -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());