//! Editing text and objects from the app (C11.3). use ely_pdf_engine::sample::{Extras, one_page, pdf_with}; use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext}; use super::input::over; use super::{frames, settle, shown, windowed}; use crate::content::{Aim, Tool}; use crate::view::PdfView; use crate::viewer::Viewer; /// Two lines, a stamp, a picture; the edit tool on. fn editing<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path) -> (Entity, &'a mut VisualTestContext, Entity) { let path = dir.join("doc.pdf"); let extras = Extras { stamp: Some("STAMP"), picture: true, ..Extras::default() }; std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world", "Second line"])], &[], &extras)).expect("writes"); let (viewer, cx) = windowed(cx, dir, &[path], true); let view = shown(cx, &viewer); settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0)); // At actual points the whole page is in view. cx.update(|_, app| view.update(app, |view, cx| { view.zoom = crate::layout::Zoom::Scale(1.0); view.set_tool(Tool::Edit, cx) })); settle(cx, |app| view.read(app).layers.contains_key(&0)); (viewer, cx, view) } /// Presses at page point `at`, with no drag. fn click(cx: &mut VisualTestContext, view: &Entity, at: (f32, f32)) { let point = over(cx, view, 0, at); cx.simulate_mouse_down(point, MouseButton::Left, Modifiers::none()); cx.simulate_mouse_up(point, MouseButton::Left, Modifiers::none()); } /// The page's blocks once the layer is read again. fn blocks(cx: &mut VisualTestContext, view: &Entity) -> Vec { settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0)); cx.update(|_, app| view.read(app).layers[&0].blocks.iter().map(|b| b.text.clone()).collect()) } /// Puts `text` in the panel's field. fn type_in(cx: &mut VisualTestContext, viewer: &Entity, text: &str) { frames(cx); cx.update(|_, app| { let field = viewer.read(app).edit_panel.read(app).field.clone().expect("drawn"); field.update(app, |field, cx| field.set_text(text, cx)); }); } #[test] fn a_block_edits_in_its_own_font_without_a_preview() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let (viewer, cx, view) = editing(&mut cx, dir.path()); // Hello world's first line sits 72 points down. click(cx, &view, (80.0, 66.0)); assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Text { .. }))); type_in(cx, &viewer, "Hi world\nSecond line"); cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_text(false, cx))); let now = blocks(cx, &view); assert!(now.iter().any(|b| b.starts_with("Hi world")) && !now.iter().any(|b| b.contains("Hello")), "{now:?}"); assert!(cx.update(|_, app| viewer.read(app).preview.is_none()), "same font, same space: no preview"); } #[test] fn another_font_shows_the_change_first_and_a_missing_glyph_says_so() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let (viewer, cx, view) = editing(&mut cx, dir.path()); click(cx, &view, (80.0, 66.0)); type_in(cx, &viewer, "你好"); cx.update(|_, app| viewer.update(app, |viewer, cx| { viewer.edit_panel.update(cx, |panel, _| panel.font = "helvetica".into()); viewer.write_text(false, cx) })); settle(cx, |app| viewer.read(app).edit_panel.read(app).note.is_some()); assert!(cx.update(|_, app| view.read(app).edited) == 0, "nothing changed"); cx.update(|_, app| viewer.update(app, |viewer, cx| { viewer.edit_panel.update(cx, |panel, _| panel.font = "cjk-sc".into()); viewer.write_text(false, cx) })); settle(cx, |app| viewer.read(app).preview.is_some()); cx.update(|_, app| viewer.update(app, |viewer, cx| { let preview = viewer.preview.take().expect("shown"); viewer.commit_text((preview.tab, preview.version, preview.page), preview.writing, cx) })); assert!(blocks(cx, &view).iter().any(|b| b.contains("你好"))); } #[test] fn an_image_drags_and_scales_from_its_corner() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let (_, cx, view) = editing(&mut cx, dir.path()); let rect = |cx: &mut VisualTestContext| cx.update(|_, app| view.read(app).layers[&0].drawn[1].rect); let (from, to) = (over(cx, &view, 0, (122.0, 267.0)), over(cx, &view, 0, (172.0, 287.0))); cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none()); cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none()); cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none()); settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0)); let moved = rect(cx); assert!((moved[0] - 122.0).abs() < 0.5 && (moved[1] - 262.0).abs() < 0.5, "{moved:?}"); // Edits drop the aim; aim, then pull the corner. click(cx, &view, (150.0, 280.0)); let (corner, out) = (over(cx, &view, 0, (222.0, 312.0)), over(cx, &view, 0, (322.0, 362.0))); cx.simulate_mouse_down(corner, MouseButton::Left, Modifiers::none()); cx.simulate_mouse_move(out, MouseButton::Left, Modifiers::none()); cx.simulate_mouse_up(out, MouseButton::Left, Modifiers::none()); settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0)); let scaled = rect(cx); assert!((scaled[2] - 322.0).abs() < 0.5 && (scaled[3] - 362.0).abs() < 0.5, "{scaled:?}"); } #[test] fn new_text_lands_where_pressed_and_objects_delete() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let (viewer, cx, view) = editing(&mut cx, dir.path()); cx.update(|_, app| view.update(app, |view, cx| view.set_tool(Tool::AddText, cx))); click(cx, &view, (100.0, 150.0)); assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::New { page: 0, .. }))); type_in(cx, &viewer, "Added"); cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_text(false, cx))); settle(cx, |app| view.read(app).edited == 1); cx.update(|_, app| view.update(app, |view, cx| view.set_tool(Tool::Edit, cx))); assert!(blocks(cx, &view).iter().any(|b| b == "Added")); click(cx, &view, (310.0, 370.0)); assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Object { index: 0, .. })), "the stamp"); cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.delete_object(cx))); assert!(!blocks(cx, &view).iter().any(|b| b == "STAMP")); } #[test] fn escape_leaves_the_edit_tool() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let (_, cx, view) = editing(&mut cx, dir.path()); cx.update(|window, app| { let focus = view.read(app).focus.clone(); window.focus(&focus, app) }); cx.simulate_keystrokes("escape"); assert_eq!(cx.update(|_, app| view.read(app).tool), Tool::Read); } #[test] fn a_replaced_image_stays_with_its_own_tab() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let (viewer, cx, a) = editing(&mut cx, dir.path()); click(cx, &a, (120.0, 267.0)); let other = super::file(dir.path(), "b.pdf", &[(612.0, 792.0, &["b"])]); cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.replace_image(cx))); cx.update(|_, app| viewer.update(app, |viewer, cx| ely_office::Opening::open_file(viewer, other.clone(), cx))); settle(cx, |app| viewer.read(app).core.tabs.len() == 2); let b = shown(cx, &viewer); assert_ne!(a, b, "B is in front when the picker answers"); let square = dir.path().join("square.pnm"); std::fs::write(&square, b"P6\n2 2\n255\n\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff").expect("writes"); cx.cx.simulate_path_prompt_response(move |_| Some(vec![square])); settle(cx, |app| a.read(app).edited == 1); assert_eq!(cx.update(|_, app| b.read(app).edited), 0); } #[test] fn a_layer_read_from_before_an_edit_is_read_again() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let (_, cx, view) = editing(&mut cx, dir.path()); // The read starts before the edit lands, returns after. cx.update(|_, app| view.update(app, |view, cx| { view.edit("turning", |worker, doc| worker.rotate_pages(doc, &[0], 90), cx); view.layers.clear(); view.ask_layers(&[0], cx); assert!(view.reading_layer); })); settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0) && !view.read(app).reading_layer); } #[test] fn escape_from_the_text_field_leaves_the_tool() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let (viewer, cx, view) = editing(&mut cx, dir.path()); click(cx, &view, (80.0, 66.0)); frames(cx); cx.update(|window, app| { let field = viewer.read(app).edit_panel.read(app).field.clone().expect("drawn"); let focus = gpui::Focusable::focus_handle(field.read(app), app); window.focus(&focus, app) }); cx.simulate_keystrokes("escape"); assert_eq!(cx.update(|_, app| view.read(app).tool), Tool::Read); } #[test] fn a_preview_goes_once_anything_else_lands() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); // Twin pages: a stale aim would hit the wrong one. let same: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["Hello world", "Second line"]), (612.0, 792.0, &["Hello world", "Second line"])]; let (viewer, cx) = windowed(&mut cx, dir.path(), &[super::file(dir.path(), "twins.pdf", same)], true); let view = shown(cx, &viewer); settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0)); cx.update(|_, app| view.update(app, |view, cx| { view.zoom = crate::layout::Zoom::Scale(1.0); view.set_tool(Tool::Edit, cx) })); settle(cx, |app| view.read(app).layers.contains_key(&0)); click(cx, &view, (80.0, 66.0)); type_in(cx, &viewer, "Hi world\nSecond line"); cx.update(|_, app| viewer.update(app, |viewer, cx| { viewer.edit_panel.update(cx, |panel, _| panel.font = "times".into()); viewer.write_text(false, cx) })); settle(cx, |app| viewer.read(app).preview.is_some()); let stale = cx.update(|_, app| { let preview = viewer.read(app).preview.as_ref().expect("shown"); ((preview.tab, preview.version, preview.page), preview.writing.clone()) }); cx.update(|_, app| view.update(app, |view, cx| view.edit("deleting", |worker, doc| worker.delete_pages(doc, &[0]), cx))); settle(cx, |app| view.read(app).edited == 1 && viewer.read(app).preview.is_none()); cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.commit_text(stale.0, stale.1, cx))); frames(cx); assert_eq!(cx.update(|_, app| view.read(app).edited), 1, "the old preview writes nothing"); } #[test] fn nothing_aimed_before_a_queued_edit_is_done() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let (viewer, cx, view) = editing(&mut cx, dir.path()); let version = cx.update(|_, app| view.read(app).version); // An edit is queued; old numbers may not hold. cx.update(|_, app| view.update(app, |view, _| view.editing = true)); let ran = std::rc::Rc::new(std::cell::Cell::new(false)); cx.update(|_, app| viewer.update(app, |viewer, cx| { let ran = ran.clone(); viewer.on_same(&view, version, cx, move |_, _| ran.set(true)) })); assert!(!ran.get()); } #[test] fn a_second_delete_before_the_first_lands_does_nothing() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let (viewer, cx, view) = editing(&mut cx, dir.path()); click(cx, &view, (310.0, 370.0)); cx.update(|_, app| viewer.update(app, |viewer, cx| { viewer.delete_object(cx); viewer.delete_object(cx); })); settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0)); assert_eq!(cx.update(|_, app| view.read(app).layers[&0].drawn.len()), 1, "the picture stays"); } #[test] fn a_drag_while_an_edit_is_queued_does_nothing() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let (_, cx, view) = editing(&mut cx, dir.path()); let (from, to) = (over(cx, &view, 0, (122.0, 267.0)), over(cx, &view, 0, (172.0, 287.0))); cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none()); cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none()); cx.update(|_, app| view.update(app, |view, _| view.editing = true)); cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none()); assert!(cx.update(|_, app| view.read(app).queue.is_empty()), "nothing queued with old numbers"); } #[test] fn the_panel_waits_for_the_drag_so_the_page_stays_put() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let (_, cx, view) = editing(&mut cx, dir.path()); let wide = cx.update(|_, app| view.read(app).room.size.width); let (from, to) = (over(cx, &view, 0, (122.0, 267.0)), over(cx, &view, 0, (140.0, 267.0))); cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none()); cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none()); frames(cx); assert_eq!(cx.update(|_, app| view.read(app).room.size.width), wide, "no panel mid-drag"); cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none()); settle(cx, |app| view.read(app).settled()); frames(cx); cx.update(|_, app| view.update(app, |view, cx| view.press(from, cx))); cx.update(|_, app| view.update(app, |view, cx| view.release(cx))); frames(cx); assert!(cx.update(|_, app| view.read(app).room.size.width) < wide, "the panel opens after"); } #[test] fn an_open_panel_stays_open_through_a_second_drag() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let (_, cx, view) = editing(&mut cx, dir.path()); let wide = cx.update(|_, app| view.read(app).room.size.width); click(cx, &view, (122.0, 267.0)); // The panel slides open; wait until the view stops narrowing. let mut narrow = wide; for _ in 0..40 { cx.executor().advance_clock(std::time::Duration::from_millis(50)); frames(cx); let now = cx.update(|_, app| view.read(app).room.size.width); if now < wide && now == narrow { break; } narrow = now; } assert!(narrow < wide, "the panel opened"); // Pull the picture's corner 50 points right. let (from, to) = (over(cx, &view, 0, (172.0, 292.0)), over(cx, &view, 0, (222.0, 292.0))); cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none()); cx.executor().advance_clock(std::time::Duration::from_millis(500)); frames(cx); cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none()); cx.executor().advance_clock(std::time::Duration::from_millis(500)); frames(cx); assert_eq!(cx.update(|_, app| view.read(app).room.size.width), narrow, "the panel stays mid-drag"); cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none()); settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0)); let rect = cx.update(|_, app| view.read(app).layers[&0].drawn[1].rect); assert!((rect[2] - 222.0).abs() < 1.0 && (rect[3] - 317.0).abs() < 1.0, "{rect:?}"); } #[test] fn a_drag_follows_the_hand_when_the_page_shifts_under_it() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let (_, cx, view) = editing(&mut cx, dir.path()); let from = over(cx, &view, 0, (122.0, 267.0)); cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none()); // The view shifts right, as a panel pushes it. cx.update(|_, app| view.update(app, |view, _| view.room.origin.x += gpui::px(100.0))); let to = gpui::point(from.x + gpui::px(50.0), from.y); cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none()); cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none()); settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0)); let rect = cx.update(|_, app| view.read(app).layers[&0].drawn[1].rect); assert!((rect[0] - 122.0).abs() < 0.5, "moved as far as the hand: {rect:?}"); } #[test] fn text_on_a_full_page_picture_aims_at_the_text_and_a_shake_stays_a_click() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let path = dir.path().join("scan.pdf"); let extras = Extras { picture: true, backdrop: true, ..Extras::default() }; std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world"])], &[], &extras)).expect("writes"); let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); let view = shown(cx, &viewer); settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0)); cx.update(|_, app| view.update(app, |view, cx| { view.zoom = crate::layout::Zoom::Scale(1.0); view.set_tool(Tool::Edit, cx) })); settle(cx, |app| view.read(app).layers.contains_key(&0)); click(cx, &view, (80.0, 66.0)); assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Text { .. })), "the text, not the picture under it"); // A two-pixel wobble on the picture moves nothing. let from = over(cx, &view, 0, (300.0, 400.0)); cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none()); let to = from + gpui::point(gpui::px(2.0), gpui::px(0.0)); cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none()); cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none()); frames(cx); assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Object { .. })), "the picture is aimed"); assert_eq!(cx.update(|_, app| view.read(app).edited), 0, "and not moved"); } #[test] fn a_small_picture_on_text_under_a_page_form_still_aims() { let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let path = dir.path().join("layered.pdf"); // The page-size form comes last, on top of both. let content = "q 10 0 0 10 80 722 cm /Im1 Do Q BT /F1 18 Tf 72 720 Td (Hello world) Tj ET q /Fm1 Do Q"; let form = "0 0 1 rg 500 50 20 20 re f"; let objects = [ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), format!("<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Length {} >>\nstream\n{form}\nendstream", form.len()), "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream".to_string(), ]; let page = " /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R /Im1 7 0 R >> >>"; std::fs::write(&path, one_page("", page, content, &objects)).expect("writes"); let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); let view = shown(cx, &viewer); settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0)); cx.update(|_, app| view.update(app, |view, cx| { view.zoom = crate::layout::Zoom::Scale(1.0); view.set_tool(Tool::Edit, cx) })); settle(cx, |app| view.read(app).layers.contains_key(&0)); // The picture sits at 80..90 across, 60..70 down. click(cx, &view, (85.0, 65.0)); assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Object { index: 0, .. })), "the small picture"); click(cx, &view, (140.0, 66.0)); assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Text { .. })), "the text, not the form around it"); } #[test] fn a_preview_overtaken_by_an_edit_says_so() { use ely_gpui_component::primitives::Severity; let dir = tempfile::tempdir().expect("a temp dir"); let mut cx = TestAppContext::single(); let (viewer, cx, view) = editing(&mut cx, dir.path()); // The window's toasts live on its root. let root = cx.update(|window, app| { let root = ely_ui::document_root(ely_platform::AppId::Pdf, viewer.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app); viewer.update(app, |viewer, _| viewer.core.root = root.downgrade()); root }); click(cx, &view, (80.0, 66.0)); type_in(cx, &viewer, "Hi world\nSecond line"); cx.update(|_, app| viewer.update(app, |viewer, cx| { // A new font asks for a preview first. viewer.edit_panel.update(cx, |panel, _| panel.font = "times".into()); viewer.write_text(false, cx); // An edit lands before the preview answers. if let Some(view) = viewer.active_view() { view.update(cx, |view, _| view.version += 1); } })); let changed = cx.update(|_, app| Viewer::text(app, "pdf.object.changed", &[])); settle(cx, |app| root.read(app).toasts(app).iter().any(|(text, severity)| *severity == Some(Severity::Warning) && *text == changed)); frames(cx); assert!(cx.update(|_, app| viewer.read(app).preview.is_none()), "no stale preview"); assert_eq!(cx.update(|_, app| view.read(app).edited), 0, "nothing written"); }