1use std::sync::Arc;
2
3use rgpui::prelude::FluentBuilder as _;
4use rgpui::{
5 AnyElement, App, Bounds, Element, ElementId, Entity, GlobalElementId, Hitbox, HitboxBehavior,
6 InspectorElementId, InteractiveElement, IntoElement, LayoutId, ParentElement, Pixels,
7 SharedString, StyleRefinement, Styled, Window, div,
8};
9
10use crate::StyledExt;
11use crate::scroll::ScrollableElement;
12use crate::text::TextViewFormat;
13use crate::text::node::CodeBlock;
14use crate::text::state::TextViewState;
15use crate::{global_state::GlobalState, text::TextViewStyle};
16
17pub(crate) type CodeBlockActionsFn =
19 dyn Fn(&CodeBlock, &mut Window, &mut App) -> AnyElement + Send + Sync;
20
21#[derive(Clone)]
38pub struct TextView {
39 id: ElementId,
40 format: Option<TextViewFormat>,
41 text: Option<SharedString>,
42 pub(crate) state: Option<Entity<TextViewState>>,
43 text_view_style: TextViewStyle,
44 style: StyleRefinement,
45 selectable: bool,
46 scrollable: bool,
47 code_block_actions: Option<Arc<CodeBlockActionsFn>>,
48}
49
50impl Styled for TextView {
51 fn style(&mut self) -> &mut StyleRefinement {
52 &mut self.style
53 }
54}
55
56impl TextView {
57 pub fn new(state: &Entity<TextViewState>) -> Self {
59 Self {
60 id: ElementId::Name(state.entity_id().to_string().into()),
61 state: Some(state.clone()),
62 format: None,
63 text: None,
64 text_view_style: TextViewStyle::default(),
65 style: StyleRefinement::default(),
66 selectable: false,
67 scrollable: false,
68 code_block_actions: None,
69 }
70 }
71
72 pub fn markdown(id: impl Into<ElementId>, markdown: impl Into<SharedString>) -> Self {
74 Self {
75 id: id.into(),
76 format: Some(TextViewFormat::Markdown),
77 text: Some(markdown.into()),
78 text_view_style: TextViewStyle::default(),
79 style: StyleRefinement::default(),
80 state: None,
81 selectable: false,
82 scrollable: false,
83 code_block_actions: None,
84 }
85 }
86
87 pub fn html(id: impl Into<ElementId>, html: impl Into<SharedString>) -> Self {
89 Self {
90 id: id.into(),
91 format: Some(TextViewFormat::Html),
92 text: Some(html.into()),
93 text_view_style: TextViewStyle::default(),
94 style: StyleRefinement::default(),
95 state: None,
96 selectable: false,
97 scrollable: false,
98 code_block_actions: None,
99 }
100 }
101
102 pub fn plain(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
104 Self {
105 id: id.into(),
106 format: Some(TextViewFormat::Plain),
107 text: Some(text.into()),
108 text_view_style: TextViewStyle::default(),
109 style: StyleRefinement::default(),
110 state: None,
111 selectable: false,
112 scrollable: false,
113 code_block_actions: None,
114 }
115 }
116
117 pub fn style(mut self, style: TextViewStyle) -> Self {
119 self.text_view_style = style;
120 self
121 }
122
123 pub fn selectable(mut self, selectable: bool) -> Self {
125 self.selectable = selectable;
126 self
127 }
128
129 pub fn scrollable(mut self, scrollable: bool) -> Self {
142 self.scrollable = scrollable;
143 self
144 }
145
146 pub fn code_block_actions<F, E>(mut self, f: F) -> Self
151 where
152 F: Fn(&CodeBlock, &mut Window, &mut App) -> E + Send + Sync + 'static,
153 E: IntoElement,
154 {
155 self.code_block_actions = Some(Arc::new(move |code_block, window, cx| {
156 f(&code_block, window, cx).into_any_element()
157 }));
158 self
159 }
160}
161
162impl IntoElement for TextView {
163 type Element = Self;
164
165 fn into_element(self) -> Self::Element {
166 self
167 }
168}
169
170pub struct TextViewLayoutState {
171 state: Entity<TextViewState>,
172 element: AnyElement,
173}
174
175impl Element for TextView {
176 type RequestLayoutState = TextViewLayoutState;
177 type PrepaintState = Hitbox;
178
179 fn id(&self) -> Option<ElementId> {
180 Some(self.id.clone())
181 }
182
183 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
184 None
185 }
186
187 fn request_layout(
188 &mut self,
189 _: Option<&GlobalElementId>,
190 _: Option<&InspectorElementId>,
191 window: &mut Window,
192 cx: &mut App,
193 ) -> (LayoutId, Self::RequestLayoutState) {
194 let state = if let Some(state) = self.state.clone() {
195 state
196 } else {
197 let default_format = self.format.unwrap_or(TextViewFormat::Markdown);
198 let default_text = self.text.clone().unwrap_or_default();
199
200 let state = window.use_keyed_state(
201 SharedString::from(format!("{}/state", self.id)),
202 cx,
203 move |_, cx| match default_format {
204 TextViewFormat::Markdown => TextViewState::markdown(default_text.as_str(), cx),
205 TextViewFormat::Html => TextViewState::html(default_text.as_str(), cx),
206 TextViewFormat::Plain => TextViewState::plain(default_text.as_str(), cx),
207 },
208 );
209 self.state = Some(state.clone());
210 state
211 };
212
213 state.update(cx, |state, cx| {
214 state.code_block_actions = self.code_block_actions.clone();
215 state.selectable = self.selectable;
216 state.scrollable = self.scrollable;
217 state.text_view_style = self.text_view_style.clone();
218
219 if let Some(text) = self.text.clone() {
220 state.set_text(text.as_str(), cx);
221 }
222 });
223
224 let focus_handle = state.read(cx).focus_handle.clone();
225 let list_state = state.read(cx).list_state.clone();
226
227 let mut el = div()
228 .key_context("TextView")
229 .track_focus(&focus_handle)
230 .when(self.scrollable, |this| {
231 this.size_full().vertical_scrollbar(&list_state)
232 })
233 .relative()
234 .on_action(move |_: &crate::input::Copy, window, cx| {
235 use crate::WindowExt as _;
236 let text = window.selected_text(cx).trim().to_string();
237 if text.is_empty() {
238 cx.propagate();
239 return;
240 }
241 cx.write_to_clipboard(rgpui::ClipboardItem::new_string(text));
242 })
243 .on_action(window.listener_for(&state, TextViewState::on_action_select_all))
244 .child(state.clone())
245 .refine_style(&self.style)
246 .into_any_element();
247 let layout_id = el.request_layout(window, cx);
248 (layout_id, TextViewLayoutState { state, element: el })
249 }
250
251 fn prepaint(
252 &mut self,
253 _: Option<&GlobalElementId>,
254 _: Option<&InspectorElementId>,
255 bounds: Bounds<Pixels>,
256 request_layout: &mut Self::RequestLayoutState,
257 window: &mut Window,
258 cx: &mut App,
259 ) -> Self::PrepaintState {
260 request_layout.element.prepaint(window, cx);
261 window.insert_hitbox(bounds, HitboxBehavior::Normal)
262 }
263
264 fn paint(
265 &mut self,
266 _: Option<&GlobalElementId>,
267 _: Option<&InspectorElementId>,
268 _bounds: Bounds<Pixels>,
269 request_layout: &mut Self::RequestLayoutState,
270 hitbox: &mut Self::PrepaintState,
271 window: &mut Window,
272 cx: &mut App,
273 ) {
274 let state = &request_layout.state;
275 GlobalState::global_mut(cx)
276 .text_view_state_stack
277 .push(state.clone());
278 request_layout.element.paint(window, cx);
279 GlobalState::global_mut(cx).text_view_state_stack.pop();
280
281 if self.selectable {
282 crate::Root::register_selectable_text_view(state, hitbox, window, cx);
286 }
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::TextView;
293 use crate::text::TextViewState;
294 use rgpui::{
295 AppContext as _, Context, Entity, IntoElement, Modifiers, MouseButton, MouseDownEvent,
296 MouseUpEvent, ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext,
297 Window, div, point, px,
298 };
299
300 struct TextViewTestRoot {
301 text_view: Entity<TextViewState>,
302 }
303
304 impl TextViewTestRoot {
305 fn new(text: &str, cx: &mut Context<Self>) -> Self {
306 let text = text.to_string();
307 let text_view = cx.new(|cx| TextViewState::markdown(&text, cx));
308 Self { text_view }
309 }
310 }
311
312 impl Render for TextViewTestRoot {
313 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
314 div()
315 .w(px(160.))
316 .child(
317 div()
318 .h(px(24.))
319 .overflow_hidden()
320 .child(TextView::new(&self.text_view).selectable(true)),
321 )
322 .child(div().h(px(40.)).child("footer"))
323 }
324 }
325
326 #[rgpui::test]
327 fn clipped_markdown_link_does_not_open(cx: &mut TestAppContext) {
328 cx.update(crate::init);
329 let (_, cx) = cx.add_window_view(|_, cx| {
330 TextViewTestRoot::new("visible\n\n[hidden](https://example.com)", cx)
331 });
332 let cx: &mut VisualTestContext = cx;
333
334 cx.simulate_click(point(px(10.), px(34.)), Modifiers::default());
335
336 assert_eq!(cx.opened_url(), None);
337 }
338
339 #[rgpui::test]
340 fn clipped_markdown_cannot_start_selection(cx: &mut TestAppContext) {
341 cx.update(crate::init);
342 let (view, cx) = cx
343 .add_window_view(|_, cx| TextViewTestRoot::new("visible\n\nhidden selection text", cx));
344 let cx: &mut VisualTestContext = cx;
345
346 cx.simulate_mouse_down(
347 point(px(10.), px(34.)),
348 MouseButton::Left,
349 Modifiers::default(),
350 );
351 cx.simulate_mouse_move(
352 point(px(90.), px(34.)),
353 Some(MouseButton::Left),
354 Modifiers::default(),
355 );
356 cx.simulate_mouse_up(
357 point(px(90.), px(34.)),
358 MouseButton::Left,
359 Modifiers::default(),
360 );
361
362 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
363 assert!(
364 selected_text.is_empty(),
365 "unexpected selection: {selected_text:?}"
366 );
367 }
368
369 #[rgpui::test]
370 fn double_click_selects_word(cx: &mut TestAppContext) {
371 cx.update(crate::init);
372 let (view, cx) =
373 cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
374
375 let cx: &mut VisualTestContext = cx;
376 cx.run_until_parked();
377 cx.update(|window, cx| {
378 let _ = window.draw(cx);
379 });
380 let position = point(px(10.), px(16.));
381 cx.simulate_event(MouseDownEvent {
382 position,
383 modifiers: Modifiers::default(),
384 button: MouseButton::Left,
385 click_count: 2,
386 first_mouse: false,
387 });
388 cx.simulate_event(MouseUpEvent {
389 position,
390 modifiers: Modifiers::default(),
391 button: MouseButton::Left,
392 click_count: 2,
393 });
394 cx.update(|window, cx| {
395 let _ = window.draw(cx);
396 });
397
398 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
399 assert_eq!(selected_text.trim(), "quick");
400 }
401
402 #[rgpui::test]
403 fn triple_click_selects_paragraph(cx: &mut TestAppContext) {
404 cx.update(crate::init);
405 let (view, cx) =
406 cx.add_window_view(|_, cx| TextViewTestRoot::new("quick select value", cx));
407
408 let cx: &mut VisualTestContext = cx;
409 cx.run_until_parked();
410 cx.update(|window, cx| {
411 let _ = window.draw(cx);
412 });
413
414 let position = point(px(10.), px(10.));
415 cx.simulate_event(MouseDownEvent {
416 position,
417 modifiers: Modifiers::default(),
418 button: MouseButton::Left,
419 click_count: 3,
420 first_mouse: false,
421 });
422 cx.simulate_event(MouseUpEvent {
423 position,
424 modifiers: Modifiers::default(),
425 button: MouseButton::Left,
426 click_count: 3,
427 });
428 cx.update(|window, cx| {
429 let _ = window.draw(cx);
430 });
431
432 let selected_text = view.read_with(cx, |root, cx| root.text_view.read(cx).selected_text());
433 assert_eq!(selected_text.trim(), "quick select value");
434 }
435}