gpui_component/text/
text_view.rs

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    div, px, AnyElement, App, AppContext, Bounds, ClipboardItem, Context, Element, ElementId,
10    Entity, EntityId, FocusHandle, GlobalElementId, InspectorElementId, InteractiveElement,
11    IntoElement, KeyBinding, LayoutId, ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
12    ParentElement, Pixels, Point, RenderOnce, SharedString, Size, StyleRefinement, Styled, Timer,
13    Window,
14};
15use smol::stream::StreamExt;
16
17use crate::highlighter::HighlightTheme;
18use crate::scroll::{Scrollbar, ScrollbarState};
19use crate::{
20    global_state::GlobalState,
21    input::{self},
22    text::{
23        node::{self, NodeContext},
24        TextViewStyle,
25    },
26};
27use crate::{v_flex, ActiveTheme, StyledExt};
28
29const CONTEXT: &'static str = "TextView";
30
31pub(crate) fn init(cx: &mut App) {
32    cx.bind_keys(vec![
33        #[cfg(target_os = "macos")]
34        KeyBinding::new("cmd-c", input::Copy, Some(CONTEXT)),
35        #[cfg(not(target_os = "macos"))]
36        KeyBinding::new("ctrl-c", input::Copy, Some(CONTEXT)),
37    ]);
38}
39
40#[derive(IntoElement, Clone)]
41struct TextViewElement {
42    list_state: Option<ListState>,
43    state: Entity<TextViewState>,
44}
45
46impl RenderOnce for TextViewElement {
47    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
48        self.state.update(cx, |state, cx| {
49            v_flex()
50                .size_full()
51                .map(|this| match &mut state.parsed_result {
52                    Some(Ok(content)) => this.child(content.root_node.render_root(
53                        self.list_state.clone(),
54                        &content.node_cx,
55                        window,
56                        cx,
57                    )),
58                    Some(Err(err)) => this.child(
59                        v_flex()
60                            .gap_1()
61                            .child("Failed to parse content")
62                            .child(err.to_string()),
63                    ),
64                    None => this,
65                })
66        })
67    }
68}
69
70/// A text view that can render Markdown or HTML.
71///
72/// ## Goals
73///
74/// - Provide a rich text rendering component for such as Markdown or HTML,
75/// used to display rich text in GPUI application (e.g., Help messages, Release notes)
76/// - Support Markdown GFM and HTML (Simple HTML like Safari Reader Mode) for showing most common used markups.
77/// - Support Heading, Paragraph, Bold, Italic, StrikeThrough, Code, Link, Image, Blockquote, List, Table, HorizontalRule, CodeBlock ...
78///
79/// ## Not Goals
80///
81/// - Customization of the complex style (some simple styles will be supported)
82/// - As a Markdown editor or viewer (If you want to like this, you must fork your version).
83/// - As a HTML viewer, we not support CSS, we only support basic HTML tags for used to as a content reader.
84///
85/// See also [`MarkdownElement`], [`HtmlElement`]
86#[derive(Clone)]
87pub struct TextView {
88    id: ElementId,
89    init_state: Option<InitState>,
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/// The type of the text view.
103#[derive(Clone, Copy, PartialEq, Eq)]
104enum TextViewType {
105    /// Markdown view
106    Markdown,
107    /// HTML view
108    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    /// The bounds of the text view
214    bounds: Bounds<Pixels>,
215    /// The local (in TextView) position of the selection.
216    selection_positions: (Option<Point<Pixels>>, Option<Point<Pixels>>),
217    /// Is current in selection.
218    is_selecting: bool,
219    is_selectable: bool,
220    scrollbar_state: ScrollbarState,
221    list_state: ListState,
222}
223
224impl TextViewState {
225    fn new(cx: &mut Context<TextViewState>) -> Self {
226        let focus_handle = cx.focus_handle();
227        Self {
228            parent_entity: None,
229            tx: None,
230            parsed_result: None,
231            focus_handle: Some(focus_handle),
232            bounds: Bounds::default(),
233            selection_positions: (None, None),
234            is_selecting: false,
235            is_selectable: false,
236            scrollbar_state: ScrollbarState::default(),
237            list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
238        }
239    }
240}
241
242impl TextViewState {
243    /// Save bounds and unselect if bounds changed.
244    fn update_bounds(&mut self, bounds: Bounds<Pixels>) {
245        if self.bounds.size != bounds.size {
246            self.clear_selection();
247        }
248        self.bounds = bounds;
249    }
250
251    fn clear_selection(&mut self) {
252        self.selection_positions = (None, None);
253        self.is_selecting = false;
254    }
255
256    fn start_selection(&mut self, pos: Point<Pixels>) {
257        let pos = pos - self.bounds.origin;
258        self.selection_positions = (Some(pos), Some(pos));
259        self.is_selecting = true;
260    }
261
262    fn update_selection(&mut self, pos: Point<Pixels>) {
263        let pos = pos - self.bounds.origin;
264        if let (Some(start), Some(_)) = self.selection_positions {
265            self.selection_positions = (Some(start), Some(pos))
266        }
267    }
268
269    fn end_selection(&mut self) {
270        self.is_selecting = false;
271    }
272
273    pub(crate) fn has_selection(&self) -> bool {
274        if let (Some(start), Some(end)) = self.selection_positions {
275            start != end
276        } else {
277            false
278        }
279    }
280
281    pub(crate) fn is_selectable(&self) -> bool {
282        self.is_selectable
283    }
284
285    /// Return the bounds of the selection in window coordinates.
286    pub(crate) fn selection_bounds(&self) -> Bounds<Pixels> {
287        selection_bounds(
288            self.selection_positions.0,
289            self.selection_positions.1,
290            self.bounds,
291        )
292    }
293
294    fn selection_text(&self) -> Option<String> {
295        Some(
296            self.parsed_result
297                .as_ref()?
298                .as_ref()
299                .ok()?
300                .root_node
301                .selected_text(),
302        )
303    }
304}
305
306#[derive(IntoElement, Clone)]
307pub enum Text {
308    String(SharedString),
309    TextView(Box<TextView>),
310}
311
312impl From<SharedString> for Text {
313    fn from(s: SharedString) -> Self {
314        Self::String(s)
315    }
316}
317
318impl From<&str> for Text {
319    fn from(s: &str) -> Self {
320        Self::String(SharedString::from(s.to_string()))
321    }
322}
323
324impl From<String> for Text {
325    fn from(s: String) -> Self {
326        Self::String(s.into())
327    }
328}
329
330impl From<TextView> for Text {
331    fn from(e: TextView) -> Self {
332        Self::TextView(Box::new(e))
333    }
334}
335
336impl Text {
337    /// Set the style for [`TextView`].
338    ///
339    /// Do nothing if this is `String`.
340    pub fn style(self, style: TextViewStyle) -> Self {
341        match self {
342            Self::String(s) => Self::String(s),
343            Self::TextView(e) => Self::TextView(Box::new(e.style(style))),
344        }
345    }
346}
347
348impl RenderOnce for Text {
349    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
350        match self {
351            Self::String(s) => s.into_any_element(),
352            Self::TextView(e) => e.into_any_element(),
353        }
354    }
355}
356
357impl Styled for TextView {
358    fn style(&mut self) -> &mut StyleRefinement {
359        &mut self.style
360    }
361}
362
363impl TextView {
364    fn create_init_state(
365        type_: TextViewType,
366        text: &SharedString,
367        highlight_theme: &Arc<HighlightTheme>,
368        state: &Entity<TextViewState>,
369        cx: &mut App,
370    ) -> InitState {
371        let state = state.read(cx);
372        if let Some(tx) = &state.tx {
373            InitState::Initialized { tx: tx.clone() }
374        } else {
375            InitState::Initializing {
376                type_,
377                text: text.clone(),
378                style: Default::default(),
379                highlight_theme: highlight_theme.clone(),
380            }
381        }
382    }
383
384    /// Create a new markdown text view.
385    pub fn markdown(
386        id: impl Into<ElementId>,
387        markdown: impl Into<SharedString>,
388        window: &mut Window,
389        cx: &mut App,
390    ) -> Self {
391        let id: ElementId = id.into();
392        let markdown = markdown.into();
393        let highlight_theme = cx.theme().highlight_theme.clone();
394        let state =
395            window.use_keyed_state(SharedString::from(format!("{}/state", id)), cx, |_, cx| {
396                TextViewState::new(cx)
397            });
398        let init_state = Self::create_init_state(
399            TextViewType::Markdown,
400            &markdown,
401            &highlight_theme,
402            &state,
403            cx,
404        );
405        if let Some(tx) = &state.read(cx).tx {
406            let _ = tx.try_send(Update::Text(markdown));
407        }
408        Self {
409            id,
410            init_state: Some(init_state),
411            style: StyleRefinement::default(),
412            state,
413            selectable: false,
414            scrollable: false,
415        }
416    }
417
418    /// Create a new html text view.
419    pub fn html(
420        id: impl Into<ElementId>,
421        html: impl Into<SharedString>,
422        window: &mut Window,
423        cx: &mut App,
424    ) -> Self {
425        let id: ElementId = id.into();
426        let html = html.into();
427        let highlight_theme = cx.theme().highlight_theme.clone();
428        let state =
429            window.use_keyed_state(SharedString::from(format!("{}/state", id)), cx, |_, cx| {
430                TextViewState::new(cx)
431            });
432        let init_state =
433            Self::create_init_state(TextViewType::Html, &html, &highlight_theme, &state, cx);
434        if let Some(tx) = &state.read(cx).tx {
435            let _ = tx.try_send(Update::Text(html));
436        }
437        Self {
438            id,
439            init_state: Some(init_state),
440            style: StyleRefinement::default(),
441            state,
442            selectable: false,
443            scrollable: false,
444        }
445    }
446
447    /// Set the source text of the text view.
448    pub fn text(mut self, raw: impl Into<SharedString>) -> Self {
449        if let Some(init_state) = &mut self.init_state {
450            match init_state {
451                InitState::Initializing { text, .. } => *text = raw.into(),
452                InitState::Initialized { tx } => {
453                    let _ = tx.try_send(Update::Text(raw.into()));
454                }
455            }
456        }
457
458        self
459    }
460
461    /// Set [`TextViewStyle`].
462    pub fn style(mut self, style: TextViewStyle) -> Self {
463        if let Some(init_state) = &mut self.init_state {
464            match init_state {
465                InitState::Initializing { style: s, .. } => *s = Box::new(style),
466                InitState::Initialized { tx } => {
467                    let _ = tx.try_send(Update::Style(Box::new(style)));
468                }
469            }
470        }
471        self
472    }
473
474    /// Set the text view to be selectable, default is false.
475    pub fn selectable(mut self) -> Self {
476        self.selectable = true;
477        self
478    }
479
480    /// Set the text view to be scrollable, default is false.
481    ///
482    /// ## If true for `scrollable`
483    ///
484    /// The `scrollable` mode used for large content,
485    /// will show scrollbar, but requires the parent to have a fixed height,
486    /// and use [`gpui::list`] to render the content in a virtualized way.
487    ///
488    /// ## If false to fit content
489    ///
490    /// The TextView will expand to fit all content, no scrollbar.
491    /// This mode is suitable for small content, such as a few lines of text, a label, etc.
492    pub fn scrollable(mut self) -> Self {
493        self.scrollable = true;
494        self
495    }
496
497    fn on_action_copy(state: &Entity<TextViewState>, cx: &mut App) {
498        let Some(selected_text) = state.read(cx).selection_text() else {
499            return;
500        };
501
502        cx.write_to_clipboard(ClipboardItem::new_string(selected_text.trim().to_string()));
503    }
504}
505
506impl IntoElement for TextView {
507    type Element = Self;
508
509    fn into_element(self) -> Self::Element {
510        self
511    }
512}
513
514impl Element for TextView {
515    type RequestLayoutState = AnyElement;
516    type PrepaintState = ();
517
518    fn id(&self) -> Option<ElementId> {
519        Some(self.id.clone())
520    }
521
522    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
523        None
524    }
525
526    fn request_layout(
527        &mut self,
528        _: Option<&GlobalElementId>,
529        _: Option<&InspectorElementId>,
530        window: &mut Window,
531        cx: &mut App,
532    ) -> (LayoutId, Self::RequestLayoutState) {
533        if let Some(InitState::Initializing {
534            type_,
535            text,
536            style,
537            highlight_theme,
538        }) = self.init_state.take()
539        {
540            let style = *style;
541            let highlight_theme = highlight_theme.clone();
542            let (tx, rx) = smol::channel::unbounded::<Update>();
543            let (tx_result, rx_result) =
544                smol::channel::unbounded::<Result<ParsedContent, SharedString>>();
545            let parsed_result = parse_content(type_, &text, style.clone(), &highlight_theme);
546
547            self.state.update(cx, {
548                let tx = tx.clone();
549                |state, _| {
550                    state.parsed_result = Some(parsed_result);
551                    state.tx = Some(tx);
552                }
553            });
554
555            cx.spawn({
556                let state = self.state.downgrade();
557                async move |cx| {
558                    while let Ok(parsed_result) = rx_result.recv().await {
559                        if let Some(state) = state.upgrade() {
560                            _ = state.update(cx, |state, cx| {
561                                state.parsed_result = Some(parsed_result);
562                                if let Some(parent_entity) = state.parent_entity {
563                                    let app = &mut **cx;
564                                    app.notify(parent_entity);
565                                }
566                                state.clear_selection();
567                            });
568                        } else {
569                            // state released, stopping processing
570                            break;
571                        }
572                    }
573                }
574            })
575            .detach();
576
577            cx.background_spawn(UpdateFuture::new(
578                type_,
579                style,
580                text,
581                highlight_theme,
582                rx,
583                tx_result,
584                Duration::from_millis(200),
585            ))
586            .detach();
587
588            self.init_state = Some(InitState::Initialized { tx });
589        }
590
591        let scrollbar_state = &self.state.read(cx).scrollbar_state;
592        let list_state = &self.state.read(cx).list_state;
593
594        let focus_handle = self
595            .state
596            .read(cx)
597            .focus_handle
598            .as_ref()
599            .expect("focus_handle should init by TextViewState::new");
600
601        let mut el = div()
602            .key_context(CONTEXT)
603            .track_focus(focus_handle)
604            .size_full()
605            .relative()
606            .on_action({
607                let state = self.state.clone();
608                move |_: &input::Copy, _, cx| {
609                    Self::on_action_copy(&state, cx);
610                }
611            })
612            .child(TextViewElement {
613                list_state: if self.scrollable {
614                    Some(list_state.clone())
615                } else {
616                    None
617                },
618                state: self.state.clone(),
619            })
620            .refine_style(&self.style)
621            .when(self.scrollable, |this| {
622                this.child(
623                    div()
624                        .absolute()
625                        .w(Scrollbar::width())
626                        .top_0()
627                        .right_0()
628                        .bottom_0()
629                        .child(Scrollbar::vertical(scrollbar_state, list_state)),
630                )
631            })
632            .into_any_element();
633        let layout_id = el.request_layout(window, cx);
634        (layout_id, el)
635    }
636
637    fn prepaint(
638        &mut self,
639        _: Option<&GlobalElementId>,
640        _: Option<&InspectorElementId>,
641        _: Bounds<Pixels>,
642        request_layout: &mut Self::RequestLayoutState,
643        window: &mut Window,
644        cx: &mut App,
645    ) -> Self::PrepaintState {
646        request_layout.prepaint(window, cx);
647    }
648
649    fn paint(
650        &mut self,
651        _: Option<&GlobalElementId>,
652        _: Option<&InspectorElementId>,
653        bounds: Bounds<Pixels>,
654        request_layout: &mut Self::RequestLayoutState,
655        _: &mut Self::PrepaintState,
656        window: &mut Window,
657        cx: &mut App,
658    ) {
659        let entity_id = window.current_view();
660        let is_selectable = self.selectable;
661
662        self.state.update(cx, |state, _| {
663            state.parent_entity = Some(entity_id);
664            state.update_bounds(bounds);
665            state.is_selectable = is_selectable;
666        });
667
668        GlobalState::global_mut(cx)
669            .text_view_state_stack
670            .push(self.state.clone());
671        request_layout.paint(window, cx);
672        GlobalState::global_mut(cx).text_view_state_stack.pop();
673
674        if self.selectable {
675            let is_selecting = self.state.read(cx).is_selecting;
676            let has_selection = self.state.read(cx).has_selection();
677
678            window.on_mouse_event({
679                let state = self.state.clone();
680                move |event: &MouseDownEvent, phase, _, cx| {
681                    if !bounds.contains(&event.position) || !phase.bubble() {
682                        return;
683                    }
684
685                    state.update(cx, |state, _| {
686                        state.start_selection(event.position);
687                    });
688                    cx.notify(entity_id);
689                }
690            });
691
692            if is_selecting {
693                // move to update end position.
694                window.on_mouse_event({
695                    let state = self.state.clone();
696                    move |event: &MouseMoveEvent, phase, _, cx| {
697                        if !phase.bubble() {
698                            return;
699                        }
700
701                        state.update(cx, |state, _| {
702                            state.update_selection(event.position);
703                        });
704                        cx.notify(entity_id);
705                    }
706                });
707
708                // up to end selection
709                window.on_mouse_event({
710                    let state = self.state.clone();
711                    move |_: &MouseUpEvent, phase, _, cx| {
712                        if !phase.bubble() {
713                            return;
714                        }
715
716                        state.update(cx, |state, _| {
717                            state.end_selection();
718                        });
719                        cx.notify(entity_id);
720                    }
721                });
722            }
723
724            if has_selection {
725                // down outside to clear selection
726                window.on_mouse_event({
727                    let state = self.state.clone();
728                    move |event: &MouseDownEvent, _, _, cx| {
729                        if bounds.contains(&event.position) {
730                            return;
731                        }
732
733                        state.update(cx, |state, _| {
734                            state.clear_selection();
735                        });
736                        cx.notify(entity_id);
737                    }
738                });
739            }
740        }
741    }
742}
743
744fn parse_content(
745    type_: TextViewType,
746    text: &str,
747    style: TextViewStyle,
748    highlight_theme: &HighlightTheme,
749) -> Result<ParsedContent, SharedString> {
750    let mut node_cx = NodeContext {
751        style: style.clone(),
752        ..NodeContext::default()
753    };
754
755    let res = match type_ {
756        TextViewType::Markdown => {
757            super::format::markdown::parse(text, &style, &mut node_cx, highlight_theme)
758        }
759        TextViewType::Html => super::format::html::parse(text, &mut node_cx),
760    };
761    res.map(move |root_node| ParsedContent { root_node, node_cx })
762}
763
764fn selection_bounds(
765    start: Option<Point<Pixels>>,
766    end: Option<Point<Pixels>>,
767    bounds: Bounds<Pixels>,
768) -> Bounds<Pixels> {
769    if let (Some(start), Some(end)) = (start, end) {
770        let start = start + bounds.origin;
771        let end = end + bounds.origin;
772
773        let origin = Point {
774            x: start.x.min(end.x),
775            y: start.y.min(end.y),
776        };
777        let size = Size {
778            width: (start.x - end.x).abs(),
779            height: (start.y - end.y).abs(),
780        };
781
782        return Bounds { origin, size };
783    }
784
785    Bounds::default()
786}
787
788#[cfg(test)]
789mod tests {
790    use super::*;
791    use gpui::{point, px, size, Bounds};
792
793    #[test]
794    fn test_text_view_state_selection_bounds() {
795        assert_eq!(
796            selection_bounds(None, None, Default::default()),
797            Bounds::default()
798        );
799        assert_eq!(
800            selection_bounds(None, Some(point(px(10.), px(20.))), Default::default()),
801            Bounds::default()
802        );
803        assert_eq!(
804            selection_bounds(Some(point(px(10.), px(20.))), None, Default::default()),
805            Bounds::default()
806        );
807
808        // 10,10 start
809        //   |------|
810        //   |      |
811        //   |------|
812        //         50,50
813        assert_eq!(
814            selection_bounds(
815                Some(point(px(10.), px(10.))),
816                Some(point(px(50.), px(50.))),
817                Default::default()
818            ),
819            Bounds {
820                origin: point(px(10.), px(10.)),
821                size: size(px(40.), px(40.))
822            }
823        );
824        // 10,10
825        //   |------|
826        //   |      |
827        //   |------|
828        //         50,50 start
829        assert_eq!(
830            selection_bounds(
831                Some(point(px(50.), px(50.))),
832                Some(point(px(10.), px(10.))),
833                Default::default()
834            ),
835            Bounds {
836                origin: point(px(10.), px(10.)),
837                size: size(px(40.), px(40.))
838            }
839        );
840        //        50,10 start
841        //   |------|
842        //   |      |
843        //   |------|
844        // 10,50
845        assert_eq!(
846            selection_bounds(
847                Some(point(px(50.), px(10.))),
848                Some(point(px(10.), px(50.))),
849                Default::default()
850            ),
851            Bounds {
852                origin: point(px(10.), px(10.)),
853                size: size(px(40.), px(40.))
854            }
855        );
856        //        50,10
857        //   |------|
858        //   |      |
859        //   |------|
860        // 10,50 start
861        assert_eq!(
862            selection_bounds(
863                Some(point(px(10.), px(50.))),
864                Some(point(px(50.), px(10.))),
865                Default::default()
866            ),
867            Bounds {
868                origin: point(px(10.), px(10.)),
869                size: size(px(40.), px(40.))
870            }
871        );
872    }
873}