Merge pull request #86 from storytold/fix/74-add-text-keeps-boxes
Add text: clicking elsewhere keeps the text and starts a new box (#74)
This commit is contained in:
commit
376ffcd8e8
|
|
@ -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").
|
||||
|
|
|
|||
|
|
@ -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<Edit> {
|
||||
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<Edit>, 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<Edit> {
|
||||
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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,57 @@ fn added(h: &Harness<'static, PrintCraftApp>) -> Vec<printcraft_engine::Added> {
|
|||
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<String> {
|
||||
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!() };
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue