diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c25dae98e30..9037ae55c42 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -71,6 +71,7 @@ pub use editing::{CloseRequest, SaveTarget}; pub use files::{ExtractDraft, FilePurpose, FileRequest, RotateDraft, SplitDraft, SplitMode, SplitPlan}; pub use recovery::{AUTOSAVE_SECS, RecoveryMeta, RecoveryStore}; use theme::{ThemeKind, ThemePreference}; +pub use widgets::ignore_system_text_clipboard; mod app; mod hooks; diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index d54daf57175..df21430f25d 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -129,6 +129,7 @@ pub(crate) fn show( let r = ui .add(crate::widgets::line(q).hint_text(tl!("Search comments")).desired_width(f32::INFINITY).id_salt("comment-search")) .accessible_name(tl!("Search comments")); + crate::widgets::text_edit_context_menu(&r, q, &mut view.comments.clipboard, true); if view.comments.search_focus { r.request_focus(); view.comments.search_focus = false; @@ -145,6 +146,7 @@ pub(crate) fn show( .id_salt("comment-add-box"), ) .accessible_name(tl!("Add a comment")); + crate::widgets::text_edit_context_menu(&r, &mut view.comments.add_box, &mut view.comments.clipboard, true); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !view.comments.add_box.trim().is_empty() { let page = view.current.min(info.pages.len().saturating_sub(1)); if let Some(p) = info.pages.get(page) { @@ -510,14 +512,17 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti // 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( - crate::widgets::block(&mut view.comments.reply) - .desired_rows(1) - .return_key(egui::KeyboardShortcut::new(egui::Modifiers::SHIFT, egui::Key::Enter)) - .hint_text(tl!("Add a reply")) - .desired_width(f32::INFINITY) - .id_salt(("comment-reply", key)), - ); + let r = ui + .add( + crate::widgets::block(&mut view.comments.reply) + .desired_rows(1) + .return_key(egui::KeyboardShortcut::new(egui::Modifiers::SHIFT, egui::Key::Enter)) + .hint_text(tl!("Add a reply")) + .desired_width(f32::INFINITY) + .id_salt(("comment-reply", key)), + ) + .accessible_name(tl!("Add a reply")); + crate::widgets::text_edit_context_menu(&r, &mut view.comments.reply, &mut view.comments.clipboard, false); let enter = crate::comments::submit_key(ui, &r, true); let ok = !view.comments.reply.trim().is_empty(); let post = @@ -616,6 +621,8 @@ fn inline_editor(ui: &mut egui::Ui, t: &Tokens, view: &mut DocView, key: (usize, let mut edit = None; let (_, _, text) = view.comments.editing.as_mut()?; let r = ui.add(crate::widgets::block(text).desired_rows(rows).desired_width(f32::INFINITY).id_salt(("comment-edit", key))); + let popup_was_open = egui::Popup::is_any_open(ui.ctx()); + crate::widgets::text_edit_context_menu(&r, text, &mut view.comments.clipboard, false); match view.comments.editor_focus.take() { Some(crate::comments::EditorFocus::Take) => r.request_focus(), Some(crate::comments::EditorFocus::Release) => r.surrender_focus(), @@ -628,7 +635,7 @@ fn inline_editor(ui: &mut egui::Ui, t: &Tokens, view: &mut DocView, key: (usize, { edit = Some(Edit::SetAnnotationContents { page, index, text }); } - if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + if ui.button(tl!("Cancel")).clicked() || (ui.input(|i| i.key_pressed(egui::Key::Escape)) && !popup_was_open) { view.comments.editing = None; } }); diff --git a/crates/ui-egui/src/tools/comments/composer.rs b/crates/ui-egui/src/tools/comments/composer.rs index fa2d7f9b1ae..dab92f444e2 100644 --- a/crates/ui-egui/src/tools/comments/composer.rs +++ b/crates/ui-egui/src/tools/comments/composer.rs @@ -1,6 +1,6 @@ use super::geometry::{drawn_text_box_rect, page_rotation, text_box_rect}; use super::paint::{edited_in_view, new_comment}; -use super::state::{ComposerKind, PageCx}; +use super::state::{CommentView, ComposerKind, PageCx}; use super::{ Color32, CommentPrefs, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NoteIcon, PagePointer, QuickTool, Shape, Tokens, pos2, theme, @@ -33,7 +33,9 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, let t = Tokens::get(ctx); let mut post = false; let mut cancel = false; - let c = view.comments.composer.as_mut()?; + let CommentView { composer: Some(c), clipboard, .. } = &mut view.comments else { + return None; + }; let title = match c.kind { ComposerKind::Note => "Sticky note", ComposerKind::TextBox { .. } => "Text box", @@ -74,12 +76,14 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, .add(egui::TextEdit::multiline(&mut c.text).hint_text(hint).desired_rows(3).desired_width(f32::INFINITY).id_salt("composer-text")) // 0.37 kittest rejects an unnamed text input; the hint is its logical name. .accessible_name(hint); + let popup_was_open = egui::Popup::is_any_open(ui.ctx()); + crate::widgets::text_edit_context_menu(&edit, &mut c.text, clipboard, false); if c.focus { edit.request_focus(); c.focus = false; } let enter = submit_key(ui, &edit, false); - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + if ui.input(|i| i.key_pressed(egui::Key::Escape)) && !popup_was_open { cancel = true; } ui.add_space(8.0); diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index 6cac912210e..e9fc39f7212 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -175,6 +175,8 @@ pub struct CommentView { pub tool_done: bool, /// What the inline editor (`editing`) does with the keyboard the next time it is drawn. pub(crate) editor_focus: Option, + /// In-process text clipboard fallback for comment boxes. + pub clipboard: String, } /// A one-off change to the keyboard focus of the Comments panel's inline editor. The editor diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5e33f9cd9a4..76b67666fbc 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -75,6 +75,139 @@ pub fn block(text: &mut dyn egui::TextBuffer) -> egui::TextEdit<'_> { egui::TextEdit::multiline(text).margin(egui::Margin::symmetric(10, 6)) } +/// Key stored in [`egui::Context`] data when a test overrides the system text clipboard with the +/// in-process buffer. +const IGNORE_SYSTEM_CLIPBOARD_ID: &str = "pdfcraft-ignore-system-text-clipboard"; + +/// Disables reading the host OS text clipboard in [`text_edit_context_menu`] for the given +/// [`egui::Context`], so headless tests rely solely on the in-process clipboard buffer. +pub fn ignore_system_text_clipboard(ctx: &egui::Context) { + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(IGNORE_SYSTEM_CLIPBOARD_ID), true)); +} + +/// Converts a character offset in `s` to its UTF-8 byte offset, clamping to `s.len()` when +/// `char_idx` reaches or exceeds the character count. +fn char_to_byte(s: &str, char_idx: usize) -> usize { + s.char_indices().nth(char_idx).map_or(s.len(), |(idx, _)| idx) +} + +/// Reads the current text from the system clipboard when available, returning an empty string on +/// failure, on platforms without system clipboard access, or when disabled for `ctx`. +fn system_clipboard_text(ctx: &egui::Context) -> String { + if ctx.data(|d| d.get_temp::(egui::Id::unique(IGNORE_SYSTEM_CLIPBOARD_ID)).unwrap_or(false)) { + return String::new(); + } + #[cfg(not(target_arch = "wasm32"))] + { + crate::os_clipboard::with(|cb| cb.get_text()).unwrap_or_default() + } + #[cfg(target_arch = "wasm32")] + { + String::new() + } +} + +/// Attaches a standard text-editing context menu (Cut, Copy, Paste, Select All) to a +/// `TextEdit` response, using `clipboard` as the in-process fallback alongside the system +/// clipboard. When `single_line` is true, pasted line breaks are replaced with spaces. +pub(crate) fn text_edit_context_menu(resp: &Response, text: &mut String, clipboard: &mut String, single_line: bool) { + resp.context_menu(|ui| { + let has_text = !text.is_empty(); + let total_chars = text.chars().count(); + let state = egui::text_edit::TextEditState::load(ui.ctx(), resp.id); + let raw_range = state + .as_ref() + .and_then(|s| s.cursor.char_range()) + .map(|r| { + let [min, max] = r.sorted_cursors(); + (usize::from(min.index).min(total_chars), usize::from(max.index).min(total_chars)) + }); + let selection = raw_range.filter(|(min, max)| min < max); + + if ui.add_enabled(has_text, egui::Button::new(tl!("Cut"))).clicked() { + let (copied, next_caret) = if let Some((start_char, end_char)) = selection { + let start_byte = char_to_byte(text, start_char); + let end_byte = char_to_byte(text, end_char); + let piece = text.get(start_byte..end_byte).unwrap_or_default().to_owned(); + if let (Some(before), Some(after)) = (text.get(..start_byte), text.get(end_byte..)) { + let mut next = String::with_capacity(before.len().saturating_add(after.len())); + next.push_str(before); + next.push_str(after); + *text = next; + } + (piece, start_char.min(text.chars().count())) + } else { + (std::mem::take(text), 0) + }; + if !copied.is_empty() { + clipboard.clone_from(&copied); + ui.ctx().copy_text(copied); + } + let mut st = state.clone().unwrap_or_default(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(next_caret)))); + st.store(ui.ctx(), resp.id); + resp.request_focus(); + ui.close(); + } + + if ui.add_enabled(has_text, egui::Button::new(tl!("Copy"))).clicked() { + let copied = if let Some((start_char, end_char)) = selection { + let start_byte = char_to_byte(text, start_char); + let end_byte = char_to_byte(text, end_char); + text.get(start_byte..end_byte).unwrap_or_default().to_owned() + } else { + text.clone() + }; + if !copied.is_empty() { + clipboard.clone_from(&copied); + ui.ctx().copy_text(copied); + } + resp.request_focus(); + ui.close(); + } + + if ui.button(tl!("Paste")).clicked() { + let system = system_clipboard_text(ui.ctx()); + let raw = if system.is_empty() { clipboard.clone() } else { system }; + let pasted = + if single_line { raw.replace("\r\n", " ").replace(['\r', '\n'], " ") } else { raw.replace("\r\n", "\n").replace('\r', "\n") }; + if !pasted.is_empty() { + let (start_char, end_char) = state + .as_ref() + .and_then(|s| s.cursor.char_range()) + .map(|r| { + let [min, max] = r.sorted_cursors(); + (usize::from(min.index).min(total_chars), usize::from(max.index).min(total_chars)) + }) + .unwrap_or((total_chars, total_chars)); + let start_byte = char_to_byte(text, start_char); + let end_byte = char_to_byte(text, end_char); + if let (Some(before), Some(after)) = (text.get(..start_byte), text.get(end_byte..)) { + let mut next = String::with_capacity(before.len().saturating_add(pasted.len()).saturating_add(after.len())); + next.push_str(before); + next.push_str(&pasted); + next.push_str(after); + *text = next; + } + let next_caret = start_char.saturating_add(pasted.chars().count()); + let mut st = state.clone().unwrap_or_default(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(next_caret)))); + st.store(ui.ctx(), resp.id); + } + resp.request_focus(); + ui.close(); + } + + if ui.add_enabled(has_text, egui::Button::new(tl!("Select All"))).clicked() { + let mut st = state.unwrap_or_default(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(total_chars)))); + st.store(ui.ctx(), resp.id); + resp.request_focus(); + ui.close(); + } + }); +} + /// One line that stays within `max_width`, cut on a character boundary with an ellipsis. /// Only the first 160 characters are measured, so a huge name cannot lay out thousands of candidates. pub(crate) fn fit_line(text: &str, max_width: f32, mut width_of: impl FnMut(&str) -> f32) -> String { @@ -984,4 +1117,17 @@ mod tests { assert_eq!(fit_line(long, 0.0, width), ""); assert_eq!(fit_line("ééé", 2.0, width), "é\u{2026}"); } + + /// Verifies that character-to-byte conversion preserves multi-byte UTF-8 boundaries and clamps + /// out-of-range indices to the string length. + #[test] + fn char_to_byte_respects_multibyte_boundaries_and_clamps_out_of_bounds() { + let s = "Ağaç 🌳"; + assert_eq!(char_to_byte(s, 0), 0); + assert_eq!(char_to_byte(s, 1), 1); + assert_eq!(char_to_byte(s, 2), 3); + assert_eq!(char_to_byte(s, 5), 7); + assert_eq!(char_to_byte(s, 6), s.len()); + assert_eq!(char_to_byte(s, 99), s.len()); + } } diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 68057324227..595ff118ff1 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -87,7 +87,12 @@ fn click_pt(h: &mut Harness<'static, PdfCraftApp>, p: (f32, f32)) { /// A text field by its placeholder ("Add a comment", "Add a reply"). fn field<'a>(h: &'a Harness<'static, PdfCraftApp>, placeholder: &'a str) -> egui_kittest::Node<'a> { - h.query_all_by(|n| n.placeholder() == Some(placeholder)).next().unwrap_or_else(|| panic!("no field {placeholder:?}")) + h.query_all_by(|n| { + matches!(n.role(), egui::accesskit::Role::TextInput | egui::accesskit::Role::MultilineTextInput) + && (n.placeholder() == Some(placeholder) || n.label().as_deref() == Some(placeholder)) + }) + .next() + .unwrap_or_else(|| panic!("no field {placeholder:?}")) } fn comments(h: &Harness<'static, PdfCraftApp>) -> Vec { @@ -3248,3 +3253,77 @@ fn hotbar_full_side_strip_spans_each_edge_and_returns_to_float() { let size = h.ctx.data_mut(|d| d.get_temp::(egui::Id::unique("custom-hotbar"))).unwrap(); assert!(size.x < 150.0 && size.y < 150.0, "floating toolbar is compact: {size:?}"); } + +/// #4771: right-clicking comment input fields offers Cut, Copy, Paste and Select All. +#[test] +fn comment_boxes_offer_cut_copy_paste_and_select_all_context_menu() { + let mut h = harness(|app| app.set_option("panel", "comments").unwrap()); + pdfcraft_ui_egui::ignore_system_text_clipboard(&h.ctx); + h.state_mut().views[0].comments.clipboard = "Pasted note".into(); + + // Right-click "Add a comment" in the Comments panel and paste. + field(&h, "Add a comment").click_secondary(); + h.run_steps(2); + h.get_by_label("Paste").click(); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.add_box, "Pasted note"); + + // Select All via context menu, then press Enter to post the sticky note. + field(&h, "Add a comment").click_secondary(); + h.run_steps(2); + h.get_by_label("Select All").click(); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(comments(&h).len(), 1); + assert_eq!(comments(&h)[0].contents.as_deref(), Some("Pasted note")); + + // Right-click "Add a reply" under the selected comment card and paste. + field(&h, "Add a reply").click_secondary(); + h.run_steps(2); + h.get_by_label("Paste").click(); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.reply, "Pasted note"); + + // Replace the reply text, Cut it via the context menu, and verify clipboard + cleared field. + h.state_mut().views[0].comments.reply = "Cut from reply".into(); + h.run_steps(2); + field(&h, "Add a reply").click_secondary(); + h.run_steps(2); + h.get_by_label("Cut").click(); + let mut emitted_copy = false; + for _ in 0..3 { + h.step(); + emitted_copy |= h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == "Cut from reply")); + } + assert!(emitted_copy, "Cut emits CopyText"); + assert!(h.state().views[0].comments.reply.is_empty()); + assert_eq!(h.state().views[0].comments.clipboard, "Cut from reply"); + + // Open the floating composer on the page, right-click its text box, and paste. + h.state_mut().set_option("quick", "freetext").unwrap(); + h.run_steps(2); + click_pt(&mut h, (250.0, 180.0)); + field(&h, "Type text").click_secondary(); + h.run_steps(2); + h.get_by_label("Paste").click(); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.composer.as_ref().map(|c| c.text.as_str()), Some("Cut from reply")); + + // Copy from the floating composer after modifying its text and paste into "Search comments". + h.state_mut().views[0].comments.composer.as_mut().unwrap().text = "Copied from composer".into(); + h.run_steps(2); + field(&h, "Type text").click_secondary(); + h.run_steps(2); + h.get_by_label("Copy").click(); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.clipboard, "Copied from composer"); + + h.state_mut().views[0].comments.search = Some(String::new()); + h.run_steps(2); + field(&h, "Search comments").click_secondary(); + h.run_steps(2); + h.get_by_label("Paste").click(); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.search.as_deref(), Some("Copied from composer")); +}