diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d6631fc5..71e0d5f4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1040,7 +1040,6 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { } let added = doc.added.clone(); let doc_links = if tool == QuickTool::Link { doc.links.clone() } else { Vec::new() }; - let mut content_done = false; if editing_content { crate::content_ui::after_refresh(view, &added); } else { @@ -1252,9 +1251,8 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { crate::redact_ui::page_input(ui, &resp, &xf, i, info, over_text, view) }; let on_content = editing_content && can_modify && { - let o = crate::content_ui::page_input(ui, &resp, &xf, i, info, &added, tool == QuickTool::AddText, &text_style, view); - content_done |= o.done; - o.consumed || tool == QuickTool::AddText + crate::content_ui::page_input(ui, &resp, &xf, i, info, &added, tool == QuickTool::AddText, &text_style, view) + || tool == QuickTool::AddText }; let on_edit_text = tool == QuickTool::EditText && can_modify && { let generation = doc.edit_generation(); @@ -1559,11 +1557,10 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, &author) { view.pending_edit = Some(e); } - let typed_text = view.content.draft.is_some(); - if let Some(e) = crate::content_ui::editor(ui.ctx(), view, info, &added) { - view.pending_edit = Some(e); + let (typed, text_done) = crate::content_ui::editor(ui.ctx(), view, info, &added); + if typed.is_some() { + view.pending_edit = typed; } - content_done |= typed_text && view.content.draft.is_none(); let form_notice = view.forms.notice.take(); // One crop, then back to selecting (as Acrobat does). let cropped = view.pending_edit.as_ref().is_some_and(|e| matches!(e, printcraft_engine::Edit::SetPageBox { .. })); @@ -1583,8 +1580,8 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { if stamp_placed { tool = QuickTool::Select; } - // One text box, then back to selecting (as Acrobat does). - if content_done && tool == QuickTool::AddText { + // Text: each click on the page starts a box (#74); Done in the editor goes back to selecting. + if text_done && tool == QuickTool::AddText { tool = QuickTool::Select; } // One field, then back to selecting (Acrobat's default without "Keep tools pinned"). diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 0ca6b85f..456e5d08 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -95,13 +95,8 @@ fn dragged(r: Rect, grab: Grab, d: egui::Vec2, keep_aspect: bool) -> Rect { } } -pub(crate) struct Outcome { - pub consumed: bool, - /// A text item was placed or edited: go back to Select. - pub done: bool, -} - -/// Pointer input on one page in Edit mode (`adding_text` with the Text tool). +/// Pointer input on one page in Edit mode (`adding_text` with the Text tool). `true` when it +/// used the input. #[allow(clippy::too_many_arguments)] pub(crate) fn page_input( ui: &egui::Ui, @@ -113,14 +108,14 @@ pub(crate) fn page_input( adding_text: bool, style: &AddedText, view: &mut DocView, -) -> Outcome { - let mut out = Outcome { consumed: false, done: false }; +) -> bool { + let mut consumed = false; let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())); let cv = &mut view.content; if let Some((gp, grab, start)) = cv.grab && gp == page { - out.consumed = true; + consumed = true; if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { cv.grab = None; let end = pointer.unwrap_or(start); @@ -142,9 +137,9 @@ pub(crate) fn page_input( view.pending_edit = Some(Edit::UpdateContent { page, index: si, content: a.content.with_rect(rect) }); } } - return out; + return consumed; } - let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return out }; + let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return consumed }; let items = on_page(added, page); let hit = items.iter().rev().find(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(p)).map(|(i, a)| (*i, *a)); let selected_rect = cv @@ -156,6 +151,11 @@ pub(crate) fn page_input( if adding_text && hit.is_none() { ui.ctx().set_cursor_icon(egui::CursorIcon::Text); if resp.clicked() { + // A click elsewhere keeps the text being typed and starts a new box (#74). The click + // can reach the page before the editor sees it lose focus, so finish it here. + let typed = cv.draft.take().and_then(|d| finish(cv, d, added)); + // The Format panel styles the new text, not the box just kept. + (cv.selected, cv.select_added) = (None, None); let at = to_display(xf, info, page, p); let mut style = style.clone(); style.text.clear(); @@ -167,9 +167,12 @@ pub(crate) fn page_input( style, focus: true, }); - out.consumed = true; + consumed = true; + if typed.is_some() { + view.pending_edit = typed; + } } - return out; + return consumed; } if let Some((_, left, top)) = corner { ui.ctx().set_cursor_icon(if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); @@ -180,13 +183,13 @@ pub(crate) fn page_input( let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); if let Some((_, left, top)) = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(origin) <= 6.0)) { cv.grab = Some((page, Grab::Corner(left, top), origin)); - out.consumed = true; + consumed = true; } else if let Some((i, _)) = items.iter().rev().find(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(origin)) { cv.selected = Some((page, *i)); cv.grab = Some((page, Grab::Move, origin)); - out.consumed = true; + consumed = true; } - return out; + return consumed; } if resp.double_clicked() && let Some((i, a)) = hit @@ -194,21 +197,20 @@ pub(crate) fn page_input( { cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true }); cv.selected = Some((page, i)); - out.consumed = true; - return out; + consumed = true; + return consumed; } if resp.clicked() { match hit { Some((i, _)) => { cv.selected = Some((page, i)); - out.consumed = true; + consumed = true; } None if corner.is_none() => cv.selected = None, - None => out.consumed = true, + None => consumed = true, } } - out.consumed |= hit.is_some() && resp.is_pointer_button_down_on(); - out + consumed || hit.is_some() && resp.is_pointer_button_down_on() } /// Outlines of added items (hover and selection with handles) and the box being dragged. @@ -265,49 +267,74 @@ pub(crate) fn after_refresh(view: &mut DocView, added: &[Added]) { } } -/// The in-place text editor. Returns the edit once committed (click away; Escape cancels). -pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, added: &[Added]) -> Option { - let d = view.content.draft.clone()?; - let xf = view.page_xform(d.page)?; - let r = screen_rect(&xf, info, d.page, d.rect); +/// The in-place text editor, with Done and Discard beside it. Returns the edit once the text is +/// committed (a click away, or Done), and whether Done finished adding text. Discard or Escape +/// throws the draft away. +pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, added: &[Added]) -> (Option, bool) { + let Some((page, rect)) = view.content.draft.as_ref().map(|d| (d.page, d.rect)) else { return (None, false) }; + let Some(xf) = view.page_xform(page) else { return (None, false) }; + let r = screen_rect(&xf, info, page, rect); let zoom = xf.rect.width() / xf.pw.max(1.0); - let (mut commit, mut cancel) = (false, false); + let tokens = Tokens::get(ctx); + let (mut commit, mut cancel, mut done) = (false, false, false); egui::Area::new(egui::Id::new(("added-text", view.id.0))).order(egui::Order::Foreground).fixed_pos(r.min).show(ctx, |ui| { let Some(t) = view.content.draft.as_mut() else { return }; let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8); - let resp = ui.add( - egui::TextEdit::multiline(&mut t.text) - .font(egui::FontId::proportional((t.style.size as f32 * zoom).max(8.0))) - .desired_width(r.width().max(60.0)) - .desired_rows(1) - .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 235)) - .text_color(Color32::from_rgb(cr, cg, cb)) - .hint_text("Type text") - .id_salt("added-text-edit"), - ); - if t.focus { - resp.request_focus(); - t.focus = false; - } - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { - cancel = true; - } else if resp.lost_focus() { - commit = true; - } + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let resp = ui.add( + egui::TextEdit::multiline(&mut t.text) + .font(egui::FontId::proportional((t.style.size as f32 * zoom).max(8.0))) + .desired_width(r.width().max(60.0)) + .desired_rows(1) + .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 235)) + .text_color(Color32::from_rgb(cr, cg, cb)) + .hint_text("Type text") + .id_salt("added-text-edit"), + ); + if t.focus { + resp.request_focus(); + t.focus = false; + } + let buttons = egui::Frame::NONE + .fill(tokens.card) + .stroke(Stroke::new(1.0, tokens.border)) + .corner_radius(CornerRadius::same(6)) + .inner_margin(2) + .show(ui, |ui| { + ui.spacing_mut().item_spacing = egui::vec2(2.0, 2.0); + ui.horizontal(|ui| { + done = crate::icons::button(ui, "check", 24.0, false, "Done adding text").clicked(); + cancel = crate::icons::button(ui, "x", 24.0, false, "Discard this text (Esc)").clicked(); + }); + }) + .response + .rect; + // Pressing a button takes the focus from the text: that is not a click away. + let on_buttons = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| buttons.contains(p)); + cancel |= ui.input(|i| i.key_pressed(egui::Key::Escape)); + commit = done || resp.lost_focus() && !on_buttons; + }); }); if cancel { view.content.draft = None; - return None; + return (None, false); } if !commit { - return None; + return (None, false); } - let d = view.content.draft.take()?; + let edit = view.content.draft.take().and_then(|d| finish(&mut view.content, d, added)); + (edit, done) +} + +/// A finished draft as an edit: new text is added (and selected once it exists), retyped text +/// updated, and text emptied by retyping removed. Empty new text adds nothing. +fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option { let text = d.text.trim_end().to_string(); match d.index { None if text.trim().is_empty() => None, None => { - view.content.select_added = Some((d.page, on_page(added, d.page).len())); + cv.select_added = Some((d.page, on_page(added, d.page).len())); Some(Edit::AddText { page: d.page, text: AddedText { rect: d.rect, text, ..d.style } }) } Some(i) if text.trim().is_empty() => Some(Edit::DeleteContent { page: d.page, index: i }), @@ -367,7 +394,9 @@ pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> /// How to use the tools, under the Add content list. pub(crate) fn hint(ui: &mut egui::Ui, t: &Tokens) { ui.label( - egui::RichText::new("Click on the page to add text. Drag items to move them, drag a corner to resize, double-click text to edit it.") + egui::RichText::new( + "Click on the page to add text; each click starts a new box, and ✓ finishes. Drag items to move them, drag a corner to resize, double-click text to edit it.", + ) .small() .color(t.text_faint), ); diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs index b0d6ee45..21fdea61 100644 --- a/crates/ui-egui/tests/content.rs +++ b/crates/ui-egui/tests/content.rs @@ -43,6 +43,57 @@ fn added(h: &Harness<'static, PrintCraftApp>) -> Vec { s.session.get(s.views[0].id).unwrap().added.clone() } +/// A click whose press and release arrive in the same frame, as a quick real mouse click does. +fn quick_click(h: &mut Harness<'static, PrintCraftApp>, p: Pos2) { + h.hover_at(p); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(3); +} + +fn texts(h: &Harness<'static, PrintCraftApp>) -> Vec { + added(h) + .into_iter() + .filter_map(|a| match a.content { + printcraft_engine::AddedContent::Text(t) => Some(t.text), + _ => None, + }) + .collect() +} + +#[test] +fn clicking_elsewhere_keeps_the_text_and_starts_a_new_box() { + // #74: a click elsewhere on the page threw away the text being typed and opened a new box. + let mut h = harness(); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(2); + let p = at(&h, 40.0, 100.0); + click(&mut h, p); + h.event(egui::Event::Text("First".into())); + h.run_steps(2); + // A slow click (press and release in different frames)… + let p = at(&h, 40.0, 200.0); + click(&mut h, p); + assert_eq!(texts(&h), ["First"]); + assert!(h.state().views[0].content.draft.is_some(), "a new box opened"); + assert_eq!(h.state().quick_tool, QuickTool::AddText, "still adding text"); + h.event(egui::Event::Text("Second".into())); + h.run_steps(2); + // …and a quick one (both in one frame, as a real mouse click usually arrives). + let p = at(&h, 40.0, 300.0); + quick_click(&mut h, p); + assert_eq!(texts(&h), ["First", "Second"]); + // Discard throws away only the box being typed. + h.event(egui::Event::Text("Third".into())); + h.run_steps(2); + h.get_by_label("Discard this text (Esc)").click(); + h.run_steps(3); + assert_eq!(texts(&h), ["First", "Second"]); + assert!(h.state().views[0].content.draft.is_none()); +} + #[test] fn typing_moving_styling_and_deleting_added_content() { let mut h = harness(); @@ -54,9 +105,9 @@ fn typing_moving_styling_and_deleting_added_content() { assert!(h.state().views[0].content.draft.is_some(), "the editor opened"); h.event(egui::Event::Text("Reviewed".into())); h.run_steps(2); - // Click elsewhere on the page commits and goes back to Select. - let p = at(&h, 250.0, 380.0); - click(&mut h, p); + // Done keeps the text and goes back to Select, with the new text selected. + h.get_by_label("Done adding text").click(); + h.run_steps(3); let a = added(&h); assert_eq!(a.len(), 1); let printcraft_engine::AddedContent::Text(t) = &a[0].content else { panic!() }; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index fb76e09a..91978823 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1938,8 +1938,8 @@ milestone = "M7" status = "shipped" commands = ["edit.text"] tools = ["page_add_text", "content_update"] -evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable"] -notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text" +evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable"] +notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box" [[feature]] id = "edit.find-replace"