Edit text: the four-way arrow wherever a drag moves, and a sharp page while editing (#1717)

Moving a paragraph or image showed the text cursor over a closed paragraph
and a closed hand while dragging, so the drag by its box (and, since #1600,
by the frame of the open one) went unnoticed. Now the four-way arrow is
offered over a closed paragraph, on the frame band of the open one, over a
selected object group and throughout a move; edges keep the rewrap cursor and
the open paragraph's text keeps the text cursor.

While a paragraph is open, the page around the editor is drawn from one
raster capped at the 2048-pixel backdrop side, which went soft past about
1.2x on a Retina display. It is now asked for at the tiling threshold (4096),
sharp to about 2.5x on Letter or A4; a GPU with a smaller texture limit still
caps it.

Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Tristan von Kries
2026-10-15 08:21:21 +02:00
committed by GitHub
parent db5b9b4364
commit 4e06a01631
4 changed files with 108 additions and 11 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ const GRID_TAG: u64 = 1 << 61;
/// The tag of a raster that is out of date (shown until its replacement arrives).
const STALE_TAG: u64 = u64::MAX;
/// Pages whose raster would exceed this many device pixels on a side are drawn in tiles.
const TILE_THRESHOLD: f32 = 4096.0;
pub(crate) const TILE_THRESHOLD: f32 = 4096.0;
/// Base device-pixel side of a page tile. The actual side grows with the zoom
/// ([`tile_side`]), so a zoomed viewport is covered by a few tiles instead of many.
const TILE: u32 = 1024;
+61 -8
View File
@@ -50,8 +50,12 @@ impl TextBackground {
}
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::BASE_SIDE.min(max_side) / p.width.max(p.height).max(1.0));
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() }]);
@@ -362,6 +366,8 @@ pub struct BlockDrag {
/// How far an edge grip reaches either side of a box's edge (screen pixels).
const EDGE_REACH: f32 = 6.0;
/// How far outside an open paragraph's frame the pointer still means the frame (screen pixels).
const FRAME_REACH: f32 = 4.0;
/// The rewrap handles drawn on a paragraph box's left and right edges (screen).
fn width_handles(b: Rect) -> [Rect; 2] {
@@ -565,7 +571,7 @@ pub(crate) fn image_input(
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::Grabbing,
None => egui::CursorIcon::Move,
});
return true;
}
@@ -694,7 +700,7 @@ pub(crate) fn page_input(
if resizing {
draw_handles(preview);
}
ui.ctx().set_cursor_icon(if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Grabbing });
ui.ctx().set_cursor_icon(if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Move });
if resp.drag_stopped() || !ui.input(|i| i.pointer.any_down()) {
view.block_drag = None;
let (from, to) = (user_box(xf, info, page, b), user_box(xf, info, page, preview));
@@ -742,7 +748,15 @@ pub(crate) fn page_input(
if upright && active.is_none() {
draw_handles(boxes[hit]);
}
ui.ctx().set_cursor_icon(if on_edge { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Text });
// A closed paragraph moves when dragged (the four-way arrow says so; a click still opens it
// for typing); an edge rewraps it. The open one takes the text cursor.
ui.ctx().set_cursor_icon(if on_edge {
egui::CursorIcon::ResizeHorizontal
} else if active.is_none() {
egui::CursorIcon::Move
} else {
egui::CursorIcon::Text
});
if resp.clicked() {
view.image_selection = None;
let l = &lines[hit];
@@ -1021,6 +1035,14 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
// can differ from the PDF's. Keep every visible row inside the growing frame.
let live = draft_rect.map_or(visible, |bounds| bounds.union(visible));
ui.painter().rect_stroke(live.expand(2.0), CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
// On the frame, a press takes hold of the box (a click selects it, a drag moves it):
// the pointer says so there, and reads as text inside.
if let Some(p) = ui.input(|i| i.pointer.hover_pos())
&& live.expand(2.0 + FRAME_REACH).contains(p)
&& !live.expand(2.0).contains(p)
{
ui.ctx().set_cursor_icon(egui::CursorIcon::Move);
}
// White, unless the text is too pale to read on white (white text from a coloured
// banner, #913): then a dark box, with a caret that shows on it.
let fill = if background_ready { Color32::TRANSPARENT } else { editor_fill(text_color) };
@@ -1097,15 +1119,13 @@ pub(crate) fn caret_color_on(fill: Color32) -> Color32 {
mod tests {
use super::*;
#[test]
fn text_background_is_cached_without_editing_and_released_on_cancel() {
/// An app with one text document open, and an editor on its first paragraph.
fn preview_app() -> (crate::PdfCraftApp, LineEditor) {
let mut app = crate::PdfCraftApp::new();
let bytes = app.session.create_from_text("Preview", "Preview text").unwrap();
app.open_bytes("preview.pdf", None, bytes.as_ref().clone()).unwrap();
let doc = app.session.get(app.views[0].id).unwrap();
let block = doc.text_blocks(0).remove(0);
let source = doc.bytes.clone();
let generation = doc.edit_generation();
let ed = LineEditor {
selection: None,
font_resource: None,
@@ -1127,6 +1147,15 @@ mod tests {
extras: Extras::default(),
extras0: Extras::default(),
};
(app, ed)
}
#[test]
fn text_background_is_cached_without_editing_and_released_on_cancel() {
let (app, ed) = preview_app();
let doc = app.session.get(app.views[0].id).unwrap();
let source = doc.bytes.clone();
let generation = doc.edit_generation();
let ctx = egui::Context::default();
let mut background = TextBackground::default();
for _ in 0..100 {
@@ -1148,6 +1177,30 @@ mod tests {
assert!(background.texture.is_none() && background.renderer.is_none());
}
#[test]
fn the_editing_background_stays_sharp_on_a_zoomed_page() {
// While a paragraph is open, the page around it is drawn from this one raster: it is
// asked for at the tiling threshold, not the 2048-pixel backdrop side, which went soft
// beyond about 1.2x on a Retina display.
let (app, ed) = preview_app();
let doc = app.session.get(app.views[0].id).unwrap();
let page = &doc.info.pages[0];
let ctx = egui::Context::default();
ctx.begin_pass(egui::RawInput { max_texture_side: Some(16384), ..Default::default() });
let mut background = TextBackground::default();
background.prepare(&ctx, doc, Some(&ed), 8.0);
let expected = 8.0f32.min(crate::canvas::TILE_THRESHOLD / page.width.max(page.height));
assert!(expected > crate::canvas::BASE_SIDE / page.width.max(page.height), "the test page needs the larger side");
assert_eq!(background.tag, Some((expected * 1000.0) as u64));
// A GPU that can't hold such a texture caps it.
ctx.end_pass().textures_delta.clear();
ctx.begin_pass(egui::RawInput { max_texture_side: Some(2048), ..Default::default() });
let mut small = TextBackground::default();
small.prepare(&ctx, doc, Some(&ed), 8.0);
assert_eq!(small.tag, Some(((2048.0 / page.width.max(page.height)) * 1000.0) as u64));
ctx.end_pass().textures_delta.clear();
}
#[test]
fn pale_text_is_edited_on_a_dark_box() {
// #913: white text from a coloured banner was typed onto a white box and vanished.
+2 -2
View File
@@ -232,7 +232,7 @@ fn input(s: &mut ObjectSelection, ui: &egui::Ui, resp: &egui::Response, xf: &Pag
} else {
s.gesture = Some(gesture);
}
ui.ctx().set_cursor_icon(if gesture.marquee { egui::CursorIcon::Crosshair } else { egui::CursorIcon::Grabbing });
ui.ctx().set_cursor_icon(if gesture.marquee { egui::CursorIcon::Crosshair } else { egui::CursorIcon::Move });
return true;
}
let Some(p) = pointer.filter(|p| xf.rect.contains(*p) && ui.clip_rect().contains(*p) && resp.rect.contains(*p)) else {
@@ -242,7 +242,7 @@ fn input(s: &mut ObjectSelection, ui: &egui::Ui, resp: &egui::Response, xf: &Pag
if s.page == Some(page) && !s.selected.is_empty() {
let union = s.selected.iter().fold(Rect::NOTHING, |r, o| r.union(screen(xf, info, page, o)));
if union.contains(p) {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
ui.ctx().set_cursor_icon(egui::CursorIcon::Move);
}
return true;
}
+44
View File
@@ -4270,3 +4270,47 @@ fn private_new_chinese_keyboard_input_preserves_font_layout_and_saved_pixels() {
assert_eq!(std::fs::read(path).unwrap(), original);
println!("verified Chinese keyboard insertion/deletion and save/reopen rendering on {pages} pages");
}
/// Moving text shows the four-way arrow wherever a drag would move it (#1520 follow-up): over a
/// closed paragraph, on the frame of an open one, and while dragging. Inside the open one the
/// pointer reads as text; on a closed one's edge, as the rewrap handle.
#[test]
fn moving_text_offers_the_four_way_arrow() {
let mut h = harness(2, |_| {});
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let id = h.state().views[0].id;
let block = h.state().session.get(id).unwrap().text_blocks(0)[0].clone();
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let k = r.width() / 200.0;
let screen = |x: f64, y: f64| egui::pos2(r.left() + x as f32 * k, r.top() + (300.0 - y as f32) * k);
let centre = screen((block.rect[0] + block.rect[2]) / 2.0, (block.rect[1] + block.rect[3]) / 2.0);
let cursor_at = |h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2| {
h.hover_at(at);
h.run_steps(2);
h.output().platform_output.cursor_icon
};
// A closed paragraph: a drag moves it; its left edge rewraps it.
assert_eq!(cursor_at(&mut h, centre), egui::CursorIcon::Move);
assert_eq!(cursor_at(&mut h, screen(block.rect[0], (block.rect[1] + block.rect[3]) / 2.0)), egui::CursorIcon::ResizeHorizontal);
// While it is being dragged.
h.hover_at(centre);
h.run_steps(1);
h.drag_at(centre);
h.run_steps(1);
let mid = centre + egui::vec2(20.0 * k, 10.0 * k);
h.hover_at(mid);
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move);
h.drop_at(mid);
h.run_steps(4);
let moved = h.state().session.get(id).unwrap().text_blocks(0)[0].clone();
assert!((moved.rect[0] - block.rect[0] - 20.0).abs() < 1.5, "{:?} → {:?}", block.rect, moved.rect);
// Open: text inside, the frame moves it.
let centre = screen((moved.rect[0] + moved.rect[2]) / 2.0, (moved.rect[1] + moved.rect[3]) / 2.0);
click_at(&mut h, centre);
assert!(h.state().views[0].line_editor.is_some());
assert_eq!(cursor_at(&mut h, centre), egui::CursorIcon::Text);
let frame = screen((moved.rect[0] + moved.rect[2]) / 2.0, moved.rect[3]) - egui::vec2(0.0, 4.0);
assert_eq!(cursor_at(&mut h, frame), egui::CursorIcon::Move);
}