use std::cell::RefCell; use std::rc::Rc; use gpui::{ App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point, px, rems, }; use gpui_native_editor::highlight::Role; use gpui_native_editor::markdown::{ InlineItem, InlineSpan, InlineStyle, ListMarker, MarkdownBlock, MarkdownHeading, MarkdownViewState, TextView, bind_text_view_keys, }; fn items_text(items: &[InlineItem]) -> String { items .iter() .filter_map(|item| match item { InlineItem::Text(span) => Some(span.text.as_ref()), InlineItem::Image { .. } => None, }) .collect::>() .join("") } fn spans_text(spans: &[InlineSpan]) -> String { spans .iter() .map(|span| span.text.as_ref()) .collect::>() .join("") } fn paragraph_of(blocks: &[MarkdownBlock], index: usize) -> (&Vec, usize, bool) { match &blocks[index] { MarkdownBlock::Paragraph { items, indent, quote, } => (items, *indent, *quote), other => panic!("block {index} is {other:?}, not a paragraph"), } } fn item_of(blocks: &[MarkdownBlock], index: usize) -> (ListMarker, String, usize, bool) { match &blocks[index] { MarkdownBlock::ListItem { marker, items, indent, quote, } => (*marker, items_text(items), *indent, *quote), other => panic!("block {index} is {other:?}, not a list item"), } } #[test] fn inline_styles_survive_nesting_and_merge_runs() { let blocks = parse_body("a **bold *both* b** `code` [link](https://example.com) done"); assert_eq!(blocks.len(), 1); let (items, ..) = paragraph_of(&blocks, 0); let styled: Vec<(String, &InlineStyle)> = items .iter() .filter_map(|item| match item { InlineItem::Text(span) => Some((span.text.to_string(), &span.style)), InlineItem::Image { .. } => None, }) .collect(); assert!(styled.contains(&("bold ".to_string(), &InlineStyle::Strong))); assert!( styled.contains(&("both".to_string(), &InlineStyle::StrongEmphasis)), "strong wrapping emphasis combines into one style" ); assert!(styled.contains(&(" b".to_string(), &InlineStyle::Strong))); assert!(styled.contains(&("code".to_string(), &InlineStyle::Code))); assert!(styled.contains(&( "link".to_string(), &InlineStyle::Link { destination: "https://example.com".into() } ))); assert!(styled.contains(&("a ".to_string(), &InlineStyle::Text))); assert!(styled.contains(&(" done".to_string(), &InlineStyle::Text))); } #[test] fn headings_headings_and_thematic_breaks_parse() { let blocks = parse_body("# Title\n\ntext\n\n---\n\n###### small"); assert_eq!(blocks.len(), 4); assert!(matches!( &blocks[0], MarkdownBlock::Heading { level: MarkdownHeading::H1, .. } )); assert!(matches!(&blocks[2], MarkdownBlock::ThematicBreak { .. })); assert!(matches!( &blocks[3], MarkdownBlock::Heading { level: MarkdownHeading::H6, .. } )); } #[test] fn lists_number_order_and_nest() { let source = "- one\n- two\n 1. inner\n 2. more\n3. third\n"; let blocks = parse_body(source); let markers: Vec = blocks .iter() .filter_map(|block| match block { MarkdownBlock::ListItem { marker, .. } => Some(*marker), _ => None, }) .collect(); assert_eq!( markers, vec![ ListMarker::Bullet, ListMarker::Bullet, ListMarker::Ordered(1), ListMarker::Ordered(2), ListMarker::Ordered(3), ], "ordered numbering continues across nested lists" ); let (_, _, outer_indent, _) = item_of(&blocks, 0); assert_eq!(outer_indent, 0); let (_, _, inner_indent, _) = item_of(&blocks, 2); assert_eq!(inner_indent, 1, "nested items indent one level deeper"); } #[test] fn tight_items_stay_tight_but_block_content_flushes_before_them() { let source = "- item text\n\n second paragraph\n- next\n"; let blocks = parse_body(source); // The first paragraph of a loose item carries its marker row; the // continuation flushes as a plain paragraph at the same indent. let (marker, text, indent, _) = item_of(&blocks, 0); assert_eq!(marker, ListMarker::Bullet); assert_eq!(text, "item text"); assert_eq!(indent, 0); let (items, indent, _) = paragraph_of(&blocks, 1); assert_eq!(items_text(items), "second paragraph"); assert_eq!( indent, 1, "loose item continuations indent under the item's text" ); let (marker, text, ..) = item_of(&blocks, 2); assert_eq!(marker, ListMarker::Bullet); assert_eq!(text, "next"); } #[test] fn tight_item_content_after_a_nested_block_stays_in_the_item() { let blocks = parse_body( "- doc ```rust fn x() {} ``` after ", ); assert_eq!( blocks.len(), 3, "marker row, code, and one continuation row" ); let (marker, text, indent, _) = item_of(&blocks, 0); assert_eq!(marker, ListMarker::Bullet); assert_eq!(text, "doc"); assert_eq!(indent, 0); assert!(matches!( &blocks[1], MarkdownBlock::Code { indent: 1, quote: false, .. } )); let (items, indent, _) = paragraph_of(&blocks, 2); assert_eq!(items_text(items), "after"); assert_eq!(indent, 1, "the continuation stays under the item's text"); } #[test] fn task_markers_do_not_leak_into_following_items() { let blocks = parse_body("- [ ] todo\n- plain\n1. [x] done\n2. next\n"); assert_eq!(item_of(&blocks, 0).0, ListMarker::Task(false)); assert_eq!(item_of(&blocks, 1).0, ListMarker::Bullet); assert_eq!(item_of(&blocks, 2).0, ListMarker::Task(true)); assert_eq!(item_of(&blocks, 3).0, ListMarker::Ordered(2)); } #[test] fn quoted_text_after_an_item_flushes_the_item_first() { let blocks = parse_body("- text\n > quoted\n"); assert_eq!(blocks.len(), 2); assert_eq!(item_of(&blocks, 0).1, "text"); let (_, indent, quote) = paragraph_of(&blocks, 1); assert!(quote, "the quoted paragraph carries the quote flag"); assert_eq!(indent, 1, "quote content stays under the item's text"); } #[test] fn a_rule_after_item_text_keeps_its_order() { let blocks = parse_body("- text\n ***\n"); assert_eq!(blocks.len(), 2); assert_eq!(item_of(&blocks, 0).1, "text"); assert!(matches!( &blocks[1], MarkdownBlock::ThematicBreak { indent: 1, quote: false } )); } #[test] fn nested_code_and_tables_carry_indent_and_quote() { let blocks = parse_body("- fenced\n\n ```rust\n fn x() {}\n ```\n\n> | a |\n> |---|\n> | b |\n"); assert_eq!(blocks.len(), 3); let (marker, text, indent, _) = item_of(&blocks, 0); assert_eq!(marker, ListMarker::Bullet); assert_eq!(text, "fenced"); assert_eq!(indent, 0); assert!(matches!( &blocks[1], MarkdownBlock::Code { indent: 1, quote: false, .. } )); assert!(matches!( &blocks[2], MarkdownBlock::Table { indent: 0, quote: true, .. } )); } #[test] fn quotes_mark_their_paragraphs_and_lists() { let blocks = parse_body("> quoted\n>\n> - listed\n\nplain"); let (_, _, quote) = paragraph_of(&blocks, 0); assert!(quote, "quoted paragraphs carry the quote flag"); assert!(item_of(&blocks, 1).3, "quoted list items carry it too"); assert!(!paragraph_of(&blocks, 2).2); } #[cfg(feature = "rust")] #[test] fn fenced_code_highlights_known_languages_and_keeps_the_info_string() { let blocks = parse_body("```rust\nfn main() {}\n```\n"); assert_eq!(blocks.len(), 1); let MarkdownBlock::Code { language, lines, .. } = &blocks[0] else { panic!("the first block is code"); }; assert_eq!(language.as_deref(), Some("rust")); assert!( lines .lines .iter() .flat_map(|line| &line.spans) .any(|span| span.role == Role::Keyword), "rust fences highlight through the grammar" ); } #[test] fn fenced_code_keeps_the_info_string_and_renders_unknown_languages_plain() { let blocks = parse_body("```text\nplain\n```"); assert_eq!(blocks.len(), 1); let MarkdownBlock::Code { language, lines, .. } = &blocks[0] else { panic!("the block is code"); }; assert_eq!(language.as_deref(), Some("text")); assert!( lines .lines .iter() .flat_map(|line| &line.spans) .all(|span| span.role == Role::Text), "unknown languages render plain" ); } #[test] fn tables_parse_header_first_with_column_positions() { let blocks = parse_body("| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n"); let MarkdownBlock::Table { table, .. } = &blocks[0] else { panic!("the block is a table"); }; assert_eq!(table.rows.len(), 3); assert!(table.rows[0].header); assert!(!table.rows[1].header); let header: Vec = table.rows[0] .cells .iter() .map(|cell| spans_text(&cell.spans)) .collect(); assert_eq!(header, vec!["a", "b"]); assert_eq!(spans_text(&table.rows[2].cells[1].spans), "4"); } #[test] fn images_render_inline_and_standalone_and_html_renders_nothing() { // An image alone in its paragraph becomes a standalone image block. let blocks = parse_body("![the logo](logo.png)"); assert_eq!(blocks.len(), 1); let MarkdownBlock::Image { source, alt, .. } = &blocks[0] else { panic!("the block is an image"); }; assert_eq!(source.as_ref(), "logo.png"); assert_eq!(alt.as_ref(), "the logo"); // Two consecutive images in one paragraph become two blocks. let blocks = parse_body("![a](one.png)\n![b](two.png)"); assert_eq!(blocks.len(), 2); assert_eq!( blocks .iter() .filter(|block| matches!(block, MarkdownBlock::Image { .. })) .count(), 2 ); // An image among text renders as an inline atom; the spaces around it // stay in their runs, and its alt text is dropped. let blocks = parse_body("see ![the logo](logo.png) now\n\n
hidden
\n"); assert_eq!(blocks.len(), 1, "the html block renders no block at all"); let (items, ..) = paragraph_of(&blocks, 0); assert_eq!(items_text(items), "see now"); assert_eq!( items .iter() .filter(|item| matches!(item, InlineItem::Image { .. })) .count(), 1 ); // Images inside headings and table cells fall back to their alt text. let blocks = parse_body("# ![alt h](h.png) head\n\n| ![cell](c.png) |\n|---|\n| x |\n"); let MarkdownBlock::Heading { spans, .. } = &blocks[0] else { panic!("the first block is a heading"); }; assert!( spans_text(spans).contains("alt h"), "the heading image renders as its alt text" ); let MarkdownBlock::Table { table, .. } = &blocks[1] else { panic!("the second block is a table"); }; let cell_text: String = table.rows[0].cells[0] .spans .iter() .map(|span| span.text.as_ref()) .collect(); assert_eq!(cell_text, "cell"); } #[test] fn set_source_reparses_and_blocks_reset() { let mut cx = TestAppContext::single(); cx.update(gpui_native_theme::Theme::init); let state = cx.new(|_| MarkdownViewState::new("test-md", "# old\n")); cx.update(|cx| { state.update(cx, |state, cx| state.set_source("- a\n- b\n", cx)); }); let (source, count) = cx.update(|cx| { let state = state.read(cx); (state.source().to_string(), state.blocks().len()) }); assert_eq!(source, "- a\n- b\n"); assert_eq!(count, 2, "the block list reflects the new source"); } struct Host { view: Entity, } impl Render for Host { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div().size_full().child(self.view.clone()) } } fn view_window(source: &str) -> (TestAppContext, VisualTestContext, Entity) { let mut cx = TestAppContext::single(); cx.update(gpui_native_theme::Theme::init); let state = MarkdownViewState::new("test-md", source); let state_cell: Rc>>> = Rc::new(RefCell::new(None)); let cell = state_cell.clone(); let window = cx.add_window(move |_, cx| { let view = cx.new(|_| state); cell.borrow_mut().replace(view.clone()); Host { view } }); let state = state_cell.borrow().clone().expect("state captured"); let mut visual = VisualTestContext::from_window(window.into(), &cx); visual.update(|window, cx| { window.draw(cx).clear(cx); }); (cx, visual, state) } /// The stable id string of the block at `block_index`. Leaks so the id can /// feed `debug_bounds`, which requires `&'static str`. fn block_id(state: &Entity, cx: &App, block_index: usize) -> &'static str { Box::::leak( state .read(cx) .block_id(block_index) .expect("the block has an id") .to_string() .into_boxed_str(), ) } #[test] fn blocks_render_virtualized_and_scroll_to_a_block() { let mut source = String::new(); for paragraph in 0..200 { source.push_str(&format!("paragraph {paragraph} with some body text\n\n")); } let (_cx, mut visual, state) = view_window(&source); let first_id = _cx.update(|cx| block_id(&state, cx, 0)); let target_id = _cx.update(|cx| block_id(&state, cx, 150)); let last_id = _cx.update(|cx| block_id(&state, cx, 199)); assert!( visual.debug_bounds(first_id).is_some(), "the first block renders" ); assert!( visual.debug_bounds(target_id).is_none(), "blocks past the viewport never render" ); state.update_in(&mut visual, |state, _window, _cx| { state.scroll_to_block(150) }); visual.update(|window, cx| { window.draw(cx).clear(cx); }); assert!( visual.debug_bounds(target_id).is_some(), "scrolling brings the target block in" ); assert!( visual.debug_bounds(first_id).is_none(), "the first block scrolled out" ); assert!( visual.debug_bounds(last_id).is_none(), "blocks past the target stay unrendered" ); } #[test] fn clicking_a_link_opens_its_destination() { let (_cx, mut visual, state) = view_window("[click me](https://example.com/welcome)"); let first_id = _cx.update(|cx| block_id(&state, cx, 0)); let block = visual .debug_bounds(first_id) .expect("the paragraph renders"); visual.simulate_event(MouseDownEvent { position: point(block.left() + px(4.), block.center().y), button: MouseButton::Left, ..Default::default() }); visual.simulate_event(MouseUpEvent { position: point(block.left() + px(4.), block.center().y), button: MouseButton::Left, ..Default::default() }); assert_eq!( visual.opened_url().as_deref(), Some("https://example.com/welcome"), "the link's destination opens through the platform" ); } /// Parses without an app context; the view reuses this path. fn parse_body(source: &str) -> Vec { let state = MarkdownViewState::new("parse-md", source); state.blocks().to_vec() } /// Mirrors the gallery's "Large file" sample: five blocks per section. fn large_markdown(sections: usize) -> String { let mut source = String::new(); for section in 0..sections { source.push_str(&format!("## Section {section}\n\n")); source.push_str(&format!( "Handler {section} retries stale sockets and logs every outcome.\n\n" )); source.push_str(&format!( "- [ ] Audit handler {section}\n- See [issue {section}](https://example.com/issues/{section})\n\n" )); source.push_str("```rust\nfn handle() -> bool { true }\n```\n\n"); } source } #[test] fn a_large_document_swaps_and_renders_without_freezing() { let source = large_markdown(1000); let started = std::time::Instant::now(); let (_cx, mut visual, state) = view_window("# small\n"); state.update_in(&mut visual, |state, _window, cx| { state.set_source(source, cx) }); visual.update(|window, cx| { window.draw(cx).clear(cx); }); let elapsed = std::hint::black_box(started.elapsed()); assert!( elapsed < std::time::Duration::from_secs(30), "swapping to a large document took {elapsed:?}; the frame must not measure every block" ); let count = _cx.update(|cx| state.read(cx).blocks().len()); assert_eq!(count, 5000, "five blocks per section"); let first_id = _cx.update(|cx| block_id(&state, cx, 0)); let last_id = _cx.update(|cx| block_id(&state, cx, 4999)); assert!( visual.debug_bounds(first_id).is_some(), "the first block renders after the swap" ); assert!( visual.debug_bounds(last_id).is_none(), "blocks past the viewport never render" ); } #[test] fn block_ids_survive_prepending_and_edits_around_them() { let mut cx = TestAppContext::single(); cx.update(gpui_native_theme::Theme::init); let state = cx.new(|_| MarkdownViewState::new("feed", "first\n\nsecond\n")); let (first, second) = cx.update(|cx| { let state = state.read(cx); ( state.block_id(0).expect("the first block has an id"), state.block_id(1).expect("the second block has an id"), ) }); cx.update(|cx| { state.update(cx, |state, cx| { state.set_source("new\n\nfirst\n\nsecond\n", cx) }); }); cx.update(|cx| { let state = state.read(cx); assert_eq!( state.block_id(1), Some(first.clone()), "prepending a block keeps the id of the old first block" ); assert_eq!( state.block_id(2), Some(second.clone()), "prepending a block keeps the id of the old second block" ); }); // Editing a block's text changes only that block's id. cx.update(|cx| { state.update(cx, |state, cx| { state.set_source("new\n\nfirst edited\n\nsecond\n", cx) }); }); cx.update(|cx| { let state = state.read(cx); assert_eq!( state.block_id(2), Some(second), "the untouched block keeps its id" ); assert_ne!( state.block_id(1), Some(first), "the edited block gets a new id" ); }); } #[test] fn selection_spans_pieces_and_images_contribute_no_text() { let mut cx = TestAppContext::single(); cx.update(gpui_native_theme::Theme::init); let state = cx.new(|_| { MarkdownViewState::new( "sel", "alpha\n\nbravo ![e](u.png) charlie\n\n- delta\n- echo\n", ) }); let selected = cx.update(|cx| { state.update(cx, |view, cx| { view.select_range(0, 30, cx); view.selected_text() }) }); assert_eq!( selected.as_deref(), Some("alpha\nbravo \n charlie\ndelta\nec"), "pieces join with newlines and images contribute no text" ); } #[test] fn selection_is_empty_past_the_document_end_and_clears_on_reparse() { let mut cx = TestAppContext::single(); cx.update(gpui_native_theme::Theme::init); let state = cx.new(|_| MarkdownViewState::new("sel", "only\n")); let (overshoot, after_reparse) = cx.update(|cx| { state.update(cx, |view, cx| { view.select_range(0, 10_000, cx); let overshoot = view.selected_text(); view.set_source("other\n", cx); (overshoot, view.selected_text()) }) }); assert_eq!( overshoot.as_deref(), Some("only"), "ranges clamp to the document" ); assert_eq!(after_reparse, None, "reparsing clears the selection"); } struct TextViewHost { link_handler: Rc>>, } impl Render for TextViewHost { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { let clicks = self.link_handler.clone(); div() .debug_selector(|| "text-view".to_string()) .size_full() .child( TextView::markdown( "text-view", "[a very long link label that fills the line](https://example.com/welcome)", ) .on_link_click(move |url, _window, _cx| { clicks.borrow_mut().push(url.to_string()); }), ) } } #[test] fn text_view_clicks_route_through_the_link_handler() { let mut cx = TestAppContext::single(); cx.update(gpui_native_theme::Theme::init); let clicks: Rc>> = Rc::new(RefCell::new(Vec::new())); let clicks_cell = clicks.clone(); let window = cx.add_window(move |_, _| TextViewHost { link_handler: clicks_cell, }); let mut visual = VisualTestContext::from_window(window.into(), &cx); visual.update(|window, cx| { window.draw(cx).clear(cx); }); let bounds = visual .debug_bounds("text-view") .expect("the text view renders"); let position = point(bounds.left() + px(4.), bounds.top() + px(10.)); visual.simulate_event(MouseDownEvent { position, button: MouseButton::Left, ..Default::default() }); visual.simulate_event(MouseUpEvent { position, button: MouseButton::Left, ..Default::default() }); assert_eq!( clicks.borrow().as_slice(), ["https://example.com/welcome".to_string()], "the click lands on the link and routes through the handler" ); } struct SelectionHost { state: Entity, } impl Render for SelectionHost { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div().child(div().w(rems(30.)).child(TextView::new(&self.state))) } } fn selection_window( source: &str, ) -> (TestAppContext, VisualTestContext, Entity) { let mut cx = TestAppContext::single(); cx.update(gpui_native_theme::Theme::init); cx.update(bind_text_view_keys); let state = MarkdownViewState::new("sel-view", source); let state_cell: Rc>>> = Rc::new(RefCell::new(None)); let cell = state_cell.clone(); let window = cx.add_window(move |_, cx| { let view = cx.new(|_| state); cell.borrow_mut().replace(view.clone()); SelectionHost { state: view } }); let state = state_cell.borrow().clone().expect("state captured"); let mut visual = VisualTestContext::from_window(window.into(), &cx); visual.update(|window, cx| { window.draw(cx).clear(cx); }); (cx, visual, state) } /// Leaks a block id so it can feed `debug_bounds`, which needs `&'static str`. fn leaked_block_id( state: &Entity, cx: &App, block_index: usize, ) -> &'static str { Box::::leak( state .read(cx) .block_id(block_index) .expect("the block has an id") .to_string() .into_boxed_str(), ) } fn click(visual: &mut VisualTestContext, position: gpui::Point) { visual.simulate_event(MouseDownEvent { position, button: MouseButton::Left, ..Default::default() }); visual.simulate_event(MouseUpEvent { position, button: MouseButton::Left, ..Default::default() }); } #[test] fn dragging_across_paragraphs_selects_and_copies() { let (cx, mut visual, state) = selection_window("alpha bravo\n\ncharlie delta\n"); let first = cx.update(|cx| leaked_block_id(&state, cx, 0)); let second = cx.update(|cx| leaked_block_id(&state, cx, 1)); let start = { let bounds = visual.debug_bounds(first).expect("the first block renders"); gpui::point(bounds.left() + px(4.), bounds.top() + px(8.)) }; visual.simulate_event(MouseDownEvent { position: start, button: MouseButton::Left, ..Default::default() }); let end = { let bounds = visual .debug_bounds(second) .expect("the second block renders"); gpui::point(bounds.left() + px(40.), bounds.top() + px(8.)) }; visual.simulate_event(MouseMoveEvent { position: end, ..Default::default() }); visual.simulate_event(MouseUpEvent { position: end, button: MouseButton::Left, ..Default::default() }); let selected = cx.update(|cx| state.read(cx).selected_text()); assert_eq!( selected.as_deref(), Some("alpha bravo\ncharli"), "the drag selects from the press through the pointer" ); visual.dispatch_action(gpui_native_editor::markdown::Copy); let clipboard = cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text())); assert_eq!( clipboard.as_deref(), selected.as_deref(), "the copy action copies the selected text" ); } struct TaskHost { toggles: Rc>>, } impl Render for TaskHost { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { let toggles = self.toggles.clone(); div().size_full().child( TextView::markdown("task-view", "- [ ] first\n- [x] second\n").on_task_toggle( move |block, checked, _window, _cx| { toggles.borrow_mut().push((block, checked)); }, ), ) } } #[test] fn clicking_a_task_checkbox_toggles_it_through_the_handler() { let mut cx = TestAppContext::single(); cx.update(gpui_native_theme::Theme::init); let toggles: Rc>> = Rc::new(RefCell::new(Vec::new())); let toggles_cell = toggles.clone(); let window = cx.add_window(move |_, _| TaskHost { toggles: toggles_cell, }); let mut visual = VisualTestContext::from_window(window.into(), &cx); visual.update(|window, cx| { window.draw(cx).clear(cx); }); let bounds = visual .debug_bounds("task-toggle-0") .expect("the task renders"); click( &mut visual, gpui::point(bounds.center().x, bounds.center().y), ); assert_eq!( toggles.borrow().as_slice(), [(0, true)], "checking the first task reports its block and the new state" ); } struct CodeHost; impl Render for CodeHost { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div().size_full().child(TextView::markdown( "code-host", "```text\nfn main() {}\n```\n", )) } } #[test] fn the_code_block_copy_button_copies_the_fenced_source() { let mut cx = TestAppContext::single(); cx.update(gpui_native_theme::Theme::init); let window = cx.add_window(|_, _| CodeHost); let mut visual = VisualTestContext::from_window(window.into(), &cx); visual.update(|window, cx| { window.draw(cx).clear(cx); }); let bounds = visual .debug_bounds("copy-code") .expect("the copy button renders"); click( &mut visual, gpui::point(bounds.center().x, bounds.center().y), ); let clipboard = cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text())); assert_eq!( clipboard.as_deref(), Some("fn main() {}"), "the copy button copies the fenced source" ); }