mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:23:02 +00:00
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:
committed by
GitHub
parent
db5b9b4364
commit
4e06a01631
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user