diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 4f5630c0b..3907cd2ee 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -14,6 +14,20 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; use crate::page_pointer::PagePointer; +mod background; +mod image; +mod layout; + +pub(crate) use background::TextBackground; +pub use image::{CropMode, ImageSelection}; +pub(crate) use image::{ImageAction, image_input, image_owns_pointer}; +#[cfg(test)] +use image::{rotate_knob, turn_degrees}; +#[cfg(test)] +use layout::{DARK_EDITOR_FILL, contrast}; +pub(crate) use layout::{caret_color_on, editor_fill, editor_font, install_source_fonts}; +use layout::{color32, editor_galley, editor_job, look_of, luminance, unseen}; + const ACCENT: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); #[derive(Clone, Copy, PartialEq)] @@ -22,200 +36,6 @@ enum TextTarget { Added(usize), } -/// A cached, read-only page background. The source glyphs are removed before rasterization; -/// colour, pictures and unselected text beneath the editor remain visible. -#[derive(Default)] -pub(crate) struct TextBackground { - key: Option<(u64, usize, TextTarget)>, - renderer: Option, - texture: Option, - tag: Option, - received: Option, - draft_key: Option<(String, pdfcraft_engine::BlockStyle)>, - draft_rect: Option<[f64; 4]>, - /// This frame's place for the textless page under the open paragraph's editor. It stays - /// empty, so the page shows its own text, until the paragraph is changed ([`Self::reveal`]). - slot: Option<(egui::Painter, egui::layers::ShapeIdx, egui::Shape)>, - /// How the open paragraph looked in its editor last frame. - look: Option, - /// An applied paragraph's last look. The page raster still has the old text until the page - /// is rendered again, so the textless page and this look stay up until then. - settling: Option, -} - -/// A paragraph editor's text as last drawn: its laid-out rows, where they were on the page (in -/// the page's 0..1 coordinates), and the page's width and turn on screen then. -struct EditorLook { - page: usize, - block: usize, - galley: Arc, - at: (f32, f32), - color: Color32, - page_width: f32, - rot: u16, -} - -impl TextBackground { - pub fn pending(&self) -> bool { - self.renderer.is_some() && self.received != self.tag - } - - /// The open paragraph's editor drew `galley` at `pos` this frame. - pub fn remember(&mut self, xf: &PageXform, page: usize, block: usize, galley: Arc, pos: Pos2, color: Color32) { - self.look = Some(EditorLook { page, block, galley, at: xf.screen_to_norm(pos), color, page_width: xf.rect.width(), rot: xf.rot }); - } - - /// An edit was applied. When it is the open paragraph's, its last look stays up over the - /// textless page until the page is rendered again; any other edit ends both. - pub fn committed(&mut self, edit: &Edit) { - let look = self.look.take().filter(|look| { - matches!(edit, Edit::EditTextBlock { page, block, .. } if (look.page, look.block) == (*page, *block)) - && self.key.is_some_and(|(_, page, target)| (page, target) == (look.page, TextTarget::Block(look.block))) - && self.texture.is_some() - }); - match look { - Some(look) => { - self.renderer = None; - self.settling = Some(look); - } - None => *self = Self::default(), - } - } - - pub fn page_received(&mut self, page: usize) { - if self.settling.as_ref().is_some_and(|look| look.page == page) { - *self = Self::default(); - } - } - - pub fn prepare( - &mut self, - ctx: &egui::Context, - doc: &pdfcraft_engine::Document, - editor: Option<&LineEditor>, - draft: Option<&crate::content_ui::TextDraft>, - scale: f32, - ) { - let key = editor - .map(|ed| (doc.edit_generation(), ed.page, TextTarget::Block(ed.block))) - .or_else(|| draft.and_then(|d| d.index.map(|index| (doc.edit_generation(), d.page, TextTarget::Added(index))))); - self.slot = None; - if key.is_none() && self.settling.is_some() { - return; - } - if self.key != key { - *self = Self { key, ..Default::default() }; - if let Some((_, page, target)) = key { - self.renderer = match target { - TextTarget::Block(block) => doc.text_edit_preview(page, block), - TextTarget::Added(index) => doc.added_text_preview(page, index), - } - .ok() - .map(|p| p.renderer()); - } - } - if let Some(ed) = editor { - let draft_key = (ed.text.clone(), ed.style()); - if self.draft_key.as_ref() != Some(&draft_key) { - self.draft_rect = doc.text_edit_layout(ed.page, ed.block, &ed.text, &draft_key.1).ok().and_then(|layout| layout.draft_rect); - self.draft_key = Some(draft_key); - } - } - let (Some((_, page, _)), Some(renderer)) = (self.key, self.renderer.as_ref()) else { return }; - let Some(p) = doc.info.pages.get(page) else { return }; - // One raster stands in for the page's tiles while a paragraph is open, so everything - // around the editor is drawn from it: at the tiling threshold it stays sharp on a Retina - // display up to about 2.5x on a Letter or A4 page, where the backdrop side (2048) went - // soft past 1.2x. - let max_side = ctx.input(|i| i.max_texture_side) as f32; - let scale = scale.min(crate::canvas::TILE_THRESHOLD.min(max_side) / p.width.max(p.height).max(1.0)); - let tag = (scale * 1000.0) as u64; - if self.tag != Some(tag) { - renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]); - self.tag = Some(tag); - } - if let Some(out) = renderer.try_recv() { - if out.error.is_some() { - self.renderer = None; - self.texture = None; - return; - } - if out.request.tag == tag { - self.texture = Some(ctx.load_texture( - "text-editor-background", - crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba), - egui::TextureOptions::LINEAR, - )); - self.received = Some(tag); - } - } - if self.received != Some(tag) { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } - } - - /// The draft's measured extent, when it was measured for paragraph `block` on `page`. - pub fn draft_rect_for(&self, page: usize, block: usize) -> Option<[f64; 4]> { - self.draft_rect.filter(|_| self.key.is_some_and(|(_, p, target)| (p, target) == (page, TextTarget::Block(block)))) - } - - pub fn ready(&self, page: usize, block: usize) -> bool { - self.key.is_some_and(|(_, p, target)| (p, target) == (page, TextTarget::Block(block))) && self.texture.is_some() - } - - pub fn ready_added(&self, page: usize, index: usize) -> bool { - self.key.is_some_and(|(_, p, target)| (p, target) == (page, TextTarget::Added(index))) && self.texture.is_some() - } - - pub fn paint( - &mut self, - painter: &egui::Painter, - xf: &PageXform, - page: usize, - editor: Option<&LineEditor>, - draft: Option<&crate::content_ui::TextDraft>, - ) { - let for_editor = editor.is_some_and(|ed| ed.page == page && self.ready(page, ed.block)); - // A zoom or a turn of the view since the paragraph was applied would misplace its rows: - // the page raster (with the old text) shows until the new one arrives instead. - let settled = - self.settling.as_ref().filter(|look| look.page == page && look.rot == xf.rot && (look.page_width - xf.rect.width()).abs() < 0.5); - let ready = - for_editor || draft.is_some_and(|d| d.page == page && d.index.is_some_and(|index| self.ready_added(page, index))) || settled.is_some(); - if !ready { - return; - } - let Some(tex) = &self.texture else { return }; - // Font metrics can underestimate the actual glyph ink. Paint the complete textless - // page so overhanging glyphs cannot remain outside the estimated source rectangle. - let painter = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); - if for_editor { - // Its place under everything drawn on the page later; the editor fills it once the - // paragraph changes (an unchanged paragraph looks as the page draws it). - let mut mesh = egui::Mesh::with_texture(tex.id()); - for (u, v) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] { - mesh.vertices.push(egui::epaint::Vertex { pos: xf.norm_to_screen(u, v), uv: Pos2::new(u, v), color: Color32::WHITE }); - } - mesh.add_triangle(0, 1, 2); - mesh.add_triangle(0, 2, 3); - let idx = painter.add(egui::Shape::Noop); - self.slot = Some((painter, idx, egui::Shape::mesh(mesh))); - } else { - xf.paint_image(&painter, tex.id(), 0.0, 0.0, 1.0, 1.0); - if let Some(look) = settled { - painter.galley(xf.norm_to_screen(look.at.0, look.at.1), Arc::clone(&look.galley), look.color); - } - } - } - - /// The open paragraph has changed: show the textless page under its editor this frame. - pub fn reveal(&mut self) { - if let Some((painter, idx, shape)) = self.slot.take() { - painter.set(idx, shape); - } - } -} - /// How many faint Edit-text outlines are drawn per page per frame. CAD plots can draw /// ~1000 tiny images on one page; stroking all of them every frame (plus a hover query /// per box) stalls the UI. Past this, only the selected and hovered boxes are outlined: @@ -389,312 +209,6 @@ impl LineEditor { } } -/// 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) -} - -pub(crate) 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, - ) -} - -/// The dark box behind text too pale to read on white. -const DARK_EDITOR_FILL: Color32 = Color32::from_gray(0x26); - -/// WCAG relative luminance of an sRGB colour. -fn luminance(c: Color32) -> f32 { - let channel = |v: u8| { - let v = f32::from(v) / 255.0; - if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } - }; - 0.2126 * channel(c.r()) + 0.7152 * channel(c.g()) + 0.0722 * channel(c.b()) -} - -/// WCAG contrast ratio between two colours (1 to 21). -fn contrast(a: Color32, b: Color32) -> f32 { - let (la, lb) = (luminance(a), luminance(b)); - (la.max(lb) + 0.05) / (la.min(lb) + 0.05) -} - -/// The inline editor's background for text drawn in `text`: white, or a dark box when the text -/// reads better on that (white or pale text, which would vanish on white). -pub(crate) fn editor_fill(text: Color32) -> Color32 { - if contrast(text, Color32::WHITE) >= contrast(text, DARK_EDITOR_FILL) { Color32::WHITE } else { DARK_EDITOR_FILL } -} - -/// 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)>, - /// Crop mode (Acrobat's crop handles): the box kept so far. - pub crop: Option, - /// The drag is turning the picture by its rotate handle (the `drag` start is then unused). - turning: bool, - /// A move or resize applied on release: the frame it had and the one it got, in the page's - /// 0..1 coordinates, while the page raster still has the picture where it was. - settled: Option<(Rect, Rect)>, -} - -impl ImageSelection { - /// Cancel an in-progress text, rotation or crop drag and restore the crop it was previewing. - pub(crate) fn cancel_pointer_gesture(&mut self) { - self.drag = None; - self.turning = false; - if let Some(crop) = &mut self.crop - && let Some((_, _, before)) = crop.drag.take() - { - crop.keep = before; - } - } -} - -/// Cropping a page image: the handles trim instead of resizing, the part that will go is -/// dimmed, Enter or Done applies and Esc or Cancel leaves it. -#[derive(Clone, Debug, PartialEq)] -pub struct CropMode { - /// The box to keep (user space). - pub keep: [f64; 4], - /// A drag in progress: the grip (0–3 corners, 4–7 edges, 8 the inside), where it started - /// and the box then. - drag: Option<(usize, Pos2, [f64; 4])>, -} - -impl CropMode { - pub fn new(keep: [f64; 4]) -> Self { - Self { keep, drag: None } - } -} - -/// The screen boxes of the crop's eight handles: corners, then the top, right, bottom and left -/// edge midpoints. -fn crop_handles(k: Rect) -> [Pos2; 8] { - [k.left_top(), k.right_top(), k.left_bottom(), k.right_bottom(), k.center_top(), k.right_center(), k.center_bottom(), k.left_center()] -} - -/// Crop mode for the selected image `i` whose visible frame is `b` (screen). Returns `true` -/// (the pointer and keys are the crop's while it lasts). -#[allow(clippy::too_many_arguments)] -fn crop_input( - ui: &egui::Ui, - resp: &egui::Response, - xf: &PageXform, - ptr: &PagePointer, - page: usize, - info: &DocInfo, - view: &mut DocView, - i: usize, - b: Rect, - allow_interaction: bool, -) -> bool { - let painter = ui.painter(); - let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false }; - let hover_pos = ptr.pos; - let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); - // A drag in progress moves a grip; the box stays inside the picture and at least 6 px. - if allow_interaction - && resp.drag_started() - && let Some(o) = ptr.origin - { - let k = keep_screen(mode.keep); - let grip = crop_handles(k).iter().position(|h| h.distance(o) < 8.0).or_else(|| k.contains(o).then_some(8)); - if let Some(g) = grip { - mode.drag = Some((g, o, mode.keep)); - } - } - if let Some((grip, start, at_start)) = mode.drag - && let Some(p) = hover_pos - { - let d = p - start; - let k0 = keep_screen(at_start); - let (mut min, mut max) = (k0.min, k0.max); - match grip { - 0 => { - min.x += d.x; - min.y += d.y; - } - 1 => { - max.x += d.x; - min.y += d.y; - } - 2 => { - min.x += d.x; - max.y += d.y; - } - 3 => { - max.x += d.x; - max.y += d.y; - } - 4 => min.y += d.y, - 5 => max.x += d.x, - 6 => max.y += d.y, - 7 => min.x += d.x, - _ => { - let shift = egui::vec2(d.x.clamp(b.min.x - k0.min.x, b.max.x - k0.max.x), d.y.clamp(b.min.y - k0.min.y, b.max.y - k0.max.y)); - min += shift; - max += shift; - } - } - let (min_w, min_h) = (6.0_f32.min(b.width()), 6.0_f32.min(b.height())); - min.x = min.x.clamp(b.min.x, b.max.x - min_w); - min.y = min.y.clamp(b.min.y, b.max.y - min_h); - max.x = max.x.clamp(min.x + min_w, b.max.x); - max.y = max.y.clamp(min.y + min_h, b.max.y); - mode.keep = user_box(xf, info, page, Rect::from_min_max(min, max)); - if resp.drag_stopped() || !ptr.primary_down { - mode.drag = None; - } - } - let k = keep_screen(mode.keep); - // What goes is dimmed; what stays keeps its frame and handles. - let dim = Color32::from_black_alpha(96); - for r in [ - Rect::from_min_max(b.min, Pos2::new(b.max.x, k.min.y)), - Rect::from_min_max(Pos2::new(b.min.x, k.max.y), b.max), - Rect::from_min_max(Pos2::new(b.min.x, k.min.y), Pos2::new(k.min.x, k.max.y)), - Rect::from_min_max(Pos2::new(k.max.x, k.min.y), Pos2::new(b.max.x, k.max.y)), - ] { - if r.is_positive() { - painter.rect_filled(r.intersect(xf.rect), CornerRadius::ZERO, dim); - } - } - painter.rect_stroke(k, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - for h in crop_handles(k) { - painter.rect( - Rect::from_center_size(h, egui::vec2(8.0, 8.0)), - CornerRadius::ZERO, - Color32::WHITE, - Stroke::new(1.0, ACCENT), - egui::StrokeKind::Middle, - ); - } - // Done and Cancel, painted under the box (above it near the bottom of the view). - let font = egui::FontId::proportional(12.5); - let pill = |label: &str, right: f32, y: f32| -> Rect { - let w = painter.layout_no_wrap(label.to_string(), font.clone(), Color32::WHITE).size().x + 20.0; - Rect::from_min_size(Pos2::new(right - w, y), egui::vec2(w, 22.0)) - }; - let y = if k.max.y + 30.0 < xf.rect.max.y { k.max.y + 6.0 } else { k.min.y - 28.0 }; - let done = pill(tl!("Done"), k.max.x, y); - let cancel = pill(tl!("Cancel"), done.min.x - 6.0, y); - let t = crate::theme::Tokens::get(ui.ctx()); - painter.rect_filled(done, CornerRadius::same(11), ACCENT); - painter.text(done.center(), egui::Align2::CENTER_CENTER, tl!("Done"), font.clone(), Color32::WHITE); - painter.rect(cancel, CornerRadius::same(11), t.panel, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); - painter.text(cancel.center(), egui::Align2::CENTER_CENTER, tl!("Cancel"), font, t.text); - if let Some(p) = hover_pos { - let icon = if let Some(g) = crop_handles(k).iter().position(|h| h.distance(p) < 8.0) { - match g { - 0 | 3 => egui::CursorIcon::ResizeNwSe, - 1 | 2 => egui::CursorIcon::ResizeNeSw, - 4 | 6 => egui::CursorIcon::ResizeVertical, - _ => egui::CursorIcon::ResizeHorizontal, - } - } else if done.contains(p) || cancel.contains(p) { - egui::CursorIcon::PointingHand - } else if k.contains(p) { - egui::CursorIcon::Move - } else { - egui::CursorIcon::Default - }; - ui.ctx().set_cursor_icon(icon); - } - let clicked = |r: Rect| allow_interaction && resp.clicked() && hover_pos.is_some_and(|p| r.contains(p)); - let keys = |key: egui::Key| allow_interaction && ui.input(|inp| inp.key_pressed(key)) && !ui.ctx().egui_wants_keyboard_input(); - if clicked(done) || keys(egui::Key::Enter) { - let keep = mode.keep; - if let Some(s) = view.image_selection.as_mut() { - s.crop = None; - } - if keep_screen(keep) != b { - view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Crop(keep) }); - } - } else if (clicked(cancel) || keys(egui::Key::Escape)) - && let Some(s) = view.image_selection.as_mut() - { - s.crop = None; - } - true -} - -impl ImageSelection { - pub(crate) fn dragging(&self) -> bool { - self.drag.is_some() - } -} - -/// The page raster's picture of the box `from`, stretched into `to`, over blanks at `blank` (where -/// the screen may still show the picture). -fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::TextureId, blank: &[Rect], from: Rect, to: Rect) { - let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); - for r in blank { - clipped.rect_filled(*r, CornerRadius::ZERO, Color32::WHITE); - } - let mut mesh = egui::Mesh::with_texture(tex); - for (a, b) in [ - (from.left_top(), to.left_top()), - (from.right_top(), to.right_top()), - (from.right_bottom(), to.right_bottom()), - (from.left_bottom(), to.left_bottom()), - ] { - let (u, v) = xf.screen_to_norm(a); - mesh.vertices.push(egui::epaint::Vertex { pos: b, uv: Pos2::new(u, v), color: Color32::WHITE }); - } - mesh.add_triangle(0, 1, 2); - mesh.add_triangle(0, 2, 3); - clipped.add(egui::Shape::mesh(mesh)); -} - -/// Once a selected corner or the rotate knob is grabbed, text under it cannot take over the gesture. The same -/// applies after the pointer leaves the image/page while dragging. -pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, view: &DocView) -> bool { - let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page) else { return false }; - if selection.drag.is_some() { - return true; - } - let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false }; - let Some(rect) = image.visible_rect else { return false }; - let b = xf.user_rect(info, page, rect.map(|v| v as f32)); - let p = if ptr.primary_down { ptr.origin } else { ptr.pos }; - let knob = rotate_knob(b, ui.clip_rect()).0; - p.is_some_and(|p| [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom(), knob].iter().any(|c| c.distance(p) < 8.0)) -} - /// What a drag on a paragraph box takes hold of. #[derive(Clone, Copy, Debug, PartialEq)] enum Grip { @@ -769,42 +283,6 @@ pub(crate) fn grabs_edge(xf: &PageXform, info: &DocInfo, page: usize, mut rects: has_edges(info, page) && rects.any(|r| edge_at(paragraph_box(xf, info, page, r), p).is_some()) } -/// 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), - Copy(usize, usize), - CopyFile(usize, usize), - /// Copy the picture, then remove it from the page. - Cut(usize, usize), - /// Mark the picture for redaction (applied later from Redact a PDF). - Redact(usize, usize), - /// Create a link over the picture. - Link(usize, usize), - /// Open Recognize Text for the picture's page. - Recognize(usize), -} - -/// Where the rotate knob sits, and the edge point its stem starts from: above the middle of the -/// top edge, or below the bottom edge when the area on screen (`view`) has no room above. -fn rotate_knob(b: Rect, view: Rect) -> (Pos2, Pos2) { - let above = b.center_top() - egui::vec2(0.0, 22.0); - if above.y - 6.0 >= view.top() { (above, b.center_top()) } else { (b.center_bottom() + egui::vec2(0.0, 22.0), b.center_bottom()) } -} - -/// Degrees clockwise (-180..=180) that turn "straight up" from `centre` onto the pointer; `snap` -/// rounds to steps of 15 degrees (Shift). A pointer on the centre turns nothing. -fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 { - let v = pointer - centre; - if !(v.x.is_finite() && v.y.is_finite()) || v.length() < 4.0 { - return 0.0; - } - // Screen y points down: up is (0, -1); clockwise is atan2(x, -y). - let d = v.x.atan2(-v.y).to_degrees(); - if snap { (d / 15.0).round() * 15.0 } else { d } -} - 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)); @@ -812,366 +290,6 @@ fn user_box(xf: &PageXform, info: &DocInfo, page: usize, r: Rect) -> [f64; 4] { [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, - ptr: &PagePointer, - page: usize, - info: &DocInfo, - images: &[pdfcraft_engine::PageImage], - view: &mut DocView, - action: &mut Option, - allow_interaction: bool, -) -> bool { - let boxes: Vec = images.iter().map(|im| im.visible_rect.map_or(Rect::NOTHING, |r| xf.user_rect(info, page, r.map(|v| v as f32)))).collect(); - let painter = ui.painter(); - let hover_pos = ptr.pos; - let image_contains = |index: usize, point: Pos2| { - let (x, y) = xf.screen_to_view(point); - let Some(p) = info.pages.get(page) else { return false }; - let user = p.view_to_user(x, y); - images.get(index).is_some_and(|im| im.contains(user.map(f64::from))) - }; - let selected = view.image_selection.as_ref().filter(|s| s.page == page).map(|s| s.index).filter(|i| *i < boxes.len()); - let dragging = view.image_selection.as_ref().is_some_and(|s| s.drag.is_some()); - // Few images: faint outlines for all of them, as before. Many: only the selected and - // hovered boxes, so a ~1000-image CAD page stays interactive in Edit text & images. - // Fully clipped placements (empty visible frames) get no outline. - let faint_all = boxes.len() <= MAX_OUTLINES; - // The picture being dragged has its frame at the pointer; its outline where it was would - // trail behind until the release. - let moving = selected.filter(|_| dragging); - if faint_all { - for (_, b) in boxes.iter().enumerate().filter(|(i, b)| b.is_positive() && moving != Some(*i)) { - painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); - } - } - let hovered_box = hover_pos.and_then(|p| boxes.iter().enumerate().rposition(|(i, b)| b.is_positive() && b.contains(p) && image_contains(i, p))); - if !faint_all { - for i in selected.filter(|_| moving.is_none()).into_iter().chain(hovered_box.filter(|h| Some(*h) != selected)) { - if let Some(b) = boxes.get(i).filter(|b| b.is_positive()) { - painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); - } - } - } - for i in selected.into_iter().chain(hovered_box).collect::>() { - let Some(b) = boxes.get(i).copied().filter(|b| b.is_positive()) else { continue }; - if selected == Some(i) { - // The selected frame and handles are drawn below (or the drag preview instead). - if !dragging { - painter.rect_filled(b, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); - } - } else { - painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); - } - } - // The selected image: frame, corner handles, dragging. - if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) { - let b = boxes[i]; - if view.image_selection.as_ref().is_some_and(|s| s.crop.is_some()) { - return crop_input(ui, resp, xf, ptr, page, info, view, i, b, allow_interaction); - } - let corners = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()]; - let handles = |rect: Rect| { - for c in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] { - 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, - ); - } - }; - // Until the page is rendered again its raster has an applied move or resize's picture - // where it was: it stays blanked there and drawn where it went. - // The whole-page raster and its tiles are replaced separately: the old frame stays blanked - // while anything still shows the picture there, but its pixels come from the old frame - // only while the whole-page raster itself is the old one. Once that is fresh it has the - // picture where it went, and that is where they come from. - let (out_of_date, raster_old, raster) = (view.page_out_of_date(page), view.page_raster_out_of_date(page), view.page_raster(page)); - let screen = |r: Rect| Rect::from_two_pos(xf.norm_to_screen(r.min.x, r.min.y), xf.norm_to_screen(r.max.x, r.max.y)); - // Where the screen may still show the picture from before an applied move, while the page - // isn't drawn again yet. Another drag keeps it blanked too. - let left_at = view.image_selection.as_ref().and_then(|s| s.settled).filter(|_| out_of_date).map(|(from, _)| from); - if let Some(s) = view.image_selection.as_mut().filter(|s| s.drag.is_none()) { - match (s.settled, raster) { - (Some((from, to)), Some(tex)) if out_of_date => { - let source = if raster_old { from } else { to }; - stretched_picture(painter, xf, tex, &[screen(from)], screen(source), screen(to)); - } - _ => s.settled = None, - } - } - if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) { - painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - handles(b); - } - if allow_interaction && let Some(corner) = hover_pos.and_then(|p| corners.iter().position(|c| c.distance(p) < 8.0)) { - ui.ctx().set_cursor_icon(if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); - } - // The rotate handle: a knob above the top edge, joined to it by a short stem. - let (knob, stem) = rotate_knob(b, ui.clip_rect()); - if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) { - painter.line_segment([stem, knob], Stroke::new(1.0, ACCENT)); - painter.circle(knob, 5.0, Color32::WHITE, Stroke::new(1.0, ACCENT)); - if allow_interaction && hover_pos.is_some_and(|p| p.distance(knob) < 8.0) { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); - } - } - if allow_interaction - && resp.drag_started() - && let Some(o) = ptr.origin - && o.distance(knob) < 8.0 - { - if let Some(s) = view.image_selection.as_mut() { - s.drag = Some((o, None)); - s.turning = true; - } - } else if allow_interaction - && resp.drag_started() - && let Some(o) = ptr.origin - { - let corner = corners.iter().position(|c| c.distance(o) < 8.0); - if corner.is_some() || image_contains(i, o) { - let opposite = corner.map(|k| corners[3 - k]); - if let Some(s) = view.image_selection.as_mut() { - s.drag = Some((o, opposite)); - } - } - } - if view.image_selection.as_ref().is_some_and(|s| s.turning) { - let shift = ui.input(|i| i.modifiers.shift); - let degrees = hover_pos.map_or(0.0, |p| turn_degrees(b.center(), p, shift)); - // The turned outline as a preview; the engine redraws the pixels on release. - let centre = b.center(); - let (sin, cos) = degrees.to_radians().sin_cos(); - let turn = |c: Pos2| { - let v = c - centre; - centre + egui::vec2(v.x * cos - v.y * sin, v.x * sin + v.y * cos) - }; - let outline = [b.left_top(), b.right_top(), b.right_bottom(), b.left_bottom()].map(turn).to_vec(); - painter.add(egui::Shape::closed_line(outline, Stroke::new(1.5, ACCENT))); - let galley = painter.layout_no_wrap(format!("{degrees:.0}\u{b0}"), egui::FontId::proportional(12.0), ACCENT); - let label = Rect::from_center_size(turn(knob) - egui::vec2(0.0, 14.0), galley.size() + egui::vec2(8.0, 4.0)); - painter.rect(label, CornerRadius::same(3), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); - painter.galley(label.center() - galley.size() / 2.0, galley, ACCENT); - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - if resp.drag_stopped() || !ptr.primary_down { - if let Some(s) = view.image_selection.as_mut() { - s.drag = None; - s.turning = false; - } - if degrees.abs() >= 0.05 { - view.pending_edit = - Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::RotateBy(f64::from(degrees)) }); - } - } - return true; - } - if let Some((start, opposite)) = view.image_selection.as_ref().and_then(|s| s.drag) - && let Some(p) = hover_pos - { - 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)); - // Use the axis with the larger relative pointer change. Taking the maximum - // absolute size freezes shrinking when the other axis hasn't moved. Measure - // from the press point so grabbing a handle off-centre cannot cause a jump. - let horizontal = (p.x - start.x) * (start.x - fixed.x).signum() / w0; - let vertical = (p.y - start.y) * (start.y - fixed.y).signum() / h0; - let delta = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; - let k = (1.0 + delta).max(0.05); - let (w, h) = (w0 * k, h0 * k); - let x = if start.x < fixed.x { fixed.x - w } else { fixed.x }; - let y = if start.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), - }; - // Move/resize the full placement by the visible frame's change. The engine - // transforms an isolated crop together with its image, preserving its contents. - let raw = images.get(i).map_or(b, |im| xf.user_rect(info, page, im.rect.map(|v| v as f32))); - // A zero-width or zero-height visible frame (or a degenerate ratio) keeps the - // placement's scale instead of producing inf/NaN. - let ratio = |new: f32, old: f32| if old.abs() > 1e-3 && (new / old).is_finite() { new / old } else { 1.0 }; - let factor = egui::vec2(ratio(preview.width(), b.width()), ratio(preview.height(), b.height())); - let target = Rect::from_min_max(preview.min + (raw.min - b.min) * factor, preview.min + (raw.max - b.min) * factor); - let visible = preview.intersect(xf.rect); - if visible.is_positive() { - // The pixels follow the frame: the page raster's picture of the image, stretched - // into the preview over a blank where it was. Re-rendering every frame of a drag - // would lag; the edit renders it properly on release. - if preview != b - && let Some(tex) = view.page_raster(page) - { - // Its pixels: where the whole-page raster has it. - let source = left_at.filter(|_| raster_old).map_or(b, screen); - let blank: Vec = left_at.map(screen).into_iter().chain(std::iter::once(b)).collect(); - stretched_picture(painter, xf, tex, &blank, source, preview); - } - painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); - painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - handles(preview); - } - if resp.drag_stopped() || !ptr.primary_down { - if let Some(s) = view.image_selection.as_mut() { - s.drag = None; - let norm = |r: Rect| { - let ((u0, v0), (u1, v1)) = (xf.screen_to_norm(r.min), xf.screen_to_norm(r.max)); - Rect::from_two_pos(Pos2::new(u0, v0), Pos2::new(u1, v1)) - }; - s.settled = (preview != b).then(|| (left_at.unwrap_or_else(|| norm(b)), norm(preview))); - } - if preview != b { - view.pending_edit = - Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, target)) }); - } - } - ui.ctx().set_cursor_icon(match opposite { - Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw, - Some(_) => egui::CursorIcon::ResizeNwSe, - None => egui::CursorIcon::Move, - }); - return true; - } - if allow_interaction - && 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; - } - } - if !allow_interaction { - return false; - } - // An open menu stays drawn wherever the pointer is: most of its items lie outside the picture - // (a logo is smaller than its menu), and a menu that is not drawn for a frame cannot be clicked. - // Only a menu opened by a right-click on the picture: one opened over text or empty page while - // a picture stays selected is the page's own menu. - let menu_key = resp.id.with("image-menu"); - if resp.secondary_clicked() { - ui.ctx().data_mut(|d| d.insert_temp(menu_key, false)); - } - let mut menu_drawn = false; - if let Some(sel) = selected - && resp.context_menu_opened() - && ui.ctx().data(|d| d.get_temp::(menu_key)).unwrap_or(false) - { - resp.context_menu(|ui| image_menu(ui, page, sel, images, view, action)); - menu_drawn = true; - } - let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p)) else { return false }; - let Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| b.contains(p) && image_contains(i, 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); - } - let Some(b) = boxes.get(hit).copied() else { return false }; - let corner = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()].iter().position(|c| c.distance(p) < 8.0); - ui.ctx().set_cursor_icon(match (selected == Some(hit), corner) { - (true, Some(0 | 3)) => egui::CursorIcon::ResizeNwSe, - (true, Some(_)) => egui::CursorIcon::ResizeNeSw, - (true, None) => egui::CursorIcon::Move, - _ => egui::CursorIcon::PointingHand, - }); - if resp.clicked() || resp.secondary_clicked() { - view.image_selection = Some(ImageSelection { page, index: hit, drag: None, crop: None, turning: false, settled: None }); - } - if resp.secondary_clicked() { - ui.ctx().data_mut(|d| d.insert_temp(menu_key, true)); - } - if selected == Some(hit) && !menu_drawn { - resp.context_menu(|ui| image_menu(ui, page, hit, images, view, action)); - } - true -} - -/// The right-click menu of the picture `hit` (its rotate, flip, replace, copy, cut, redact, link, -/// recognize and delete items). -fn image_menu( - ui: &mut egui::Ui, - page: usize, - hit: usize, - images: &[pdfcraft_engine::PageImage], - view: &mut DocView, - action: &mut Option, -) { - use pdfcraft_engine::ImageEdit as E; - let items: [(&str, Option); 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(); - } - } - // Crop: the handles trim instead of resizing until Done. - if ui.button(tl!("Crop Image")).clicked() { - if let (Some(s), Some(im)) = (view.image_selection.as_mut(), images.get(hit)) { - s.crop = Some(CropMode::new(im.visible_rect.unwrap_or(im.rect))); - } - ui.close(); - } - let cropped = images.get(hit).is_some_and(|im| im.visible_rect.is_some_and(|v| v.iter().zip(im.rect.iter()).any(|(a, b)| (a - b).abs() > 0.01))); - if ui.add_enabled(cropped, egui::Button::new(tl!("Remove Crop"))).clicked() { - if let Some(im) = images.get(hit) { - view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Crop(im.rect) }); - } - 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(); - } - // Cutting and copying also find the picture inside grouped Form artwork. - if ui.button(tl!("Cut Image")).clicked() { - *action = Some(ImageAction::Cut(page, hit)); - ui.close(); - } - if ui.button(tl!("Copy Image")).clicked() { - *action = Some(ImageAction::Copy(page, hit)); - ui.close(); - } - if ui.button(tl!("Copy Image in Original Format")).clicked() { - *action = Some(ImageAction::CopyFile(page, hit)); - ui.close(); - } - ui.separator(); - if ui.button(tl!("Redact Image")).clicked() { - *action = Some(ImageAction::Redact(page, hit)); - ui.close(); - } - if ui.button(tl!("Create Link…")).clicked() { - *action = Some(ImageAction::Link(page, hit)); - ui.close(); - } - if ui.button(tl!("Recognize Text…")).clicked() { - *action = Some(ImageAction::Recognize(page)); - 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(); - } -} - /// Open `block` on `page` in the inline editor. The box's geometry and source are read from the /// `lines` handed in, so callers pass the document's current inventory. #[allow(clippy::too_many_arguments)] @@ -1374,203 +492,6 @@ pub(crate) fn page_input( true } -/// One TextEdit lays out all source runs; its caret and selection span the whole region. -pub(crate) fn install_source_fonts(ctx: &egui::Context, doc: &pdfcraft_engine::Document, page: usize) -> BTreeMap { - let programs = doc.text_editor_fonts(page); - let mut definitions = ctx.fonts(|f| f.definitions().clone()); - // Only the current editor's document fonts live in the atlas. Never accumulate the - // programs of previously opened files or add them to the interface's default families. - definitions.font_data.retain(|name, _| !name.starts_with("pdf-source-")); - definitions.families.retain(|family, _| !matches!(family, FontFamily::Name(name) if name.starts_with("pdf-source-"))); - let mut families = BTreeMap::new(); - for (index, program) in programs.iter().enumerate() { - let name = format!("pdf-source-{index}"); - definitions.font_data.insert(name.clone(), Arc::new(egui::FontData::from_owned(program.bytes.clone()))); - families.insert(program.resource.clone(), name); - } - for program in &programs { - let Some(name) = families.get(&program.resource) else { continue }; - // Word-created PDFs often split one face into several subsets. Use all of the - // document's matching subsets before falling back for newly inserted characters. - let mut stack = vec![name.clone()]; - stack.extend( - programs - .iter() - .filter(|p| { - pdfcraft_fonts::embedding::face_key(&p.face) == pdfcraft_fonts::embedding::face_key(&program.face) - && p.resource != program.resource - }) - .filter_map(|p| families.get(&p.resource).cloned()), - ); - if let Some(fallback) = definitions.families.get(&FontFamily::Proportional) { - stack.extend(fallback.iter().cloned()); - } - definitions.families.insert(FontFamily::Name(name.clone().into()), stack); - } - if !families.is_empty() { - ctx.set_fonts(definitions); - } - families -} - -fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32) -> egui::text::LayoutJob { - let mut job = egui::text::LayoutJob::default(); - // An unchanged PDF row already has an authoritative line break. Do not rewrap it - // because font rounding or a fallback face measures a fraction of a pixel wider. - job.wrap.max_width = if text == ed.original && ed.look == ed.look0 && ed.extras == ed.extras0 { f32::INFINITY } else { width }; - if let Some((block, lines)) = &ed.source { - let lefts: Vec<_> = block.rows.iter().filter_map(|r| r.first().and_then(|i| lines.get(*i)).map(|l| l.rect[0])).collect(); - let left_span = lefts.iter().copied().fold(f64::NEG_INFINITY, f64::max) - lefts.iter().copied().fold(f64::INFINITY, f64::min); - let centered = block.rows.len() > 1 - && left_span > block.size * 0.5 - && block.rows.iter().all(|row| { - let rect = row - .iter() - .filter_map(|i| lines.get(*i)) - .map(|l| l.rect) - .reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]); - rect.is_some_and(|r| ((r[0] + r[2] - block.rect[0] - block.rect[2]) / 2.0).abs() < block.size) - }); - if centered { - job.halign = egui::Align::Center; - } - for (range, index) in block.edited_spans(text) { - let Some(line) = lines.get(index) else { continue }; - let mut look = source_look(&line.base_font, line.size, line.color, line.bold, line.italic); - look.size = line.size; - if ed.look.family != ed.look0.family || ed.family_chosen { - look.family = ed.look.family; - } - if ed.look.bold != ed.look0.bold { - look.bold = ed.look.bold; - } - if ed.look.italic != ed.look0.italic { - look.italic = ed.look.italic; - } - if ed.look.color != ed.look0.color { - look.color = ed.look.color; - } - if ed.look.size != ed.look0.size { - look.size = ed.look.size; - } - let mut font = editor_font(&look, (look.size as f32 * scale).clamp(1.0, 300.0)); - let original_face = - ed.look.family == ed.look0.family && !ed.family_chosen && ed.look.bold == ed.look0.bold && ed.look.italic == ed.look0.italic; - let embedded = original_face && ed.fonts.contains_key(&line.font); - if embedded && let Some(name) = ed.fonts.get(&line.font) { - font.family = FontFamily::Name(name.clone().into()); - } - let native_width = ui.fonts_mut(|f| f.layout_no_wrap(line.text.clone(), font.clone(), Color32::BLACK).size().x); - let target_width = ((line.rect[2] - line.rect[0]) * look.size / line.size.max(0.01)) as f32 * scale; - let spacing = if original_face { (target_width - native_width) / line.text.chars().count().saturating_sub(1).max(1) as f32 } else { 0.0 }; - job.append( - text.get(range).unwrap_or_default(), - 0.0, - egui::TextFormat { - font_id: font, - extra_letter_spacing: spacing + ed.extras.char_spacing as f32 * scale, - color: color32(look.color), - italics: look.italic && !embedded, - ..Default::default() - }, - ); - } - } else { - job.append( - text, - 0.0, - egui::TextFormat { - font_id: editor_font(&ed.look, ed.size), - color: color32(ed.look.color), - italics: ed.look.italic, - ..Default::default() - }, - ); - } - job -} - -/// `galley` drawn in no colour: its rows still place the caret and the selection. -fn unseen(mut galley: Arc) -> Arc { - for placed in &mut Arc::make_mut(&mut galley).rows { - for vertex in &mut Arc::make_mut(&mut placed.row).visuals.mesh.vertices { - vertex.color = Color32::TRANSPARENT; - } - } - galley -} - -/// Align the editable galley's baselines to the source rows instead of egui's interface -/// leading. Glyphs, meshes, caret and selection receive the same transform. -fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32) -> Arc { - let mut job = editor_job(ui, ed, text, scale, width); - let centered = job.halign == egui::Align::Center; - job.halign = egui::Align::LEFT; - let mut galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); - let Some((block, lines)) = &ed.source else { return galley }; - let sources: Vec<_> = block - .rows - .iter() - .filter_map(|row| { - let first = row.first().and_then(|i| lines.get(*i))?; - let rect = row - .iter() - .filter_map(|i| lines.get(*i)) - .map(|l| l.rect) - .reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])])?; - Some((first.baseline()[1], rect)) - }) - .collect(); - let Some((first_baseline, first_rect)) = sources.first().copied() else { return galley }; - let leading = if ed.extras.line_spacing > 0.0 { - ed.extras.line_spacing * ed.look.size - } else { - let fallback = if block.lines.len() == 1 && !text.contains('\n') { - block.lines.first().and_then(|i| lines.get(*i)).map_or(block.size * 1.2, |line| line.wrap_leading()) - } else { - block.size * 1.5 - }; - sources.windows(2).map(|r| (r[0].0 - r[1].0).abs()).rfind(|v| *v > 0.0).unwrap_or(fallback) - }; - let g = Arc::make_mut(&mut galley); - let mut bounds = Rect::NOTHING; - let mut meshes = Rect::NOTHING; - for (index, placed) in g.rows.iter_mut().enumerate() { - let (last_baseline, last_rect) = sources.last().copied().unwrap_or((first_baseline, first_rect)); - let (baseline, source_rect) = sources - .get(index) - .copied() - .unwrap_or((last_baseline - index.saturating_add(1).saturating_sub(sources.len()) as f64 * leading, last_rect)); - let changed_size = ed.look.size != ed.look0.size; - let ratio = if changed_size { ed.look.size / ed.look0.size.max(0.01) } else { 1.0 }; - let ascent = (source_rect[3] - sources.get(index).map_or(last_baseline, |s| s.0)) as f32 * scale * ratio as f32; - let descent = (sources.get(index).map_or(last_baseline, |s| s.0) - source_rect[1]) as f32 * scale * ratio as f32; - let base_y = (block.rect[3] - baseline) as f32 * scale; - let row = Arc::make_mut(&mut placed.row); - placed.pos.y = base_y - ascent; - placed.pos.x = if centered { (width - row.size.x) / 2.0 } else { (source_rect[0] - block.rect[0]) as f32 * scale }; - for glyph in &mut row.glyphs { - let dy = ascent - glyph.pos.y; - glyph.pos.y = ascent; - glyph.font_ascent = ascent; - glyph.font_height = ascent + descent; - glyph.line_height = ascent + descent; - if !glyph.uv_rect.is_nothing() { - for vertex in row.visuals.mesh.vertices.iter_mut().skip(glyph.first_vertex as usize).take(4) { - vertex.pos.y += dy; - } - } - } - row.size.y = (ascent + descent).max(1.0); - row.visuals.mesh_bounds = row.visuals.mesh.vertices.iter().fold(Rect::NOTHING, |r, v| r.union(Rect::from_min_max(v.pos, v.pos))); - bounds = bounds.union(Rect::from_min_size(placed.pos, row.size)); - meshes = meshes.union(row.visuals.mesh_bounds.translate(placed.pos.to_vec2())); - } - g.rect = Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(bounds.right().max(0.0), bounds.bottom().max(0.0))); - g.mesh_bounds = meshes; - galley -} - /// The inline editor; returns the edit once the text is applied. pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option { // Clicks outside the document (the Format text panel) keep the paragraph open. Where the @@ -1773,12 +694,6 @@ pub(crate) fn lost_focus_once(r: &egui::Response, had: &mut bool) -> bool { std::mem::replace(had, has) && !has } -/// Caret colour that stays visible on a field filled with `fill`: black on light fills, white on dark ones. -pub(crate) fn caret_color_on(fill: Color32) -> Color32 { - let luma = 299 * u32::from(fill.r()) + 587 * u32::from(fill.g()) + 114 * u32::from(fill.b()); - if luma > 127_500 { Color32::BLACK } else { Color32::WHITE } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/edit_text_ui/background.rs b/crates/ui-egui/src/edit_text_ui/background.rs new file mode 100644 index 000000000..71b2bffd3 --- /dev/null +++ b/crates/ui-egui/src/edit_text_ui/background.rs @@ -0,0 +1,195 @@ +use super::*; + +/// A cached, read-only page background. The source glyphs are removed before rasterization; +/// colour, pictures and unselected text beneath the editor remain visible. +#[derive(Default)] +pub(crate) struct TextBackground { + key: Option<(u64, usize, TextTarget)>, + pub(super) renderer: Option, + pub(super) texture: Option, + pub(super) tag: Option, + received: Option, + draft_key: Option<(String, pdfcraft_engine::BlockStyle)>, + draft_rect: Option<[f64; 4]>, + /// This frame's place for the textless page under the open paragraph's editor. It stays + /// empty, so the page shows its own text, until the paragraph is changed ([`Self::reveal`]). + slot: Option<(egui::Painter, egui::layers::ShapeIdx, egui::Shape)>, + /// How the open paragraph looked in its editor last frame. + look: Option, + /// An applied paragraph's last look. The page raster still has the old text until the page + /// is rendered again, so the textless page and this look stay up until then. + settling: Option, +} + +/// A paragraph editor's text as last drawn: its laid-out rows, where they were on the page (in +/// the page's 0..1 coordinates), and the page's width and turn on screen then. +struct EditorLook { + page: usize, + block: usize, + galley: Arc, + at: (f32, f32), + color: Color32, + page_width: f32, + rot: u16, +} + +impl TextBackground { + pub fn pending(&self) -> bool { + self.renderer.is_some() && self.received != self.tag + } + + /// The open paragraph's editor drew `galley` at `pos` this frame. + pub fn remember(&mut self, xf: &PageXform, page: usize, block: usize, galley: Arc, pos: Pos2, color: Color32) { + self.look = Some(EditorLook { page, block, galley, at: xf.screen_to_norm(pos), color, page_width: xf.rect.width(), rot: xf.rot }); + } + + /// An edit was applied. When it is the open paragraph's, its last look stays up over the + /// textless page until the page is rendered again; any other edit ends both. + pub fn committed(&mut self, edit: &Edit) { + let look = self.look.take().filter(|look| { + matches!(edit, Edit::EditTextBlock { page, block, .. } if (look.page, look.block) == (*page, *block)) + && self.key.is_some_and(|(_, page, target)| (page, target) == (look.page, TextTarget::Block(look.block))) + && self.texture.is_some() + }); + match look { + Some(look) => { + self.renderer = None; + self.settling = Some(look); + } + None => *self = Self::default(), + } + } + + pub fn page_received(&mut self, page: usize) { + if self.settling.as_ref().is_some_and(|look| look.page == page) { + *self = Self::default(); + } + } + + pub fn prepare( + &mut self, + ctx: &egui::Context, + doc: &pdfcraft_engine::Document, + editor: Option<&LineEditor>, + draft: Option<&crate::content_ui::TextDraft>, + scale: f32, + ) { + let key = editor + .map(|ed| (doc.edit_generation(), ed.page, TextTarget::Block(ed.block))) + .or_else(|| draft.and_then(|d| d.index.map(|index| (doc.edit_generation(), d.page, TextTarget::Added(index))))); + self.slot = None; + if key.is_none() && self.settling.is_some() { + return; + } + if self.key != key { + *self = Self { key, ..Default::default() }; + if let Some((_, page, target)) = key { + self.renderer = match target { + TextTarget::Block(block) => doc.text_edit_preview(page, block), + TextTarget::Added(index) => doc.added_text_preview(page, index), + } + .ok() + .map(|p| p.renderer()); + } + } + if let Some(ed) = editor { + let draft_key = (ed.text.clone(), ed.style()); + if self.draft_key.as_ref() != Some(&draft_key) { + self.draft_rect = doc.text_edit_layout(ed.page, ed.block, &ed.text, &draft_key.1).ok().and_then(|layout| layout.draft_rect); + self.draft_key = Some(draft_key); + } + } + let (Some((_, page, _)), Some(renderer)) = (self.key, self.renderer.as_ref()) else { return }; + let Some(p) = doc.info.pages.get(page) else { return }; + // One raster stands in for the page's tiles while a paragraph is open, so everything + // around the editor is drawn from it: at the tiling threshold it stays sharp on a Retina + // display up to about 2.5x on a Letter or A4 page, where the backdrop side (2048) went + // soft past 1.2x. + let max_side = ctx.input(|i| i.max_texture_side) as f32; + let scale = scale.min(crate::canvas::TILE_THRESHOLD.min(max_side) / p.width.max(p.height).max(1.0)); + let tag = (scale * 1000.0) as u64; + if self.tag != Some(tag) { + renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]); + self.tag = Some(tag); + } + if let Some(out) = renderer.try_recv() { + if out.error.is_some() { + self.renderer = None; + self.texture = None; + return; + } + if out.request.tag == tag { + self.texture = Some(ctx.load_texture( + "text-editor-background", + crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba), + egui::TextureOptions::LINEAR, + )); + self.received = Some(tag); + } + } + if self.received != Some(tag) { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + + /// The draft's measured extent, when it was measured for paragraph `block` on `page`. + pub fn draft_rect_for(&self, page: usize, block: usize) -> Option<[f64; 4]> { + self.draft_rect.filter(|_| self.key.is_some_and(|(_, p, target)| (p, target) == (page, TextTarget::Block(block)))) + } + + pub fn ready(&self, page: usize, block: usize) -> bool { + self.key.is_some_and(|(_, p, target)| (p, target) == (page, TextTarget::Block(block))) && self.texture.is_some() + } + + pub fn ready_added(&self, page: usize, index: usize) -> bool { + self.key.is_some_and(|(_, p, target)| (p, target) == (page, TextTarget::Added(index))) && self.texture.is_some() + } + + pub fn paint( + &mut self, + painter: &egui::Painter, + xf: &PageXform, + page: usize, + editor: Option<&LineEditor>, + draft: Option<&crate::content_ui::TextDraft>, + ) { + let for_editor = editor.is_some_and(|ed| ed.page == page && self.ready(page, ed.block)); + // A zoom or a turn of the view since the paragraph was applied would misplace its rows: + // the page raster (with the old text) shows until the new one arrives instead. + let settled = + self.settling.as_ref().filter(|look| look.page == page && look.rot == xf.rot && (look.page_width - xf.rect.width()).abs() < 0.5); + let ready = + for_editor || draft.is_some_and(|d| d.page == page && d.index.is_some_and(|index| self.ready_added(page, index))) || settled.is_some(); + if !ready { + return; + } + let Some(tex) = &self.texture else { return }; + // Font metrics can underestimate the actual glyph ink. Paint the complete textless + // page so overhanging glyphs cannot remain outside the estimated source rectangle. + let painter = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); + if for_editor { + // Its place under everything drawn on the page later; the editor fills it once the + // paragraph changes (an unchanged paragraph looks as the page draws it). + let mut mesh = egui::Mesh::with_texture(tex.id()); + for (u, v) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] { + mesh.vertices.push(egui::epaint::Vertex { pos: xf.norm_to_screen(u, v), uv: Pos2::new(u, v), color: Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + let idx = painter.add(egui::Shape::Noop); + self.slot = Some((painter, idx, egui::Shape::mesh(mesh))); + } else { + xf.paint_image(&painter, tex.id(), 0.0, 0.0, 1.0, 1.0); + if let Some(look) = settled { + painter.galley(xf.norm_to_screen(look.at.0, look.at.1), Arc::clone(&look.galley), look.color); + } + } + } + + /// The open paragraph has changed: show the textless page under its editor this frame. + pub fn reveal(&mut self) { + if let Some((painter, idx, shape)) = self.slot.take() { + painter.set(idx, shape); + } + } +} diff --git a/crates/ui-egui/src/edit_text_ui/image.rs b/crates/ui-egui/src/edit_text_ui/image.rs new file mode 100644 index 000000000..9f55a7d5e --- /dev/null +++ b/crates/ui-egui/src/edit_text_ui/image.rs @@ -0,0 +1,637 @@ +use super::*; + +/// 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)>, + /// Crop mode (Acrobat's crop handles): the box kept so far. + pub crop: Option, + /// The drag is turning the picture by its rotate handle (the `drag` start is then unused). + turning: bool, + /// A move or resize applied on release: the frame it had and the one it got, in the page's + /// 0..1 coordinates, while the page raster still has the picture where it was. + settled: Option<(Rect, Rect)>, +} + +impl ImageSelection { + /// Cancel an in-progress text, rotation or crop drag and restore the crop it was previewing. + pub(crate) fn cancel_pointer_gesture(&mut self) { + self.drag = None; + self.turning = false; + if let Some(crop) = &mut self.crop + && let Some((_, _, before)) = crop.drag.take() + { + crop.keep = before; + } + } +} + +/// Cropping a page image: the handles trim instead of resizing, the part that will go is +/// dimmed, Enter or Done applies and Esc or Cancel leaves it. +#[derive(Clone, Debug, PartialEq)] +pub struct CropMode { + /// The box to keep (user space). + pub keep: [f64; 4], + /// A drag in progress: the grip (0–3 corners, 4–7 edges, 8 the inside), where it started + /// and the box then. + drag: Option<(usize, Pos2, [f64; 4])>, +} + +impl CropMode { + pub fn new(keep: [f64; 4]) -> Self { + Self { keep, drag: None } + } +} + +/// The screen boxes of the crop's eight handles: corners, then the top, right, bottom and left +/// edge midpoints. +fn crop_handles(k: Rect) -> [Pos2; 8] { + [k.left_top(), k.right_top(), k.left_bottom(), k.right_bottom(), k.center_top(), k.right_center(), k.center_bottom(), k.left_center()] +} + +/// Crop mode for the selected image `i` whose visible frame is `b` (screen). Returns `true` +/// (the pointer and keys are the crop's while it lasts). +#[allow(clippy::too_many_arguments)] +fn crop_input( + ui: &egui::Ui, + resp: &egui::Response, + xf: &PageXform, + ptr: &PagePointer, + page: usize, + info: &DocInfo, + view: &mut DocView, + i: usize, + b: Rect, + allow_interaction: bool, +) -> bool { + let painter = ui.painter(); + let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false }; + let hover_pos = ptr.pos; + let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); + // A drag in progress moves a grip; the box stays inside the picture and at least 6 px. + if allow_interaction + && resp.drag_started() + && let Some(o) = ptr.origin + { + let k = keep_screen(mode.keep); + let grip = crop_handles(k).iter().position(|h| h.distance(o) < 8.0).or_else(|| k.contains(o).then_some(8)); + if let Some(g) = grip { + mode.drag = Some((g, o, mode.keep)); + } + } + if let Some((grip, start, at_start)) = mode.drag + && let Some(p) = hover_pos + { + let d = p - start; + let k0 = keep_screen(at_start); + let (mut min, mut max) = (k0.min, k0.max); + match grip { + 0 => { + min.x += d.x; + min.y += d.y; + } + 1 => { + max.x += d.x; + min.y += d.y; + } + 2 => { + min.x += d.x; + max.y += d.y; + } + 3 => { + max.x += d.x; + max.y += d.y; + } + 4 => min.y += d.y, + 5 => max.x += d.x, + 6 => max.y += d.y, + 7 => min.x += d.x, + _ => { + let shift = egui::vec2(d.x.clamp(b.min.x - k0.min.x, b.max.x - k0.max.x), d.y.clamp(b.min.y - k0.min.y, b.max.y - k0.max.y)); + min += shift; + max += shift; + } + } + let (min_w, min_h) = (6.0_f32.min(b.width()), 6.0_f32.min(b.height())); + min.x = min.x.clamp(b.min.x, b.max.x - min_w); + min.y = min.y.clamp(b.min.y, b.max.y - min_h); + max.x = max.x.clamp(min.x + min_w, b.max.x); + max.y = max.y.clamp(min.y + min_h, b.max.y); + mode.keep = user_box(xf, info, page, Rect::from_min_max(min, max)); + if resp.drag_stopped() || !ptr.primary_down { + mode.drag = None; + } + } + let k = keep_screen(mode.keep); + // What goes is dimmed; what stays keeps its frame and handles. + let dim = Color32::from_black_alpha(96); + for r in [ + Rect::from_min_max(b.min, Pos2::new(b.max.x, k.min.y)), + Rect::from_min_max(Pos2::new(b.min.x, k.max.y), b.max), + Rect::from_min_max(Pos2::new(b.min.x, k.min.y), Pos2::new(k.min.x, k.max.y)), + Rect::from_min_max(Pos2::new(k.max.x, k.min.y), Pos2::new(b.max.x, k.max.y)), + ] { + if r.is_positive() { + painter.rect_filled(r.intersect(xf.rect), CornerRadius::ZERO, dim); + } + } + painter.rect_stroke(k, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + for h in crop_handles(k) { + painter.rect( + Rect::from_center_size(h, egui::vec2(8.0, 8.0)), + CornerRadius::ZERO, + Color32::WHITE, + Stroke::new(1.0, ACCENT), + egui::StrokeKind::Middle, + ); + } + // Done and Cancel, painted under the box (above it near the bottom of the view). + let font = egui::FontId::proportional(12.5); + let pill = |label: &str, right: f32, y: f32| -> Rect { + let w = painter.layout_no_wrap(label.to_string(), font.clone(), Color32::WHITE).size().x + 20.0; + Rect::from_min_size(Pos2::new(right - w, y), egui::vec2(w, 22.0)) + }; + let y = if k.max.y + 30.0 < xf.rect.max.y { k.max.y + 6.0 } else { k.min.y - 28.0 }; + let done = pill(tl!("Done"), k.max.x, y); + let cancel = pill(tl!("Cancel"), done.min.x - 6.0, y); + let t = crate::theme::Tokens::get(ui.ctx()); + painter.rect_filled(done, CornerRadius::same(11), ACCENT); + painter.text(done.center(), egui::Align2::CENTER_CENTER, tl!("Done"), font.clone(), Color32::WHITE); + painter.rect(cancel, CornerRadius::same(11), t.panel, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + painter.text(cancel.center(), egui::Align2::CENTER_CENTER, tl!("Cancel"), font, t.text); + if let Some(p) = hover_pos { + let icon = if let Some(g) = crop_handles(k).iter().position(|h| h.distance(p) < 8.0) { + match g { + 0 | 3 => egui::CursorIcon::ResizeNwSe, + 1 | 2 => egui::CursorIcon::ResizeNeSw, + 4 | 6 => egui::CursorIcon::ResizeVertical, + _ => egui::CursorIcon::ResizeHorizontal, + } + } else if done.contains(p) || cancel.contains(p) { + egui::CursorIcon::PointingHand + } else if k.contains(p) { + egui::CursorIcon::Move + } else { + egui::CursorIcon::Default + }; + ui.ctx().set_cursor_icon(icon); + } + let clicked = |r: Rect| allow_interaction && resp.clicked() && hover_pos.is_some_and(|p| r.contains(p)); + let keys = |key: egui::Key| allow_interaction && ui.input(|inp| inp.key_pressed(key)) && !ui.ctx().egui_wants_keyboard_input(); + if clicked(done) || keys(egui::Key::Enter) { + let keep = mode.keep; + if let Some(s) = view.image_selection.as_mut() { + s.crop = None; + } + if keep_screen(keep) != b { + view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Crop(keep) }); + } + } else if (clicked(cancel) || keys(egui::Key::Escape)) + && let Some(s) = view.image_selection.as_mut() + { + s.crop = None; + } + true +} + +impl ImageSelection { + pub(crate) fn dragging(&self) -> bool { + self.drag.is_some() + } +} + +/// The page raster's picture of the box `from`, stretched into `to`, over blanks at `blank` (where +/// the screen may still show the picture). +fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::TextureId, blank: &[Rect], from: Rect, to: Rect) { + let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); + for r in blank { + clipped.rect_filled(*r, CornerRadius::ZERO, Color32::WHITE); + } + let mut mesh = egui::Mesh::with_texture(tex); + for (a, b) in [ + (from.left_top(), to.left_top()), + (from.right_top(), to.right_top()), + (from.right_bottom(), to.right_bottom()), + (from.left_bottom(), to.left_bottom()), + ] { + let (u, v) = xf.screen_to_norm(a); + mesh.vertices.push(egui::epaint::Vertex { pos: b, uv: Pos2::new(u, v), color: Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + clipped.add(egui::Shape::mesh(mesh)); +} + +/// Once a selected corner or the rotate knob is grabbed, text under it cannot take over the gesture. The same +/// applies after the pointer leaves the image/page while dragging. +pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, view: &DocView) -> bool { + let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page) else { return false }; + if selection.drag.is_some() { + return true; + } + let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false }; + let Some(rect) = image.visible_rect else { return false }; + let b = xf.user_rect(info, page, rect.map(|v| v as f32)); + let p = if ptr.primary_down { ptr.origin } else { ptr.pos }; + let knob = rotate_knob(b, ui.clip_rect()).0; + p.is_some_and(|p| [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom(), knob].iter().any(|c| c.distance(p) < 8.0)) +} + +/// 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), + Copy(usize, usize), + CopyFile(usize, usize), + /// Copy the picture, then remove it from the page. + Cut(usize, usize), + /// Mark the picture for redaction (applied later from Redact a PDF). + Redact(usize, usize), + /// Create a link over the picture. + Link(usize, usize), + /// Open Recognize Text for the picture's page. + Recognize(usize), +} + +/// Where the rotate knob sits, and the edge point its stem starts from: above the middle of the +/// top edge, or below the bottom edge when the area on screen (`view`) has no room above. +pub(super) fn rotate_knob(b: Rect, view: Rect) -> (Pos2, Pos2) { + let above = b.center_top() - egui::vec2(0.0, 22.0); + if above.y - 6.0 >= view.top() { (above, b.center_top()) } else { (b.center_bottom() + egui::vec2(0.0, 22.0), b.center_bottom()) } +} + +/// Degrees clockwise (-180..=180) that turn "straight up" from `centre` onto the pointer; `snap` +/// rounds to steps of 15 degrees (Shift). A pointer on the centre turns nothing. +pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 { + let v = pointer - centre; + if !(v.x.is_finite() && v.y.is_finite()) || v.length() < 4.0 { + return 0.0; + } + // Screen y points down: up is (0, -1); clockwise is atan2(x, -y). + let d = v.x.atan2(-v.y).to_degrees(); + if snap { (d / 15.0).round() * 15.0 } else { d } +} + +/// 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, + ptr: &PagePointer, + page: usize, + info: &DocInfo, + images: &[pdfcraft_engine::PageImage], + view: &mut DocView, + action: &mut Option, + allow_interaction: bool, +) -> bool { + let boxes: Vec = images.iter().map(|im| im.visible_rect.map_or(Rect::NOTHING, |r| xf.user_rect(info, page, r.map(|v| v as f32)))).collect(); + let painter = ui.painter(); + let hover_pos = ptr.pos; + let image_contains = |index: usize, point: Pos2| { + let (x, y) = xf.screen_to_view(point); + let Some(p) = info.pages.get(page) else { return false }; + let user = p.view_to_user(x, y); + images.get(index).is_some_and(|im| im.contains(user.map(f64::from))) + }; + let selected = view.image_selection.as_ref().filter(|s| s.page == page).map(|s| s.index).filter(|i| *i < boxes.len()); + let dragging = view.image_selection.as_ref().is_some_and(|s| s.drag.is_some()); + // Few images: faint outlines for all of them, as before. Many: only the selected and + // hovered boxes, so a ~1000-image CAD page stays interactive in Edit text & images. + // Fully clipped placements (empty visible frames) get no outline. + let faint_all = boxes.len() <= MAX_OUTLINES; + // The picture being dragged has its frame at the pointer; its outline where it was would + // trail behind until the release. + let moving = selected.filter(|_| dragging); + if faint_all { + for (_, b) in boxes.iter().enumerate().filter(|(i, b)| b.is_positive() && moving != Some(*i)) { + painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); + } + } + let hovered_box = hover_pos.and_then(|p| boxes.iter().enumerate().rposition(|(i, b)| b.is_positive() && b.contains(p) && image_contains(i, p))); + if !faint_all { + for i in selected.filter(|_| moving.is_none()).into_iter().chain(hovered_box.filter(|h| Some(*h) != selected)) { + if let Some(b) = boxes.get(i).filter(|b| b.is_positive()) { + painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); + } + } + } + for i in selected.into_iter().chain(hovered_box).collect::>() { + let Some(b) = boxes.get(i).copied().filter(|b| b.is_positive()) else { continue }; + if selected == Some(i) { + // The selected frame and handles are drawn below (or the drag preview instead). + if !dragging { + painter.rect_filled(b, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); + } + } else { + painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); + } + } + // The selected image: frame, corner handles, dragging. + if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) { + let b = boxes[i]; + if view.image_selection.as_ref().is_some_and(|s| s.crop.is_some()) { + return crop_input(ui, resp, xf, ptr, page, info, view, i, b, allow_interaction); + } + let corners = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()]; + let handles = |rect: Rect| { + for c in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] { + 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, + ); + } + }; + // Until the page is rendered again its raster has an applied move or resize's picture + // where it was: it stays blanked there and drawn where it went. + // The whole-page raster and its tiles are replaced separately: the old frame stays blanked + // while anything still shows the picture there, but its pixels come from the old frame + // only while the whole-page raster itself is the old one. Once that is fresh it has the + // picture where it went, and that is where they come from. + let (out_of_date, raster_old, raster) = (view.page_out_of_date(page), view.page_raster_out_of_date(page), view.page_raster(page)); + let screen = |r: Rect| Rect::from_two_pos(xf.norm_to_screen(r.min.x, r.min.y), xf.norm_to_screen(r.max.x, r.max.y)); + // Where the screen may still show the picture from before an applied move, while the page + // isn't drawn again yet. Another drag keeps it blanked too. + let left_at = view.image_selection.as_ref().and_then(|s| s.settled).filter(|_| out_of_date).map(|(from, _)| from); + if let Some(s) = view.image_selection.as_mut().filter(|s| s.drag.is_none()) { + match (s.settled, raster) { + (Some((from, to)), Some(tex)) if out_of_date => { + let source = if raster_old { from } else { to }; + stretched_picture(painter, xf, tex, &[screen(from)], screen(source), screen(to)); + } + _ => s.settled = None, + } + } + if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) { + painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + handles(b); + } + if allow_interaction && let Some(corner) = hover_pos.and_then(|p| corners.iter().position(|c| c.distance(p) < 8.0)) { + ui.ctx().set_cursor_icon(if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); + } + // The rotate handle: a knob above the top edge, joined to it by a short stem. + let (knob, stem) = rotate_knob(b, ui.clip_rect()); + if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) { + painter.line_segment([stem, knob], Stroke::new(1.0, ACCENT)); + painter.circle(knob, 5.0, Color32::WHITE, Stroke::new(1.0, ACCENT)); + if allow_interaction && hover_pos.is_some_and(|p| p.distance(knob) < 8.0) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + } + if allow_interaction + && resp.drag_started() + && let Some(o) = ptr.origin + && o.distance(knob) < 8.0 + { + if let Some(s) = view.image_selection.as_mut() { + s.drag = Some((o, None)); + s.turning = true; + } + } else if allow_interaction + && resp.drag_started() + && let Some(o) = ptr.origin + { + let corner = corners.iter().position(|c| c.distance(o) < 8.0); + if corner.is_some() || image_contains(i, o) { + let opposite = corner.map(|k| corners[3 - k]); + if let Some(s) = view.image_selection.as_mut() { + s.drag = Some((o, opposite)); + } + } + } + if view.image_selection.as_ref().is_some_and(|s| s.turning) { + let shift = ui.input(|i| i.modifiers.shift); + let degrees = hover_pos.map_or(0.0, |p| turn_degrees(b.center(), p, shift)); + // The turned outline as a preview; the engine redraws the pixels on release. + let centre = b.center(); + let (sin, cos) = degrees.to_radians().sin_cos(); + let turn = |c: Pos2| { + let v = c - centre; + centre + egui::vec2(v.x * cos - v.y * sin, v.x * sin + v.y * cos) + }; + let outline = [b.left_top(), b.right_top(), b.right_bottom(), b.left_bottom()].map(turn).to_vec(); + painter.add(egui::Shape::closed_line(outline, Stroke::new(1.5, ACCENT))); + let galley = painter.layout_no_wrap(format!("{degrees:.0}\u{b0}"), egui::FontId::proportional(12.0), ACCENT); + let label = Rect::from_center_size(turn(knob) - egui::vec2(0.0, 14.0), galley.size() + egui::vec2(8.0, 4.0)); + painter.rect(label, CornerRadius::same(3), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); + painter.galley(label.center() - galley.size() / 2.0, galley, ACCENT); + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + if resp.drag_stopped() || !ptr.primary_down { + if let Some(s) = view.image_selection.as_mut() { + s.drag = None; + s.turning = false; + } + if degrees.abs() >= 0.05 { + view.pending_edit = + Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::RotateBy(f64::from(degrees)) }); + } + } + return true; + } + if let Some((start, opposite)) = view.image_selection.as_ref().and_then(|s| s.drag) + && let Some(p) = hover_pos + { + 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)); + // Use the axis with the larger relative pointer change. Taking the maximum + // absolute size freezes shrinking when the other axis hasn't moved. Measure + // from the press point so grabbing a handle off-centre cannot cause a jump. + let horizontal = (p.x - start.x) * (start.x - fixed.x).signum() / w0; + let vertical = (p.y - start.y) * (start.y - fixed.y).signum() / h0; + let delta = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; + let k = (1.0 + delta).max(0.05); + let (w, h) = (w0 * k, h0 * k); + let x = if start.x < fixed.x { fixed.x - w } else { fixed.x }; + let y = if start.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), + }; + // Move/resize the full placement by the visible frame's change. The engine + // transforms an isolated crop together with its image, preserving its contents. + let raw = images.get(i).map_or(b, |im| xf.user_rect(info, page, im.rect.map(|v| v as f32))); + // A zero-width or zero-height visible frame (or a degenerate ratio) keeps the + // placement's scale instead of producing inf/NaN. + let ratio = |new: f32, old: f32| if old.abs() > 1e-3 && (new / old).is_finite() { new / old } else { 1.0 }; + let factor = egui::vec2(ratio(preview.width(), b.width()), ratio(preview.height(), b.height())); + let target = Rect::from_min_max(preview.min + (raw.min - b.min) * factor, preview.min + (raw.max - b.min) * factor); + let visible = preview.intersect(xf.rect); + if visible.is_positive() { + // The pixels follow the frame: the page raster's picture of the image, stretched + // into the preview over a blank where it was. Re-rendering every frame of a drag + // would lag; the edit renders it properly on release. + if preview != b + && let Some(tex) = view.page_raster(page) + { + // Its pixels: where the whole-page raster has it. + let source = left_at.filter(|_| raster_old).map_or(b, screen); + let blank: Vec = left_at.map(screen).into_iter().chain(std::iter::once(b)).collect(); + stretched_picture(painter, xf, tex, &blank, source, preview); + } + painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); + painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + handles(preview); + } + if resp.drag_stopped() || !ptr.primary_down { + if let Some(s) = view.image_selection.as_mut() { + s.drag = None; + let norm = |r: Rect| { + let ((u0, v0), (u1, v1)) = (xf.screen_to_norm(r.min), xf.screen_to_norm(r.max)); + Rect::from_two_pos(Pos2::new(u0, v0), Pos2::new(u1, v1)) + }; + s.settled = (preview != b).then(|| (left_at.unwrap_or_else(|| norm(b)), norm(preview))); + } + if preview != b { + view.pending_edit = + Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, target)) }); + } + } + ui.ctx().set_cursor_icon(match opposite { + Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw, + Some(_) => egui::CursorIcon::ResizeNwSe, + None => egui::CursorIcon::Move, + }); + return true; + } + if allow_interaction + && 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; + } + } + if !allow_interaction { + return false; + } + // An open menu stays drawn wherever the pointer is: most of its items lie outside the picture + // (a logo is smaller than its menu), and a menu that is not drawn for a frame cannot be clicked. + // Only a menu opened by a right-click on the picture: one opened over text or empty page while + // a picture stays selected is the page's own menu. + let menu_key = resp.id.with("image-menu"); + if resp.secondary_clicked() { + ui.ctx().data_mut(|d| d.insert_temp(menu_key, false)); + } + let mut menu_drawn = false; + if let Some(sel) = selected + && resp.context_menu_opened() + && ui.ctx().data(|d| d.get_temp::(menu_key)).unwrap_or(false) + { + resp.context_menu(|ui| image_menu(ui, page, sel, images, view, action)); + menu_drawn = true; + } + let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p)) else { return false }; + let Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| b.contains(p) && image_contains(i, 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); + } + let Some(b) = boxes.get(hit).copied() else { return false }; + let corner = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()].iter().position(|c| c.distance(p) < 8.0); + ui.ctx().set_cursor_icon(match (selected == Some(hit), corner) { + (true, Some(0 | 3)) => egui::CursorIcon::ResizeNwSe, + (true, Some(_)) => egui::CursorIcon::ResizeNeSw, + (true, None) => egui::CursorIcon::Move, + _ => egui::CursorIcon::PointingHand, + }); + if resp.clicked() || resp.secondary_clicked() { + view.image_selection = Some(ImageSelection { page, index: hit, drag: None, crop: None, turning: false, settled: None }); + } + if resp.secondary_clicked() { + ui.ctx().data_mut(|d| d.insert_temp(menu_key, true)); + } + if selected == Some(hit) && !menu_drawn { + resp.context_menu(|ui| image_menu(ui, page, hit, images, view, action)); + } + true +} + +/// The right-click menu of the picture `hit` (its rotate, flip, replace, copy, cut, redact, link, +/// recognize and delete items). +fn image_menu( + ui: &mut egui::Ui, + page: usize, + hit: usize, + images: &[pdfcraft_engine::PageImage], + view: &mut DocView, + action: &mut Option, +) { + use pdfcraft_engine::ImageEdit as E; + let items: [(&str, Option); 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(); + } + } + // Crop: the handles trim instead of resizing until Done. + if ui.button(tl!("Crop Image")).clicked() { + if let (Some(s), Some(im)) = (view.image_selection.as_mut(), images.get(hit)) { + s.crop = Some(CropMode::new(im.visible_rect.unwrap_or(im.rect))); + } + ui.close(); + } + let cropped = images.get(hit).is_some_and(|im| im.visible_rect.is_some_and(|v| v.iter().zip(im.rect.iter()).any(|(a, b)| (a - b).abs() > 0.01))); + if ui.add_enabled(cropped, egui::Button::new(tl!("Remove Crop"))).clicked() { + if let Some(im) = images.get(hit) { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Crop(im.rect) }); + } + 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(); + } + // Cutting and copying also find the picture inside grouped Form artwork. + if ui.button(tl!("Cut Image")).clicked() { + *action = Some(ImageAction::Cut(page, hit)); + ui.close(); + } + if ui.button(tl!("Copy Image")).clicked() { + *action = Some(ImageAction::Copy(page, hit)); + ui.close(); + } + if ui.button(tl!("Copy Image in Original Format")).clicked() { + *action = Some(ImageAction::CopyFile(page, hit)); + ui.close(); + } + ui.separator(); + if ui.button(tl!("Redact Image")).clicked() { + *action = Some(ImageAction::Redact(page, hit)); + ui.close(); + } + if ui.button(tl!("Create Link…")).clicked() { + *action = Some(ImageAction::Link(page, hit)); + ui.close(); + } + if ui.button(tl!("Recognize Text…")).clicked() { + *action = Some(ImageAction::Recognize(page)); + 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(); + } +} diff --git a/crates/ui-egui/src/edit_text_ui/layout.rs b/crates/ui-egui/src/edit_text_ui/layout.rs new file mode 100644 index 000000000..a59c10899 --- /dev/null +++ b/crates/ui-egui/src/edit_text_ui/layout.rs @@ -0,0 +1,270 @@ +use super::*; + +/// 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() + } +} + +pub(super) fn look_of(b: &pdfcraft_engine::TextBlock) -> pdfcraft_engine::AddedText { + source_look(&b.base_font, b.size, b.color, b.bold, b.italic) +} + +pub(crate) 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) +} + +pub(super) 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, + ) +} + +/// The dark box behind text too pale to read on white. +pub(super) const DARK_EDITOR_FILL: Color32 = Color32::from_gray(0x26); + +/// WCAG relative luminance of an sRGB colour. +pub(super) fn luminance(c: Color32) -> f32 { + let channel = |v: u8| { + let v = f32::from(v) / 255.0; + if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * channel(c.r()) + 0.7152 * channel(c.g()) + 0.0722 * channel(c.b()) +} + +/// WCAG contrast ratio between two colours (1 to 21). +pub(super) fn contrast(a: Color32, b: Color32) -> f32 { + let (la, lb) = (luminance(a), luminance(b)); + (la.max(lb) + 0.05) / (la.min(lb) + 0.05) +} + +/// The inline editor's background for text drawn in `text`: white, or a dark box when the text +/// reads better on that (white or pale text, which would vanish on white). +pub(crate) fn editor_fill(text: Color32) -> Color32 { + if contrast(text, Color32::WHITE) >= contrast(text, DARK_EDITOR_FILL) { Color32::WHITE } else { DARK_EDITOR_FILL } +} + +/// One TextEdit lays out all source runs; its caret and selection span the whole region. +pub(crate) fn install_source_fonts(ctx: &egui::Context, doc: &pdfcraft_engine::Document, page: usize) -> BTreeMap { + let programs = doc.text_editor_fonts(page); + let mut definitions = ctx.fonts(|f| f.definitions().clone()); + // Only the current editor's document fonts live in the atlas. Never accumulate the + // programs of previously opened files or add them to the interface's default families. + definitions.font_data.retain(|name, _| !name.starts_with("pdf-source-")); + definitions.families.retain(|family, _| !matches!(family, FontFamily::Name(name) if name.starts_with("pdf-source-"))); + let mut families = BTreeMap::new(); + for (index, program) in programs.iter().enumerate() { + let name = format!("pdf-source-{index}"); + definitions.font_data.insert(name.clone(), Arc::new(egui::FontData::from_owned(program.bytes.clone()))); + families.insert(program.resource.clone(), name); + } + for program in &programs { + let Some(name) = families.get(&program.resource) else { continue }; + // Word-created PDFs often split one face into several subsets. Use all of the + // document's matching subsets before falling back for newly inserted characters. + let mut stack = vec![name.clone()]; + stack.extend( + programs + .iter() + .filter(|p| { + pdfcraft_fonts::embedding::face_key(&p.face) == pdfcraft_fonts::embedding::face_key(&program.face) + && p.resource != program.resource + }) + .filter_map(|p| families.get(&p.resource).cloned()), + ); + if let Some(fallback) = definitions.families.get(&FontFamily::Proportional) { + stack.extend(fallback.iter().cloned()); + } + definitions.families.insert(FontFamily::Name(name.clone().into()), stack); + } + if !families.is_empty() { + ctx.set_fonts(definitions); + } + families +} + +pub(super) fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32) -> egui::text::LayoutJob { + let mut job = egui::text::LayoutJob::default(); + // An unchanged PDF row already has an authoritative line break. Do not rewrap it + // because font rounding or a fallback face measures a fraction of a pixel wider. + job.wrap.max_width = if text == ed.original && ed.look == ed.look0 && ed.extras == ed.extras0 { f32::INFINITY } else { width }; + if let Some((block, lines)) = &ed.source { + let lefts: Vec<_> = block.rows.iter().filter_map(|r| r.first().and_then(|i| lines.get(*i)).map(|l| l.rect[0])).collect(); + let left_span = lefts.iter().copied().fold(f64::NEG_INFINITY, f64::max) - lefts.iter().copied().fold(f64::INFINITY, f64::min); + let centered = block.rows.len() > 1 + && left_span > block.size * 0.5 + && block.rows.iter().all(|row| { + let rect = row + .iter() + .filter_map(|i| lines.get(*i)) + .map(|l| l.rect) + .reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]); + rect.is_some_and(|r| ((r[0] + r[2] - block.rect[0] - block.rect[2]) / 2.0).abs() < block.size) + }); + if centered { + job.halign = egui::Align::Center; + } + for (range, index) in block.edited_spans(text) { + let Some(line) = lines.get(index) else { continue }; + let mut look = source_look(&line.base_font, line.size, line.color, line.bold, line.italic); + look.size = line.size; + if ed.look.family != ed.look0.family || ed.family_chosen { + look.family = ed.look.family; + } + if ed.look.bold != ed.look0.bold { + look.bold = ed.look.bold; + } + if ed.look.italic != ed.look0.italic { + look.italic = ed.look.italic; + } + if ed.look.color != ed.look0.color { + look.color = ed.look.color; + } + if ed.look.size != ed.look0.size { + look.size = ed.look.size; + } + let mut font = editor_font(&look, (look.size as f32 * scale).clamp(1.0, 300.0)); + let original_face = + ed.look.family == ed.look0.family && !ed.family_chosen && ed.look.bold == ed.look0.bold && ed.look.italic == ed.look0.italic; + let embedded = original_face && ed.fonts.contains_key(&line.font); + if embedded && let Some(name) = ed.fonts.get(&line.font) { + font.family = FontFamily::Name(name.clone().into()); + } + let native_width = ui.fonts_mut(|f| f.layout_no_wrap(line.text.clone(), font.clone(), Color32::BLACK).size().x); + let target_width = ((line.rect[2] - line.rect[0]) * look.size / line.size.max(0.01)) as f32 * scale; + let spacing = if original_face { (target_width - native_width) / line.text.chars().count().saturating_sub(1).max(1) as f32 } else { 0.0 }; + job.append( + text.get(range).unwrap_or_default(), + 0.0, + egui::TextFormat { + font_id: font, + extra_letter_spacing: spacing + ed.extras.char_spacing as f32 * scale, + color: color32(look.color), + italics: look.italic && !embedded, + ..Default::default() + }, + ); + } + } else { + job.append( + text, + 0.0, + egui::TextFormat { + font_id: editor_font(&ed.look, ed.size), + color: color32(ed.look.color), + italics: ed.look.italic, + ..Default::default() + }, + ); + } + job +} + +/// `galley` drawn in no colour: its rows still place the caret and the selection. +pub(super) fn unseen(mut galley: Arc) -> Arc { + for placed in &mut Arc::make_mut(&mut galley).rows { + for vertex in &mut Arc::make_mut(&mut placed.row).visuals.mesh.vertices { + vertex.color = Color32::TRANSPARENT; + } + } + galley +} + +/// Align the editable galley's baselines to the source rows instead of egui's interface +/// leading. Glyphs, meshes, caret and selection receive the same transform. +pub(super) fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32) -> Arc { + let mut job = editor_job(ui, ed, text, scale, width); + let centered = job.halign == egui::Align::Center; + job.halign = egui::Align::LEFT; + let mut galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); + let Some((block, lines)) = &ed.source else { return galley }; + let sources: Vec<_> = block + .rows + .iter() + .filter_map(|row| { + let first = row.first().and_then(|i| lines.get(*i))?; + let rect = row + .iter() + .filter_map(|i| lines.get(*i)) + .map(|l| l.rect) + .reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])])?; + Some((first.baseline()[1], rect)) + }) + .collect(); + let Some((first_baseline, first_rect)) = sources.first().copied() else { return galley }; + let leading = if ed.extras.line_spacing > 0.0 { + ed.extras.line_spacing * ed.look.size + } else { + let fallback = if block.lines.len() == 1 && !text.contains('\n') { + block.lines.first().and_then(|i| lines.get(*i)).map_or(block.size * 1.2, |line| line.wrap_leading()) + } else { + block.size * 1.5 + }; + sources.windows(2).map(|r| (r[0].0 - r[1].0).abs()).rfind(|v| *v > 0.0).unwrap_or(fallback) + }; + let g = Arc::make_mut(&mut galley); + let mut bounds = Rect::NOTHING; + let mut meshes = Rect::NOTHING; + for (index, placed) in g.rows.iter_mut().enumerate() { + let (last_baseline, last_rect) = sources.last().copied().unwrap_or((first_baseline, first_rect)); + let (baseline, source_rect) = sources + .get(index) + .copied() + .unwrap_or((last_baseline - index.saturating_add(1).saturating_sub(sources.len()) as f64 * leading, last_rect)); + let changed_size = ed.look.size != ed.look0.size; + let ratio = if changed_size { ed.look.size / ed.look0.size.max(0.01) } else { 1.0 }; + let ascent = (source_rect[3] - sources.get(index).map_or(last_baseline, |s| s.0)) as f32 * scale * ratio as f32; + let descent = (sources.get(index).map_or(last_baseline, |s| s.0) - source_rect[1]) as f32 * scale * ratio as f32; + let base_y = (block.rect[3] - baseline) as f32 * scale; + let row = Arc::make_mut(&mut placed.row); + placed.pos.y = base_y - ascent; + placed.pos.x = if centered { (width - row.size.x) / 2.0 } else { (source_rect[0] - block.rect[0]) as f32 * scale }; + for glyph in &mut row.glyphs { + let dy = ascent - glyph.pos.y; + glyph.pos.y = ascent; + glyph.font_ascent = ascent; + glyph.font_height = ascent + descent; + glyph.line_height = ascent + descent; + if !glyph.uv_rect.is_nothing() { + for vertex in row.visuals.mesh.vertices.iter_mut().skip(glyph.first_vertex as usize).take(4) { + vertex.pos.y += dy; + } + } + } + row.size.y = (ascent + descent).max(1.0); + row.visuals.mesh_bounds = row.visuals.mesh.vertices.iter().fold(Rect::NOTHING, |r, v| r.union(Rect::from_min_max(v.pos, v.pos))); + bounds = bounds.union(Rect::from_min_size(placed.pos, row.size)); + meshes = meshes.union(row.visuals.mesh_bounds.translate(placed.pos.to_vec2())); + } + g.rect = Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(bounds.right().max(0.0), bounds.bottom().max(0.0))); + g.mesh_bounds = meshes; + galley +} + +/// Caret colour that stays visible on a field filled with `fill`: black on light fills, white on dark ones. +pub(crate) fn caret_color_on(fill: Color32) -> Color32 { + let luma = 299 * u32::from(fill.r()) + 587 * u32::from(fill.g()) + 114 * u32::from(fill.b()); + if luma > 127_500 { Color32::BLACK } else { Color32::WHITE } +}