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::ScrollableElement;
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 .vertical_scrollbar(list_state)
630 .into_any_element();
631 let layout_id = el.request_layout(window, cx);
632 (layout_id, el)
633 }
634
635 fn prepaint(
636 &mut self,
637 _: Option<&GlobalElementId>,
638 _: Option<&InspectorElementId>,
639 _: Bounds<Pixels>,
640 request_layout: &mut Self::RequestLayoutState,
641 window: &mut Window,
642 cx: &mut App,
643 ) -> Self::PrepaintState {
644 request_layout.prepaint(window, cx);
645 }
646
647 fn paint(
648 &mut self,
649 _: Option<&GlobalElementId>,
650 _: Option<&InspectorElementId>,
651 bounds: Bounds<Pixels>,
652 request_layout: &mut Self::RequestLayoutState,
653 _: &mut Self::PrepaintState,
654 window: &mut Window,
655 cx: &mut App,
656 ) {
657 let entity_id = window.current_view();
658 let is_selectable = self.selectable;
659
660 self.state.update(cx, |state, _| {
661 state.parent_entity = Some(entity_id);
662 state.update_bounds(bounds);
663 state.is_selectable = is_selectable;
664 });
665
666 GlobalState::global_mut(cx)
667 .text_view_state_stack
668 .push(self.state.clone());
669 request_layout.paint(window, cx);
670 GlobalState::global_mut(cx).text_view_state_stack.pop();
671
672 if self.selectable {
673 let is_selecting = self.state.read(cx).is_selecting;
674 let has_selection = self.state.read(cx).has_selection();
675
676 window.on_mouse_event({
677 let state = self.state.clone();
678 move |event: &MouseDownEvent, phase, _, cx| {
679 if !bounds.contains(&event.position) || !phase.bubble() {
680 return;
681 }
682
683 state.update(cx, |state, _| {
684 state.start_selection(event.position);
685 });
686 cx.notify(entity_id);
687 }
688 });
689
690 if is_selecting {
691 window.on_mouse_event({
693 let state = self.state.clone();
694 move |event: &MouseMoveEvent, phase, _, cx| {
695 if !phase.bubble() {
696 return;
697 }
698
699 state.update(cx, |state, _| {
700 state.update_selection(event.position);
701 });
702 cx.notify(entity_id);
703 }
704 });
705
706 window.on_mouse_event({
708 let state = self.state.clone();
709 move |_: &MouseUpEvent, phase, _, cx| {
710 if !phase.bubble() {
711 return;
712 }
713
714 state.update(cx, |state, _| {
715 state.end_selection();
716 });
717 cx.notify(entity_id);
718 }
719 });
720 }
721
722 if has_selection {
723 window.on_mouse_event({
725 let state = self.state.clone();
726 move |event: &MouseDownEvent, _, _, cx| {
727 if bounds.contains(&event.position) {
728 return;
729 }
730
731 state.update(cx, |state, _| {
732 state.clear_selection();
733 });
734 cx.notify(entity_id);
735 }
736 });
737 }
738 }
739 }
740}
741
742fn parse_content(
743 type_: TextViewType,
744 text: &str,
745 style: TextViewStyle,
746 highlight_theme: &HighlightTheme,
747) -> Result<ParsedContent, SharedString> {
748 let mut node_cx = NodeContext {
749 style: style.clone(),
750 ..NodeContext::default()
751 };
752
753 let res = match type_ {
754 TextViewType::Markdown => {
755 super::format::markdown::parse(text, &style, &mut node_cx, highlight_theme)
756 }
757 TextViewType::Html => super::format::html::parse(text, &mut node_cx),
758 };
759 res.map(move |root_node| ParsedContent { root_node, node_cx })
760}
761
762fn selection_bounds(
763 start: Option<Point<Pixels>>,
764 end: Option<Point<Pixels>>,
765 bounds: Bounds<Pixels>,
766) -> Bounds<Pixels> {
767 if let (Some(start), Some(end)) = (start, end) {
768 let start = start + bounds.origin;
769 let end = end + bounds.origin;
770
771 let origin = Point {
772 x: start.x.min(end.x),
773 y: start.y.min(end.y),
774 };
775 let size = Size {
776 width: (start.x - end.x).abs(),
777 height: (start.y - end.y).abs(),
778 };
779
780 return Bounds { origin, size };
781 }
782
783 Bounds::default()
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789 use gpui::{Bounds, point, px, size};
790
791 #[test]
792 fn test_text_view_state_selection_bounds() {
793 assert_eq!(
794 selection_bounds(None, None, Default::default()),
795 Bounds::default()
796 );
797 assert_eq!(
798 selection_bounds(None, Some(point(px(10.), px(20.))), Default::default()),
799 Bounds::default()
800 );
801 assert_eq!(
802 selection_bounds(Some(point(px(10.), px(20.))), None, Default::default()),
803 Bounds::default()
804 );
805
806 assert_eq!(
812 selection_bounds(
813 Some(point(px(10.), px(10.))),
814 Some(point(px(50.), px(50.))),
815 Default::default()
816 ),
817 Bounds {
818 origin: point(px(10.), px(10.)),
819 size: size(px(40.), px(40.))
820 }
821 );
822 assert_eq!(
828 selection_bounds(
829 Some(point(px(50.), px(50.))),
830 Some(point(px(10.), px(10.))),
831 Default::default()
832 ),
833 Bounds {
834 origin: point(px(10.), px(10.)),
835 size: size(px(40.), px(40.))
836 }
837 );
838 assert_eq!(
844 selection_bounds(
845 Some(point(px(50.), px(10.))),
846 Some(point(px(10.), px(50.))),
847 Default::default()
848 ),
849 Bounds {
850 origin: point(px(10.), px(10.)),
851 size: size(px(40.), px(40.))
852 }
853 );
854 assert_eq!(
860 selection_bounds(
861 Some(point(px(10.), px(50.))),
862 Some(point(px(50.), px(10.))),
863 Default::default()
864 ),
865 Bounds {
866 origin: point(px(10.), px(10.)),
867 size: size(px(40.), px(40.))
868 }
869 );
870 }
871}