refactor(ui-egui): split edit_text_ui.rs into edit_text_ui/

New modules: background, layout, image.
This commit is contained in:
aiongg
2026-10-26 21:17:18 +00:00
parent 36917232ba
commit c83a42144a
4 changed files with 1116 additions and 1099 deletions
File diff suppressed because it is too large Load Diff
@@ -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<pdfcraft_render::RenderPool>,
pub(super) texture: Option<egui::TextureHandle>,
pub(super) tag: Option<u64>,
received: Option<u64>,
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<EditorLook>,
/// 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<EditorLook>,
}
/// 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<egui::Galley>,
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<egui::Galley>, 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);
}
}
}
+637
View File
@@ -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<Pos2>)>,
/// Crop mode (Acrobat's crop handles): the box kept so far.
pub crop: Option<CropMode>,
/// 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<ImageAction>,
allow_interaction: bool,
) -> bool {
let boxes: Vec<Rect> = 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::<Vec<_>>() {
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<Rect> = 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::<bool>(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<ImageAction>,
) {
use pdfcraft_engine::ImageEdit as E;
let items: [(&str, Option<E>); 4] = [
(tl!("Rotate Clockwise"), Some(E::Rotate(1))),
(tl!("Rotate Counterclockwise"), Some(E::Rotate(3))),
(tl!("Flip Horizontal"), Some(E::Flip { horizontal: true })),
(tl!("Flip Vertical"), Some(E::Flip { horizontal: false })),
];
for (label, change) in items {
if ui.button(label).clicked() {
view.pending_edit = change.map(|c| Edit::EditPageImage { page, index: hit, change: c });
ui.close();
}
}
// 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();
}
}
+270
View File
@@ -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<String, String> {
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<egui::Galley>) -> Arc<egui::Galley> {
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<egui::Galley> {
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 }
}