diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 8f9e9e4d90..7e9ae12561 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -1163,6 +1163,24 @@ impl ImageView { } self.pdf_drag_position = Some(cursor_pos); + if self.pdf_selection.is_none() { + let hit = { + let item = self.image_item.read(cx); + let Some(pdf_info) = &item.pdf_info else { + return; + }; + self.find_page_and_char_at(cursor_pos, pdf_info) + }; + if let Some((page_index, char_index)) = hit { + self.pdf_selection = Some(PdfSelection { + start_page: page_index, + start_char: char_index, + end_page: page_index, + end_char: char_index, + text: String::new(), + }); + } + } self.update_pdf_drag_selection(cursor_pos, cx); self.check_pdf_autoscroll(cx); } @@ -1235,9 +1253,11 @@ impl ImageView { self.ensure_pdf_selection_text(cx); } None => { - self.pdf_mouse_down = false; - self.pdf_selection = None; - self.pdf_drag_position = None; + // Blank-space press: stay armed so a drag that later reaches + // text can form a selection; a click without drag selects + // nothing. + self.pdf_mouse_down = true; + self.pdf_drag_position = Some(click_pos); self.pdf_autoscroll_task = None; } } @@ -3508,6 +3528,7 @@ impl Render for ImageView { .child( div() .id(("pdf-page-canvas", page_idx)) + .debug_selector(move || format!("pdf-page-canvas-{page_idx}")) .relative() .w(page_w) .h(page_h) @@ -3515,7 +3536,12 @@ impl Render for ImageView { .border_1() .border_color(border_color) .bg(gpui::white()) - .cursor(gpui::CursorStyle::IBeam) + .cursor(match self.annotation_tool { + PdfAnnotationTool::Pen => { + gpui::CursorStyle::Crosshair + } + _ => gpui::CursorStyle::IBeam, + }) .on_mouse_down( MouseButton::Left, cx.listener(move |this, event: &MouseDownEvent, window, cx| { @@ -3844,11 +3870,9 @@ impl Render for ImageView { }; let tool_button = |tool: PdfAnnotationTool, cx: &mut Context| { let is_active = self.annotation_tool == tool; - let selected_label: Option = - is_active.then(|| SharedString::from(label(tool))); Button::new(("pdf-annotation-tool", tool as usize), label(tool)) .label_size(LabelSize::XSmall) - .selected_label::(selected_label) + .toggle_state(is_active) .on_click(cx.listener(move |this, _, _, cx| { this.set_annotation_tool(tool, cx); })) @@ -3856,6 +3880,7 @@ impl Render for ImageView { h_flex() .id("pdf-annotation-tools") + .debug_selector(|| "pdf-annotation-tools".into()) .absolute() .top_2() .left_2() @@ -4632,7 +4657,9 @@ pub fn init(cx: &mut App) { mod tests { use super::*; use fs::{FakeFs, Fs as _}; - use gpui::{TestAppContext, VisualTestContext}; + use gpui::{ + Modifiers, MouseDownEvent, MouseMoveEvent, MouseUpEvent, TestAppContext, VisualTestContext, + }; use settings::SettingsStore; use util::rel_path::rel_path; @@ -5384,6 +5411,254 @@ mod tests { assert_eq!(cache.current_memory_bytes(), 0); assert!(cache.is_empty()); } + + /// Single-page PDF with one text line ("Hello annotation world") near the + /// top, at the same spot the annotation drag test targets. + fn build_annotation_fixture() -> Vec { + let contents = b"BT /F1 24 Tf 72 700 Td (Hello annotation world) Tj ET"; + let bodies: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>".to_vec(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), + format!( + "<< /Length {} >>\nstream\n{}\nendstream", + contents.len(), + String::from_utf8_lossy(contents) + ) + .into_bytes(), + ]; + + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (index, body) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend(format!("{} 0 obj\n", index + 1).into_bytes()); + out.extend_from_slice(body); + out.extend(b"\nendobj\n"); + } + let xref_offset = out.len(); + out.extend(format!("xref\n0 {}\n", bodies.len() + 1).into_bytes()); + out.extend(b"0000000000 65535 f \n"); + for offset in &offsets { + out.extend(format!("{offset:010} 00000 n \n").into_bytes()); + } + out.extend( + format!( + "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n", + bodies.len() + 1, + xref_offset + ) + .into_bytes(), + ); + out + } + + #[gpui::test] + async fn test_annotation_tools_apply_via_mouse(cx: &mut TestAppContext) { + init_test(cx); + let pdf_bytes = build_annotation_fixture(); + let fs = FakeFs::new(cx.executor()); + fs.create_dir(Path::new("/root")) + .await + .expect("test root should be created"); + fs.insert_file("/root/annot.pdf", pdf_bytes).await; + + let project = Project::test(fs, [Path::new("/root")], cx).await; + let worktree_id = cx.update(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .expect("test project should contain a worktree") + .read(cx) + .id() + }); + let image_item = project + .update(cx, |project, cx| { + project.open_image( + ProjectPath { + worktree_id, + path: rel_path("annot.pdf").into(), + }, + cx, + ) + }) + .await + .expect("test pdf should open"); + + let (image_view, cx) = cx + .add_window_view(|window, cx| ImageView::new(image_item.clone(), project, window, cx)); + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + + let original_len = cx + .read(|cx| { + image_item + .read(cx) + .pdf_info + .as_ref() + .map(|i| i.pdf_bytes.len()) + }) + .expect("pdf should be loaded"); + + cx.update(|window, cx| { + image_view.update(cx, |view, cx| { + view.toggle_annotation_tools(&ToggleAnnotationTools, window, cx) + }); + }); + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + + // Click the palette's Highlight button (second tool button) like a user would. + let palette = cx + .debug_bounds("pdf-annotation-tools") + .expect("the annotation palette should be laid out"); + cx.simulate_event(MouseDownEvent { + position: point(palette.origin.x + px(70.), palette.center().y), + button: MouseButton::Left, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }); + cx.run_until_parked(); + cx.simulate_event(MouseUpEvent { + position: point(palette.origin.x + px(70.), palette.center().y), + button: MouseButton::Left, + modifiers: Modifiers::default(), + click_count: 1, + }); + cx.run_until_parked(); + let armed = cx.read(|cx| image_view.read(cx).annotation_tool); + assert_eq!( + armed, + PdfAnnotationTool::Highlight, + "clicking the palette button should arm the highlight tool" + ); + + let canvas = cx + .debug_bounds("pdf-page-canvas-0") + .expect("page canvas should be laid out"); + let drag = |cx: &mut VisualTestContext, from: Point, to: Point| { + cx.simulate_event(MouseDownEvent { + position: from, + button: MouseButton::Left, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }); + cx.run_until_parked(); + cx.simulate_event(MouseMoveEvent { + position: from + point(px(2.), px(0.)), + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::default(), + }); + cx.run_until_parked(); + cx.simulate_event(MouseMoveEvent { + position: to, + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::default(), + }); + cx.run_until_parked(); + cx.simulate_event(MouseUpEvent { + position: to, + button: MouseButton::Left, + modifiers: Modifiers::default(), + click_count: 1, + }); + cx.run_until_parked(); + }; + + let text_start = point( + canvas.origin.x + canvas.size.width * 0.25, + canvas.origin.y + canvas.size.height * 0.10, + ); + let text_end = point( + canvas.origin.x + canvas.size.width * 0.55, + canvas.origin.y + canvas.size.height * 0.10, + ); + drag(cx, text_start, text_end); + + let (highlight_len, selection) = cx.read(|cx| { + ( + image_view + .read(cx) + .pdf_edited_bytes + .as_ref() + .map(|bytes| bytes.len()), + image_view.read(cx).pdf_selection.clone(), + ) + }); + assert!( + highlight_len.is_some_and(|len| len > original_len), + "highlighting over text should commit edited bytes, got {highlight_len:?} vs {original_len}" + ); + assert!( + selection.is_none(), + "the selection should clear after highlighting" + ); + + cx.update(|_window, cx| { + image_view.update(cx, |view, cx| { + view.set_annotation_tool(PdfAnnotationTool::Pen, cx); + }); + }); + cx.run_until_parked(); + + let stroke_start = point( + canvas.origin.x + canvas.size.width * 0.3, + canvas.origin.y + canvas.size.height * 0.4, + ); + let stroke_end = point( + canvas.origin.x + canvas.size.width * 0.6, + canvas.origin.y + canvas.size.height * 0.5, + ); + drag(cx, stroke_start, stroke_end); + + let pen_len = cx.read(|cx| { + image_view + .read(cx) + .pdf_edited_bytes + .as_ref() + .map(|bytes| bytes.len()) + }); + assert!( + pen_len.is_some_and(|len| len > highlight_len.unwrap_or(0)), + "pen stroke should grow the edited document further, got {pen_len:?} vs {highlight_len:?}" + ); + + // A highlight drag that starts on blank space must still commit once it + // reaches the text line. + cx.update(|_window, cx| { + image_view.update(cx, |view, cx| { + view.set_annotation_tool(PdfAnnotationTool::Highlight, cx) + }); + }); + cx.run_until_parked(); + let blank_start = point( + canvas.origin.x + canvas.size.width * 0.5, + canvas.origin.y + canvas.size.height * 0.5, + ); + let text_end = point( + canvas.origin.x + canvas.size.width * 0.5, + canvas.origin.y + canvas.size.height * 0.10, + ); + drag(cx, blank_start, text_end); + + let blank_drag_len = cx.read(|cx| { + image_view + .read(cx) + .pdf_edited_bytes + .as_ref() + .map(|bytes| bytes.len()) + }); + assert!( + blank_drag_len.is_some_and(|len| len > pen_len.unwrap_or(0)), + "a drag starting on blank space should still highlight text, got {blank_drag_len:?} vs {pen_len:?}" + ); + } } mod persistence {