1use std::future::Future;
2use std::pin::Pin;
3use std::sync::Arc;
4use std::task::Poll;
5use std::time::Duration;
6
7use gpui::prelude::FluentBuilder;
8use gpui::{
9 AnyElement, App, AppContext, Bounds, ClipboardItem, Context, Element, ElementId, Entity,
10 EntityId, FocusHandle, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement,
11 KeyBinding, LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement,
12 Pixels, Point, RenderOnce, SharedString, Size, StyleRefinement, Styled, Timer, Window, div, px,
13};
14use smol::stream::StreamExt;
15
16use crate::highlighter::HighlightTheme;
17use crate::scroll::Scrollbar;
18use crate::{ActiveTheme, StyledExt, v_flex};
19use crate::{
20 global_state::GlobalState,
21 input::{self},
22 text::{
23 TextViewStyle,
24 node::{self, NodeContext},
25 },
26};
27
28const CONTEXT: &'static str = "TextView";
29
30pub(crate) fn init(cx: &mut App) {
31 cx.bind_keys(vec![
32 #[cfg(target_os = "macos")]
33 KeyBinding::new("cmd-c", input::Copy, Some(CONTEXT)),
34 #[cfg(not(target_os = "macos"))]
35 KeyBinding::new("ctrl-c", input::Copy, Some(CONTEXT)),
36 ]);
37}
38
39#[derive(IntoElement, Clone)]
40struct TextViewElement {
41 list_state: Option<ListState>,
42 state: Entity<TextViewState>,
43}
44
45impl RenderOnce for TextViewElement {
46 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
47 self.state.update(cx, |state, cx| {
48 v_flex()
49 .size_full()
50 .map(|this| match &mut state.parsed_result {
51 Some(Ok(content)) => this.child(content.root_node.render_root(
52 self.list_state.clone(),
53 &content.node_cx,
54 window,
55 cx,
56 )),
57 Some(Err(err)) => this.child(
58 v_flex()
59 .gap_1()
60 .child("Failed to parse content")
61 .child(err.to_string()),
62 ),
63 None => this,
64 })
65 })
66 }
67}
68
69#[derive(Clone)]
86pub struct TextView {
87 id: ElementId,
88 init_state: Option<InitState>,
89 raw: SharedString,
90 state: Entity<TextViewState>,
91 style: StyleRefinement,
92 selectable: bool,
93 scrollable: bool,
94}
95
96#[derive(PartialEq)]
97pub(crate) struct ParsedContent {
98 pub(crate) root_node: node::Node,
99 pub(crate) node_cx: node::NodeContext,
100}
101
102#[derive(Clone, Copy, PartialEq, Eq)]
104enum TextViewType {
105 Markdown,
107 Html,
109}
110
111enum Update {
112 Text(SharedString),
113 Style(Box<TextViewStyle>),
114}
115
116struct UpdateFuture {
117 type_: TextViewType,
118 highlight_theme: Arc<HighlightTheme>,
119 current_style: TextViewStyle,
120 current_text: SharedString,
121 timer: Timer,
122 rx: Pin<Box<smol::channel::Receiver<Update>>>,
123 tx_result: smol::channel::Sender<Result<ParsedContent, SharedString>>,
124 delay: Duration,
125}
126
127impl UpdateFuture {
128 fn new(
129 type_: TextViewType,
130 style: TextViewStyle,
131 text: SharedString,
132 highlight_theme: Arc<HighlightTheme>,
133 rx: smol::channel::Receiver<Update>,
134 tx_result: smol::channel::Sender<Result<ParsedContent, SharedString>>,
135 delay: Duration,
136 ) -> Self {
137 Self {
138 type_,
139 highlight_theme,
140 current_style: style,
141 current_text: text,
142 timer: Timer::never(),
143 rx: Box::pin(rx),
144 tx_result,
145 delay,
146 }
147 }
148}
149
150impl Future for UpdateFuture {
151 type Output = ();
152
153 fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
154 loop {
155 match self.rx.poll_next(cx) {
156 Poll::Ready(Some(update)) => {
157 let changed = match update {
158 Update::Text(text) if self.current_text != text => {
159 self.current_text = text;
160 true
161 }
162 Update::Style(style) if self.current_style != *style => {
163 self.current_style = *style;
164 true
165 }
166 _ => false,
167 };
168 if changed {
169 let delay = self.delay;
170 self.timer.set_after(delay);
171 }
172 continue;
173 }
174 Poll::Ready(None) => return Poll::Ready(()),
175 Poll::Pending => {}
176 }
177
178 match self.timer.poll_next(cx) {
179 Poll::Ready(Some(_)) => {
180 let res = parse_content(
181 self.type_,
182 &self.current_text,
183 self.current_style.clone(),
184 &self.highlight_theme,
185 );
186 _ = self.tx_result.try_send(res);
187 continue;
188 }
189 Poll::Ready(None) | Poll::Pending => return Poll::Pending,
190 }
191 }
192 }
193}
194
195#[derive(Clone)]
196enum InitState {
197 Initializing {
198 type_: TextViewType,
199 text: SharedString,
200 style: Box<TextViewStyle>,
201 highlight_theme: Arc<HighlightTheme>,
202 },
203 Initialized {
204 tx: smol::channel::Sender<Update>,
205 },
206}
207
208pub(crate) struct TextViewState {
209 parent_entity: Option<EntityId>,
210 tx: Option<smol::channel::Sender<Update>>,
211 parsed_result: Option<Result<ParsedContent, SharedString>>,
212 focus_handle: Option<FocusHandle>,
213 bounds: Bounds<Pixels>,
215 selection_positions: (Option<Point<Pixels>>, Option<Point<Pixels>>),
217 is_selecting: bool,
219 is_selectable: bool,
220 list_state: ListState,
221}
222
223impl TextViewState {
224 fn new(cx: &mut Context<TextViewState>) -> Self {
225 let focus_handle = cx.focus_handle();
226 Self {
227 parent_entity: None,
228 tx: None,
229 parsed_result: None,
230 focus_handle: Some(focus_handle),
231 bounds: Bounds::default(),
232 selection_positions: (None, None),
233 is_selecting: false,
234 is_selectable: false,
235 list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
236 }
237 }
238}
239
240impl TextViewState {
241 fn update_bounds(&mut self, bounds: Bounds<Pixels>) {
243 if self.bounds.size != bounds.size {
244 self.clear_selection();
245 }
246 self.bounds = bounds;
247 }
248
249 fn clear_selection(&mut self) {
250 self.selection_positions = (None, None);
251 self.is_selecting = false;
252 }
253
254 fn start_selection(&mut self, pos: Point<Pixels>) {
255 let pos = pos - self.bounds.origin;
256 self.selection_positions = (Some(pos), Some(pos));
257 self.is_selecting = true;
258 }
259
260 fn update_selection(&mut self, pos: Point<Pixels>) {
261 let pos = pos - self.bounds.origin;
262 if let (Some(start), Some(_)) = self.selection_positions {
263 self.selection_positions = (Some(start), Some(pos))
264 }
265 }
266
267 fn end_selection(&mut self) {
268 self.is_selecting = false;
269 }
270
271 pub(crate) fn has_selection(&self) -> bool {
272 if let (Some(start), Some(end)) = self.selection_positions {
273 start != end
274 } else {
275 false
276 }
277 }
278
279 pub(crate) fn is_selectable(&self) -> bool {
280 self.is_selectable
281 }
282
283 pub(crate) fn selection_bounds(&self) -> Bounds<Pixels> {
285 selection_bounds(
286 self.selection_positions.0,
287 self.selection_positions.1,
288 self.bounds,
289 )
290 }
291
292 fn selection_text(&self) -> Option<String> {
293 Some(
294 self.parsed_result
295 .as_ref()?
296 .as_ref()
297 .ok()?
298 .root_node
299 .selected_text(),
300 )
301 }
302}
303
304#[derive(IntoElement, Clone)]
305pub enum Text {
306 String(SharedString),
307 TextView(Box<TextView>),
308}
309
310impl From<SharedString> for Text {
311 fn from(s: SharedString) -> Self {
312 Self::String(s)
313 }
314}
315
316impl From<&str> for Text {
317 fn from(s: &str) -> Self {
318 Self::String(SharedString::from(s.to_string()))
319 }
320}
321
322impl From<String> for Text {
323 fn from(s: String) -> Self {
324 Self::String(s.into())
325 }
326}
327
328impl From<TextView> for Text {
329 fn from(e: TextView) -> Self {
330 Self::TextView(Box::new(e))
331 }
332}
333
334impl Text {
335 pub fn style(self, style: TextViewStyle) -> Self {
339 match self {
340 Self::String(s) => Self::String(s),
341 Self::TextView(e) => Self::TextView(Box::new(e.style(style))),
342 }
343 }
344
345 pub fn as_str(&self) -> &str {
347 match self {
348 Self::String(s) => s.as_str(),
349 Self::TextView(view) => view.raw.as_str(),
350 }
351 }
352}
353
354impl RenderOnce for Text {
355 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
356 match self {
357 Self::String(s) => s.into_any_element(),
358 Self::TextView(e) => e.into_any_element(),
359 }
360 }
361}
362
363impl Styled for TextView {
364 fn style(&mut self) -> &mut StyleRefinement {
365 &mut self.style
366 }
367}
368
369impl TextView {
370 fn create_init_state(
371 type_: TextViewType,
372 text: &SharedString,
373 highlight_theme: &Arc<HighlightTheme>,
374 state: &Entity<TextViewState>,
375 cx: &mut App,
376 ) -> InitState {
377 let state = state.read(cx);
378 if let Some(tx) = &state.tx {
379 InitState::Initialized { tx: tx.clone() }
380 } else {
381 InitState::Initializing {
382 type_,
383 text: text.clone(),
384 style: Default::default(),
385 highlight_theme: highlight_theme.clone(),
386 }
387 }
388 }
389
390 pub fn markdown(
392 id: impl Into<ElementId>,
393 markdown: impl Into<SharedString>,
394 window: &mut Window,
395 cx: &mut App,
396 ) -> Self {
397 let id: ElementId = id.into();
398 let markdown = markdown.into();
399 let highlight_theme = cx.theme().highlight_theme.clone();
400 let state =
401 window.use_keyed_state(SharedString::from(format!("{}/state", id)), cx, |_, cx| {
402 TextViewState::new(cx)
403 });
404 let init_state = Self::create_init_state(
405 TextViewType::Markdown,
406 &markdown,
407 &highlight_theme,
408 &state,
409 cx,
410 );
411 if let Some(tx) = &state.read(cx).tx {
412 let _ = tx.try_send(Update::Text(markdown.clone()));
413 }
414 Self {
415 id,
416 init_state: Some(init_state),
417 raw: markdown.clone(),
418 style: StyleRefinement::default(),
419 state,
420 selectable: false,
421 scrollable: false,
422 }
423 }
424
425 pub fn html(
427 id: impl Into<ElementId>,
428 html: impl Into<SharedString>,
429 window: &mut Window,
430 cx: &mut App,
431 ) -> Self {
432 let id: ElementId = id.into();
433 let html = html.into();
434 let highlight_theme = cx.theme().highlight_theme.clone();
435 let state =
436 window.use_keyed_state(SharedString::from(format!("{}/state", id)), cx, |_, cx| {
437 TextViewState::new(cx)
438 });
439 let init_state =
440 Self::create_init_state(TextViewType::Html, &html, &highlight_theme, &state, cx);
441 if let Some(tx) = &state.read(cx).tx {
442 let _ = tx.try_send(Update::Text(html.clone()));
443 }
444 Self {
445 id,
446 init_state: Some(init_state),
447 style: StyleRefinement::default(),
448 state,
449 raw: html,
450 selectable: false,
451 scrollable: false,
452 }
453 }
454
455 pub fn text(mut self, raw: impl Into<SharedString>) -> Self {
457 let raw: SharedString = raw.into();
458 if let Some(init_state) = &mut self.init_state {
459 match init_state {
460 InitState::Initializing { text, .. } => *text = raw.clone(),
461 InitState::Initialized { tx } => {
462 let _ = tx.try_send(Update::Text(raw.clone()));
463 }
464 }
465 }
466 self.raw = raw;
467 self
468 }
469
470 pub fn style(mut self, style: TextViewStyle) -> Self {
472 if let Some(init_state) = &mut self.init_state {
473 match init_state {
474 InitState::Initializing { style: s, .. } => *s = Box::new(style),
475 InitState::Initialized { tx } => {
476 let _ = tx.try_send(Update::Style(Box::new(style)));
477 }
478 }
479 }
480 self
481 }
482
483 pub fn selectable(mut self, selectable: bool) -> Self {
485 self.selectable = selectable;
486 self
487 }
488
489 pub fn scrollable(mut self, scrollable: bool) -> Self {
502 self.scrollable = scrollable;
503 self
504 }
505
506 fn on_action_copy(state: &Entity<TextViewState>, cx: &mut App) {
507 let Some(selected_text) = state.read(cx).selection_text() else {
508 return;
509 };
510
511 cx.write_to_clipboard(ClipboardItem::new_string(selected_text.trim().to_string()));
512 }
513}
514
515impl IntoElement for TextView {
516 type Element = Self;
517
518 fn into_element(self) -> Self::Element {
519 self
520 }
521}
522
523impl Element for TextView {
524 type RequestLayoutState = AnyElement;
525 type PrepaintState = ();
526
527 fn id(&self) -> Option<ElementId> {
528 Some(self.id.clone())
529 }
530
531 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
532 None
533 }
534
535 fn request_layout(
536 &mut self,
537 _: Option<&GlobalElementId>,
538 _: Option<&InspectorElementId>,
539 window: &mut Window,
540 cx: &mut App,
541 ) -> (LayoutId, Self::RequestLayoutState) {
542 if let Some(InitState::Initializing {
543 type_,
544 text,
545 style,
546 highlight_theme,
547 }) = self.init_state.take()
548 {
549 let style = *style;
550 let highlight_theme = highlight_theme.clone();
551 let (tx, rx) = smol::channel::unbounded::<Update>();
552 let (tx_result, rx_result) =
553 smol::channel::unbounded::<Result<ParsedContent, SharedString>>();
554 let parsed_result = parse_content(type_, &text, style.clone(), &highlight_theme);
555
556 self.state.update(cx, {
557 let tx = tx.clone();
558 |state, _| {
559 state.parsed_result = Some(parsed_result);
560 state.tx = Some(tx);
561 }
562 });
563
564 cx.spawn({
565 let state = self.state.downgrade();
566 async move |cx| {
567 while let Ok(parsed_result) = rx_result.recv().await {
568 if let Some(state) = state.upgrade() {
569 _ = state.update(cx, |state, cx| {
570 state.parsed_result = Some(parsed_result);
571 if let Some(parent_entity) = state.parent_entity {
572 let app = &mut **cx;
573 app.notify(parent_entity);
574 }
575 state.clear_selection();
576 });
577 } else {
578 break;
580 }
581 }
582 }
583 })
584 .detach();
585
586 cx.background_spawn(UpdateFuture::new(
587 type_,
588 style,
589 text,
590 highlight_theme,
591 rx,
592 tx_result,
593 Duration::from_millis(200),
594 ))
595 .detach();
596
597 self.init_state = Some(InitState::Initialized { tx });
598 }
599
600 let list_state = &self.state.read(cx).list_state;
601
602 let focus_handle = self
603 .state
604 .read(cx)
605 .focus_handle
606 .as_ref()
607 .expect("focus_handle should init by TextViewState::new");
608
609 let mut el = div()
610 .key_context(CONTEXT)
611 .track_focus(focus_handle)
612 .size_full()
613 .relative()
614 .on_action({
615 let state = self.state.clone();
616 move |_: &input::Copy, _, cx| {
617 Self::on_action_copy(&state, cx);
618 }
619 })
620 .child(TextViewElement {
621 list_state: if self.scrollable {
622 Some(list_state.clone())
623 } else {
624 None
625 },
626 state: self.state.clone(),
627 })
628 .refine_style(&self.style)
629 .when(self.scrollable, |this| {
630 this.child(
631 div()
632 .absolute()
633 .w(Scrollbar::width())
634 .top_0()
635 .right_0()
636 .bottom_0()
637 .child(Scrollbar::vertical(list_state)),
638 )
639 })
640 .into_any_element();
641 let layout_id = el.request_layout(window, cx);
642 (layout_id, el)
643 }
644
645 fn prepaint(
646 &mut self,
647 _: Option<&GlobalElementId>,
648 _: Option<&InspectorElementId>,
649 _: Bounds<Pixels>,
650 request_layout: &mut Self::RequestLayoutState,
651 window: &mut Window,
652 cx: &mut App,
653 ) -> Self::PrepaintState {
654 request_layout.prepaint(window, cx);
655 }
656
657 fn paint(
658 &mut self,
659 _: Option<&GlobalElementId>,
660 _: Option<&InspectorElementId>,
661 bounds: Bounds<Pixels>,
662 request_layout: &mut Self::RequestLayoutState,
663 _: &mut Self::PrepaintState,
664 window: &mut Window,
665 cx: &mut App,
666 ) {
667 let entity_id = window.current_view();
668 let is_selectable = self.selectable;
669
670 self.state.update(cx, |state, _| {
671 state.parent_entity = Some(entity_id);
672 state.update_bounds(bounds);
673 state.is_selectable = is_selectable;
674 });
675
676 GlobalState::global_mut(cx)
677 .text_view_state_stack
678 .push(self.state.clone());
679 request_layout.paint(window, cx);
680 GlobalState::global_mut(cx).text_view_state_stack.pop();
681
682 if self.selectable {
683 let is_selecting = self.state.read(cx).is_selecting;
684 let has_selection = self.state.read(cx).has_selection();
685
686 window.on_mouse_event({
687 let state = self.state.clone();
688 move |event: &MouseDownEvent, phase, _, cx| {
689 if !bounds.contains(&event.position) || !phase.bubble() {
690 return;
691 }
692
693 state.update(cx, |state, _| {
694 state.start_selection(event.position);
695 });
696 cx.notify(entity_id);
697 }
698 });
699
700 if is_selecting {
701 window.on_mouse_event({
703 let state = self.state.clone();
704 move |event: &MouseMoveEvent, phase, _, cx| {
705 if !phase.bubble() {
706 return;
707 }
708
709 state.update(cx, |state, _| {
710 state.update_selection(event.position);
711 });
712 cx.notify(entity_id);
713 }
714 });
715
716 window.on_mouse_event({
718 let state = self.state.clone();
719 move |_: &MouseUpEvent, phase, _, cx| {
720 if !phase.bubble() {
721 return;
722 }
723
724 state.update(cx, |state, _| {
725 state.end_selection();
726 });
727 cx.notify(entity_id);
728 }
729 });
730 }
731
732 if has_selection {
733 window.on_mouse_event({
735 let state = self.state.clone();
736 move |event: &MouseDownEvent, _, _, cx| {
737 if bounds.contains(&event.position) {
738 return;
739 }
740
741 state.update(cx, |state, _| {
742 state.clear_selection();
743 });
744 cx.notify(entity_id);
745 }
746 });
747 }
748 }
749 }
750}
751
752fn parse_content(
753 type_: TextViewType,
754 text: &str,
755 style: TextViewStyle,
756 highlight_theme: &HighlightTheme,
757) -> Result<ParsedContent, SharedString> {
758 let mut node_cx = NodeContext {
759 style: style.clone(),
760 ..NodeContext::default()
761 };
762
763 let res = match type_ {
764 TextViewType::Markdown => {
765 super::format::markdown::parse(text, &style, &mut node_cx, highlight_theme)
766 }
767 TextViewType::Html => super::format::html::parse(text, &mut node_cx),
768 };
769 res.map(move |root_node| ParsedContent { root_node, node_cx })
770}
771
772fn selection_bounds(
773 start: Option<Point<Pixels>>,
774 end: Option<Point<Pixels>>,
775 bounds: Bounds<Pixels>,
776) -> Bounds<Pixels> {
777 if let (Some(start), Some(end)) = (start, end) {
778 let start = start + bounds.origin;
779 let end = end + bounds.origin;
780
781 let origin = Point {
782 x: start.x.min(end.x),
783 y: start.y.min(end.y),
784 };
785 let size = Size {
786 width: (start.x - end.x).abs(),
787 height: (start.y - end.y).abs(),
788 };
789
790 return Bounds { origin, size };
791 }
792
793 Bounds::default()
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799 use gpui::{Bounds, point, px, size};
800
801 #[test]
802 fn test_text_view_state_selection_bounds() {
803 assert_eq!(
804 selection_bounds(None, None, Default::default()),
805 Bounds::default()
806 );
807 assert_eq!(
808 selection_bounds(None, Some(point(px(10.), px(20.))), Default::default()),
809 Bounds::default()
810 );
811 assert_eq!(
812 selection_bounds(Some(point(px(10.), px(20.))), None, Default::default()),
813 Bounds::default()
814 );
815
816 assert_eq!(
822 selection_bounds(
823 Some(point(px(10.), px(10.))),
824 Some(point(px(50.), px(50.))),
825 Default::default()
826 ),
827 Bounds {
828 origin: point(px(10.), px(10.)),
829 size: size(px(40.), px(40.))
830 }
831 );
832 assert_eq!(
838 selection_bounds(
839 Some(point(px(50.), px(50.))),
840 Some(point(px(10.), px(10.))),
841 Default::default()
842 ),
843 Bounds {
844 origin: point(px(10.), px(10.)),
845 size: size(px(40.), px(40.))
846 }
847 );
848 assert_eq!(
854 selection_bounds(
855 Some(point(px(50.), px(10.))),
856 Some(point(px(10.), px(50.))),
857 Default::default()
858 ),
859 Bounds {
860 origin: point(px(10.), px(10.)),
861 size: size(px(40.), px(40.))
862 }
863 );
864 assert_eq!(
870 selection_bounds(
871 Some(point(px(10.), px(50.))),
872 Some(point(px(50.), px(10.))),
873 Default::default()
874 ),
875 Bounds {
876 origin: point(px(10.), px(10.)),
877 size: size(px(40.), px(40.))
878 }
879 );
880 }
881}