Files
pdfcraft/crates/ui-egui/src/edit_text_ui.rs
T
Krzysztof CieślikandBrandon Thomas f54aa1db1e fix(editing): recognize paragraph drafts as unsaved work (#811) (#843)
* view: check Shift-Command-G before Command-G for find navigation (#810)

* fix: recognize paragraph drafts as unsaved work (Issue #811)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 15:11:19 -04:00

567 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Edit a PDF ▸ Edit text & images: boxes around the paragraphs and images already on the page.
//! Click a paragraph to edit it in place (⌘Enter or clicking away applies and rewraps it to the
//! box, Esc cancels); drag it to move it, or drag the handle on its left or right edge to rewrap
//! it to a new width. Click an image to select it: drag to move, drag a corner to resize (keeping
//! its proportions), right-click for rotate, flip, replace, save and delete; Delete removes it.
use egui::{Color32, CornerRadius, FontFamily, FontId, Pos2, Rect, Stroke};
use pdfcraft_engine::Edit;
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageXform};
const ACCENT: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
/// A paragraph being edited.
#[derive(Clone, Debug, PartialEq)]
pub struct LineEditor {
pub page: usize,
pub block: usize,
pub text: String,
original: String,
rect: Rect,
/// The original PDF rectangle, in user space. It is converted again each frame so the editor
/// stays attached while the page is zoomed, scrolled, or rotated.
source_rect: [f32; 4],
multiline: bool,
/// How far the editor box may grow to the right (screen pixels): a single-line paragraph
/// rewraps growing to the page's edge, a multi-line one keeps its width.
max_width: f32,
/// Current screen-space size. Recomputed from the current page transform before painting.
size: f32,
focus: bool,
/// The Format text panel's values, and what the paragraph had (to send only changes).
pub look: pdfcraft_engine::AddedText,
look0: pdfcraft_engine::AddedText,
/// Underline, line spacing (× size; 0 = the paragraph's own), character spacing (pt) and
/// horizontal scale (%), and what they were.
pub extras: Extras,
extras0: Extras,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Extras {
pub underline: bool,
pub line_spacing: f64,
pub char_spacing: f64,
pub scale: f64,
}
impl Default for Extras {
fn default() -> Self {
Extras { underline: false, line_spacing: 0.0, char_spacing: 0.0, scale: 100.0 }
}
}
/// Underline, line spacing, character spacing and horizontal scale for the paragraph being
/// edited (under Format text). Returns `true` when something changed.
pub(crate) fn extras_panel(ui: &mut egui::Ui, e: &mut Extras) -> bool {
let before = *e;
ui.horizontal(|ui| {
if crate::icons::button(ui, "underline", 26.0, e.underline, tl!("Underline")).clicked() {
e.underline = !e.underline;
}
let zero = tl!("Line spacing").to_string();
let label = |v: f64| if v == 0.0 { zero.clone() } else { format!("{v:.2}×") };
egui::ComboBox::from_id_salt("line-spacing").selected_text(label(e.line_spacing)).width(110.0).show_ui(ui, |ui| {
for v in [1.0, 1.15, 1.5, 2.0] {
ui.selectable_value(&mut e.line_spacing, v, label(v));
}
});
});
ui.horizontal(|ui| {
let l = ui.label(tl!("Character spacing"));
ui.add(egui::DragValue::new(&mut e.char_spacing).range(-5.0..=50.0).speed(0.1).suffix(" pt")).labelled_by(l.id);
});
ui.horizontal(|ui| {
let l = ui.label(tl!("Horizontal scale"));
ui.add(egui::DragValue::new(&mut e.scale).range(10.0..=400.0).speed(1.0).suffix(" %")).labelled_by(l.id);
});
*e != before
}
impl LineEditor {
/// How far the box may grow to the right, when the paragraph is a single line (a multi-line
/// paragraph rewraps to its own width and the box doesn't grow).
pub fn growth(&self) -> Option<f32> {
(self.max_width > self.rect.width()).then_some(self.max_width)
}
/// Whether the edited text differs from the original (unsaved changes).
pub fn has_unsaved_text(&self) -> bool {
self.text != self.original
}
/// The formatting the panel changed.
pub fn style(&self) -> pdfcraft_engine::BlockStyle {
let (l, o) = (&self.look, &self.look0);
pdfcraft_engine::BlockStyle {
family: (l.family != o.family || l.bold != o.bold || l.italic != o.italic).then_some((l.family, l.bold, l.italic)),
size: (l.size != o.size).then_some(l.size),
color: (l.color != o.color).then_some(l.color),
align: (l.align != o.align).then_some(l.align),
underline: (self.extras.underline != self.extras0.underline).then_some(self.extras.underline),
line_spacing: (self.extras.line_spacing != self.extras0.line_spacing && self.extras.line_spacing > 0.0)
.then_some(self.extras.line_spacing),
char_spacing: (self.extras.char_spacing != self.extras0.char_spacing).then_some(self.extras.char_spacing),
scale: (self.extras.scale != self.extras0.scale).then_some(self.extras.scale),
..Default::default()
}
}
/// After applying formatting: the paragraph now has it.
pub fn applied(&mut self) {
self.look0 = self.look.clone();
self.extras0 = self.extras;
self.original = self.text.clone();
self.focus = true;
}
/// Adopt the rewritten paragraph's current geometry before the next overlay frame.
pub(crate) fn refresh_source(&mut self, block: &pdfcraft_engine::TextBlock) {
self.source_rect = block.rect.map(|v| v as f32);
self.multiline = block.lines.len() > 1;
}
}
/// The look shown for a paragraph: the family and weight guessed from its PDF font name.
fn source_look(base_font: &str, size: f64, color: [f64; 3], detected_bold: bool, detected_italic: bool) -> pdfcraft_engine::AddedText {
use pdfcraft_engine::FontFamily as F;
let name = base_font.to_ascii_lowercase();
let family = if ["courier", "mono", "consolas", "menlo", "monaco", "lucida console"].iter().any(|s| name.contains(s)) {
F::Courier
} else if !name.contains("sans") && ["times", "serif", "roman", "cambria", "georgia", "palatino", "garamond"].iter().any(|s| name.contains(s)) {
F::Times
} else {
F::Helvetica
};
pdfcraft_engine::AddedText {
family,
bold: detected_bold || ["bold", "black", "heavy", "semibold", "demi"].iter().any(|s| name.contains(s)),
italic: detected_italic || ["italic", "oblique", "slanted"].iter().any(|s| name.contains(s)),
size: (size * 10.0).round() / 10.0,
color,
..Default::default()
}
}
fn look_of(b: &pdfcraft_engine::TextBlock) -> pdfcraft_engine::AddedText {
source_look(&b.base_font, b.size, b.color, b.bold, b.italic)
}
fn editor_font(look: &pdfcraft_engine::AddedText, size: f32) -> FontId {
let family = match (look.family, look.bold) {
(pdfcraft_engine::FontFamily::Courier, _) => FontFamily::Monospace,
(_, true) => FontFamily::Name("semibold".into()),
(_, false) => FontFamily::Proportional,
};
FontId::new(size, family)
}
fn color32(color: [f64; 3]) -> Color32 {
Color32::from_rgb(
(color[0].clamp(0.0, 1.0) * 255.0).round() as u8,
(color[1].clamp(0.0, 1.0) * 255.0).round() as u8,
(color[2].clamp(0.0, 1.0) * 255.0).round() as u8,
)
}
/// A selected page image, and what the pointer is doing to it.
#[derive(Clone, Debug, PartialEq)]
pub struct ImageSelection {
pub page: usize,
pub index: usize,
/// Dragging: the start point and, for a corner, the opposite corner (screen).
drag: Option<(Pos2, Option<Pos2>)>,
}
/// What a drag on a paragraph box takes hold of.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Grip {
Move,
/// An edge (screen left or right), dragged to rewrap the paragraph.
Left,
Right,
}
/// A paragraph box being dragged: moved, or (from a handle on its left or right edge) resized.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BlockDrag {
page: usize,
block: usize,
/// Where the drag started (screen).
start: Pos2,
grip: Grip,
}
/// How far an edge grip reaches either side of a box's edge (screen pixels).
const EDGE_REACH: f32 = 6.0;
/// The rewrap handles drawn on a paragraph box's left and right edges (screen).
fn width_handles(b: Rect) -> [Rect; 2] {
let h = (b.height() * 0.5).clamp(14.0, 28.0);
[b.left(), b.right()].map(|x| Rect::from_center_size(Pos2::new(x, b.center().y), egui::vec2(7.0, h)))
}
/// The edge of `b` that `p` grabs: anywhere along its height, within reach of the line. A narrow
/// box keeps its middle for moving.
fn edge_at(b: Rect, p: Pos2) -> Option<Grip> {
if p.y < b.top() - EDGE_REACH || p.y > b.bottom() + EDGE_REACH {
return None;
}
let inside = EDGE_REACH.min(b.width() / 4.0);
let (dl, dr) = (p.x - b.left(), b.right() - p.x);
match (dl >= -EDGE_REACH && dl <= inside, dr >= -EDGE_REACH && dr <= inside) {
(true, true) if dl <= dr => Some(Grip::Left),
(_, true) => Some(Grip::Right),
(true, false) => Some(Grip::Left),
_ => None,
}
}
/// The topmost box under `p` and what it grabs there; edges only on an upright page.
fn grab_at(boxes: &[Rect], p: Pos2, upright: bool) -> Option<(usize, Grip)> {
boxes.iter().enumerate().rev().find_map(|(i, b)| match upright.then(|| edge_at(*b, p)).flatten() {
Some(g) => Some((i, g)),
None => b.contains(p).then_some((i, Grip::Move)),
})
}
/// What a right-click on a selected image asks for.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum ImageAction {
Replace(usize, usize),
Save(usize, usize),
}
fn user_box(xf: &PageXform, info: &DocInfo, page: usize, r: Rect) -> [f64; 4] {
let p = &info.pages[page];
let (a, b) = (xf.screen_to_view(r.min), xf.screen_to_view(r.max));
let (u, v) = (p.view_to_user(a.0, a.1), p.view_to_user(b.0, b.1));
[u[0].min(v[0]) as f64, u[1].min(v[1]) as f64, u[0].max(v[0]) as f64, u[1].max(v[1]) as f64]
}
/// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used.
#[allow(clippy::too_many_arguments)]
pub(crate) fn image_input(
ui: &egui::Ui,
resp: &egui::Response,
xf: &PageXform,
page: usize,
info: &DocInfo,
images: &[pdfcraft_engine::PageImage],
view: &mut DocView,
action: &mut Option<ImageAction>,
) -> bool {
let boxes: Vec<Rect> = images.iter().map(|im| xf.user_rect(info, page, im.rect.map(|v| v as f32))).collect();
let painter = ui.painter();
for b in &boxes {
painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside);
}
let pointer = ui.input(|i| i.pointer.hover_pos());
let selected = view.image_selection.as_ref().filter(|s| s.page == page).map(|s| s.index).filter(|i| *i < boxes.len());
for (i, b) in boxes.iter().enumerate() {
let selected_box = selected == Some(i);
let hovered = ui.input(|inp| inp.pointer.hover_pos()).is_some_and(|p| b.contains(p));
let stroke = if selected_box {
Stroke::new(1.5, ACCENT)
} else if hovered {
Stroke::new(1.5, ACCENT.gamma_multiply(0.7))
} else {
Stroke::new(0.75, ACCENT.gamma_multiply(0.35))
};
if selected_box {
painter.rect_filled(*b, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04));
}
painter.rect_stroke(*b, CornerRadius::ZERO, stroke, egui::StrokeKind::Outside);
}
// The selected image: frame, corner handles, dragging.
if let Some(i) = selected {
let b = boxes[i];
painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
let corners = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()];
for c in corners {
painter.rect(
Rect::from_center_size(c, egui::vec2(8.0, 8.0)),
CornerRadius::ZERO,
Color32::WHITE,
Stroke::new(1.0, ACCENT),
egui::StrokeKind::Middle,
);
}
let origin = ui.input(|i| i.pointer.press_origin());
if resp.drag_started()
&& let Some(o) = origin
{
let corner = corners.iter().position(|c| c.distance(o) < 8.0);
if corner.is_some() || b.contains(o) {
let opposite = corner.map(|k| corners[3 - k]);
if let Some(s) = view.image_selection.as_mut() {
s.drag = Some((o, opposite));
}
}
}
if let Some((start, opposite)) = view.image_selection.as_ref().and_then(|s| s.drag)
&& let Some(p) = pointer
{
let preview = match opposite {
// Resize from the opposite corner, keeping the aspect ratio.
Some(fixed) => {
let (w0, h0) = (b.width().max(1.0), b.height().max(1.0));
let k = ((p.x - fixed.x).abs() / w0).max((p.y - fixed.y).abs() / h0).max(0.05);
let (w, h) = (w0 * k, h0 * k);
let x = if p.x < fixed.x { fixed.x - w } else { fixed.x };
let y = if p.y < fixed.y { fixed.y - h } else { fixed.y };
Rect::from_min_size(Pos2::new(x, y), egui::vec2(w, h))
}
None => b.translate(p - start),
};
painter.rect_stroke(preview, CornerRadius::ZERO, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle);
if resp.drag_stopped() {
if let Some(s) = view.image_selection.as_mut() {
s.drag = None;
}
if preview != b {
view.pending_edit =
Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, preview)) });
}
}
return true;
}
if ui.input(|inp| inp.key_pressed(egui::Key::Delete) || inp.key_pressed(egui::Key::Backspace)) && !ui.ctx().egui_wants_keyboard_input() {
view.image_selection = None;
view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Delete });
return true;
}
}
let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false };
let Some(hit) = boxes.iter().rposition(|b| b.contains(p)) else { return false };
if selected != Some(hit) {
painter.rect_stroke(boxes[hit], CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside);
}
ui.ctx().set_cursor_icon(if selected == Some(hit) { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand });
if resp.clicked() || resp.secondary_clicked() {
view.image_selection = Some(ImageSelection { page, index: hit, drag: None });
}
if selected == Some(hit) {
resp.context_menu(|ui| {
use pdfcraft_engine::ImageEdit as E;
let items: [(&str, Option<E>); 4] = [
(tl!("Rotate Clockwise"), Some(E::Rotate(1))),
(tl!("Rotate Counterclockwise"), Some(E::Rotate(3))),
(tl!("Flip Horizontal"), Some(E::Flip { horizontal: true })),
(tl!("Flip Vertical"), Some(E::Flip { horizontal: false })),
];
for (label, change) in items {
if ui.button(label).clicked() {
view.pending_edit = change.map(|c| Edit::EditPageImage { page, index: hit, change: c });
ui.close();
}
}
let raster = images.get(hit).is_some_and(|image| !image.is_form);
if ui.add_enabled(raster, egui::Button::new(tl!("Replace Image…"))).clicked() {
*action = Some(ImageAction::Replace(page, hit));
ui.close();
}
if ui.add_enabled(raster, egui::Button::new(tl!("Save Image As…"))).clicked() {
*action = Some(ImageAction::Save(page, hit));
ui.close();
}
ui.separator();
if ui.button(tl!("Delete")).clicked() {
view.image_selection = None;
view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Delete });
ui.close();
}
});
}
true
}
/// Lines and their screen boxes for a page; hover outlines, click opens the editor. Returns
/// `true` when the pointer was used.
pub(crate) fn page_input(
ui: &egui::Ui,
resp: &egui::Response,
xf: &PageXform,
page: usize,
info: &DocInfo,
lines: &[pdfcraft_engine::TextBlock],
view: &mut DocView,
) -> bool {
let boxes: Vec<Rect> = lines.iter().map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0)).collect();
let painter = ui.painter();
let active = view.line_editor.as_ref().filter(|e| e.page == page).map(|e| e.block);
for (i, b) in boxes.iter().enumerate() {
if active == Some(i) {
painter.rect_filled(b.expand(1.0), CornerRadius::same(2), ACCENT.gamma_multiply(0.08));
painter.rect_stroke(*b, CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
} else {
painter.rect_stroke(*b, CornerRadius::same(2), Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside);
}
}
// The width handle needs the page upright (or upside down): on a quarter-turned page the
// screen's horizontal is the paragraph's vertical.
let upright = info.pages.get(page).is_some_and(|p| p.rotation % 180 == 0);
let draw_handles = |b: Rect| {
for h in width_handles(b) {
painter.rect(h, CornerRadius::same(2), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle);
}
};
// A drag in progress: the box follows the pointer (or the grabbed edge does); releasing applies it.
if let Some(d) = view.block_drag.filter(|d| d.page == page)
&& let (Some(b), Some(l)) = (boxes.get(d.block).copied(), lines.get(d.block))
{
let p = ui.input(|i| i.pointer.interact_pos()).unwrap_or(d.start);
let dx = p.x - d.start.x;
let preview = match d.grip {
Grip::Move => b.translate(p - d.start),
Grip::Left => Rect::from_min_max(Pos2::new((b.left() + dx).min(b.right() - 12.0), b.min.y), b.max),
Grip::Right => Rect::from_min_max(b.min, Pos2::new((b.right() + dx).max(b.left() + 12.0), b.max.y)),
};
painter.rect_stroke(preview, CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
let resizing = d.grip != Grip::Move;
if resizing {
draw_handles(preview);
}
ui.ctx().set_cursor_icon(if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Grabbing });
if resp.drag_stopped() || !ui.input(|i| i.pointer.any_down()) {
view.block_drag = None;
let (from, to) = (user_box(xf, info, page, b), user_box(xf, info, page, preview));
let style = if resizing {
// The new width in user space: the box's change, added to the paragraph's own. A
// moved left side (in user space; on an upside-down page that's the screen's right
// edge) moves the paragraph with it.
let width = (l.rect[2] - l.rect[0]) + (to[2] - to[0]) - (from[2] - from[0]);
let shift = to[0] - from[0];
pdfcraft_engine::BlockStyle { width: Some(width), offset: (shift != 0.0).then_some([shift, 0.0]), ..Default::default() }
} else {
pdfcraft_engine::BlockStyle { offset: Some([to[0] - from[0], to[1] - from[1]]), ..Default::default() }
};
if preview != b {
view.pending_edit = Some(Edit::EditTextBlock { page, block: d.block, text: l.text.clone(), style });
}
}
return true;
}
// Dragging a box (not while a paragraph is open for typing) moves it; from an edge, resizes it.
// What it grabs comes from where the button went down, not where the pointer is now: egui
// only calls it a drag once the pointer has moved a few pixels (or been held a moment), and a
// quick flick has left the edge by then.
if resp.drag_started()
&& active.is_none()
&& let Some(o) = ui.input(|i| i.pointer.press_origin())
&& let Some((block, grip)) = grab_at(&boxes, o, upright)
{
view.block_drag = Some(BlockDrag { page, block, start: o, grip });
return true;
}
let Some(p) = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) else { return false };
let Some((hit, grip)) = grab_at(&boxes, p, upright) else { return false };
if active != Some(hit) {
painter.rect_stroke(boxes[hit], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
}
let on_edge = active.is_none() && grip != Grip::Move;
if upright && active.is_none() {
draw_handles(boxes[hit]);
}
ui.ctx().set_cursor_icon(if on_edge { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Text });
if resp.clicked() {
let l = &lines[hit];
// Screen pixels per point, from the box's width.
let scale = (boxes[hit].width() - 4.0) / ((l.rect[2] - l.rect[0]).max(1.0) as f32);
// How far the editor box may grow: a single-line paragraph's rewrite grows to the
// page's right edge, a multi-line paragraph rewraps to its own width.
let right = xf.rect.right().min(view.viewport_rect().right()) - 6.0;
let max_width = if l.lines.len() == 1 { (right - boxes[hit].left()).max(boxes[hit].width()) } else { boxes[hit].width() };
view.line_editor = Some(LineEditor {
page,
block: hit,
text: l.text.clone(),
original: l.text.clone(),
rect: boxes[hit],
source_rect: l.rect.map(|v| v as f32),
multiline: l.lines.len() > 1,
max_width,
size: (l.size as f32 * scale).clamp(8.0, 72.0),
focus: true,
look: look_of(l),
look0: look_of(l),
extras: Extras::default(),
extras0: Extras::default(),
});
}
true
}
/// The inline editor; returns the edit once the text is applied.
pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option<Edit> {
// Clicks outside the document (the Format text panel) keep the paragraph open.
let outside = ctx.input(|i| i.pointer.latest_pos()).is_some_and(|p| !view.viewport_rect().contains(p));
let page = view.line_editor.as_ref()?.page;
let xf = view.page_xform(page)?;
let viewport_right = view.viewport_rect().right();
let ed = view.line_editor.as_mut()?;
// Reproject the source box every frame. The page may have been zoomed, scrolled or rotated
// while the format panel was open.
ed.rect = xf.user_rect(info, ed.page, ed.source_rect).expand(2.0);
let right = xf.rect.right().min(viewport_right) - 6.0;
ed.max_width = if ed.multiline { ed.rect.width() } else { (right - ed.rect.left()).max(ed.rect.width()) };
let scale = (ed.rect.width() / (ed.source_rect[2] - ed.source_rect[0]).abs().max(1.0)).max(0.01);
ed.size = (ed.look.size as f32 * scale).clamp(8.0, 72.0);
let font = editor_font(&ed.look, ed.size);
let text_color = color32(ed.look.color);
let mut done = None;
egui::Area::new(egui::Id::new("edit-text-line")).order(egui::Order::Foreground).fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())).show(
ctx,
|ui| {
// The box follows the text as you type: as wide as the longest drafted line needs
// (up to what the rewrite allows), so new content grows the box instead of wrapping
// inside the old one.
let mut width = ed.rect.width().max(120.0);
if ed.max_width > width {
let needed = ui.fonts_mut(|f| {
ed.text.lines().map(|l| f.layout_no_wrap(l.to_owned(), font.clone(), text_color).size().x).fold(0.0_f32, f32::max)
}) + 8.0;
width = width.max(needed).min(ed.max_width);
}
egui::Frame::NONE.fill(Color32::WHITE).stroke(Stroke::new(1.5, ACCENT)).inner_margin(egui::Margin::symmetric(2, 0)).show(ui, |ui| {
let rows = ed.text.lines().count().max(1);
let r = ui.add(
egui::TextEdit::multiline(&mut ed.text)
.id(egui::Id::new("edit-text-line-input"))
.font(font.clone())
.text_color(text_color)
.frame(egui::Frame::NONE)
.desired_width(width)
.desired_rows(rows),
);
if ed.focus {
r.request_focus();
ed.focus = false;
}
let esc = ui.input(|i| i.key_pressed(egui::Key::Escape));
let apply = ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command);
if esc {
done = Some(false);
} else if apply || (r.lost_focus() && !outside) {
done = Some(true);
}
});
},
);
match done {
Some(apply) => {
let ed = view.line_editor.take()?;
let style = ed.style();
(apply && (ed.text != ed.original || style != pdfcraft_engine::BlockStyle::default())).then_some(Edit::EditTextBlock {
page: ed.page,
block: ed.block,
text: ed.text,
style,
})
}
None => None,
}
}