Skip to main content

liora_components/
selectable_text.rs

1use crate::gpui_compat::element_id;
2use gpui::{
3    App, Bounds, ClipboardItem, Component, Context, Element, ElementId, Entity, FocusHandle,
4    Focusable, GlobalElementId, InspectorElementId, IntoElement, KeyDownEvent, LayoutId,
5    MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, Render,
6    RenderOnce, SharedString, Style, TextRun, TextStyle, WhiteSpace, Window, actions, div, fill,
7    point, prelude::*, px, relative, size,
8};
9use liora_core::Config;
10use std::{
11    collections::HashMap,
12    ops::Range,
13    sync::{Arc, Mutex, MutexGuard, OnceLock},
14};
15
16actions!(
17    selectable_text_actions,
18    [SelectableTextSelectAll, SelectableTextCopy]
19);
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum SelectableTextWrap {
23    Normal,
24    NoWrap,
25}
26
27impl SelectableTextWrap {
28    fn white_space(self) -> WhiteSpace {
29        match self {
30            Self::Normal => WhiteSpace::Normal,
31            Self::NoWrap => WhiteSpace::Nowrap,
32        }
33    }
34}
35
36#[derive(Clone)]
37pub struct SelectableTextOptions {
38    pub id: ElementId,
39    pub text: SharedString,
40    pub runs: Vec<TextRun>,
41    pub font_size: Pixels,
42    pub line_height: Pixels,
43    pub text_color: gpui::Hsla,
44    pub wrap: SelectableTextWrap,
45    pub key_context: &'static str,
46    pub fill_width: bool,
47}
48
49impl SelectableTextOptions {
50    pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
51        Self {
52            id: id.into(),
53            text: text.into(),
54            runs: Vec::new(),
55            font_size: px(14.0),
56            line_height: px(22.0),
57            text_color: gpui::black(),
58            wrap: SelectableTextWrap::Normal,
59            key_context: "SelectableText",
60            fill_width: true,
61        }
62    }
63}
64
65pub struct SelectableText;
66
67impl SelectableText {
68    pub fn register_key_bindings(cx: &mut App) {
69        cx.bind_keys([
70            gpui::KeyBinding::new("cmd-a", SelectableTextSelectAll, Some("SelectableText")),
71            gpui::KeyBinding::new("ctrl-a", SelectableTextSelectAll, Some("SelectableText")),
72            gpui::KeyBinding::new("cmd-c", SelectableTextCopy, Some("SelectableText")),
73            gpui::KeyBinding::new("ctrl-c", SelectableTextCopy, Some("SelectableText")),
74        ]);
75    }
76
77    pub fn view(
78        options: SelectableTextOptions,
79        window: &mut Window,
80        cx: &mut App,
81    ) -> gpui::AnyElement {
82        let input = window.use_keyed_state(options.id.clone(), cx, {
83            let initial = options.clone();
84            move |_, cx| SelectableTextState::new(cx, initial)
85        });
86        input.update(cx, |state, cx| state.update_options(options, cx));
87        SelectableTextView { input }.into_any_element()
88    }
89}
90
91struct SelectableTextView {
92    input: Entity<SelectableTextState>,
93}
94
95impl IntoElement for SelectableTextView {
96    type Element = Component<Self>;
97
98    fn into_element(self) -> Self::Element {
99        Component::new(self)
100    }
101}
102
103impl RenderOnce for SelectableTextView {
104    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
105        self.input.into_any_element()
106    }
107}
108
109struct SelectableTextSelectionState {
110    selected_range: Range<usize>,
111    selection_reversed: bool,
112    selecting: bool,
113    layout: Option<Arc<SelectableTextLayout>>,
114    line_starts: Vec<(Pixels, usize)>,
115    bounds: Option<Bounds<Pixels>>,
116}
117
118impl Default for SelectableTextSelectionState {
119    fn default() -> Self {
120        Self {
121            selected_range: 0..0,
122            selection_reversed: false,
123            selecting: false,
124            layout: None,
125            line_starts: Vec::new(),
126            bounds: None,
127        }
128    }
129}
130
131fn selection_state_map() -> &'static Mutex<HashMap<String, SelectableTextSelectionState>> {
132    static STATES: OnceLock<Mutex<HashMap<String, SelectableTextSelectionState>>> = OnceLock::new();
133    STATES.get_or_init(|| Mutex::new(HashMap::new()))
134}
135
136fn lock_selection_state_map() -> MutexGuard<'static, HashMap<String, SelectableTextSelectionState>>
137{
138    selection_state_map()
139        .lock()
140        .unwrap_or_else(|poisoned| poisoned.into_inner())
141}
142
143fn selection_key(id: &ElementId) -> String {
144    format!("{id:?}")
145}
146
147fn with_selection_state<R>(
148    id: &ElementId,
149    f: impl FnOnce(&mut SelectableTextSelectionState) -> R,
150) -> R {
151    let mut states = lock_selection_state_map();
152    f(states.entry(selection_key(id)).or_default())
153}
154
155fn selected_range_snapshot(id: &ElementId) -> Range<usize> {
156    lock_selection_state_map()
157        .get(&selection_key(id))
158        .map(|state| state.selected_range.clone())
159        .unwrap_or(0..0)
160}
161
162fn set_layout_state(
163    id: &ElementId,
164    layout: Arc<SelectableTextLayout>,
165    line_starts: Vec<(Pixels, usize)>,
166    bounds: Bounds<Pixels>,
167) {
168    with_selection_state(id, |state| {
169        state.layout = Some(layout);
170        state.line_starts = line_starts;
171        state.bounds = Some(bounds);
172    });
173}
174
175struct SelectableTextState {
176    id: ElementId,
177    text: SharedString,
178    runs: Vec<TextRun>,
179    font_size: Pixels,
180    line_height: Pixels,
181    text_color: gpui::Hsla,
182    wrap: SelectableTextWrap,
183    key_context: &'static str,
184    fill_width: bool,
185    focus_handle: FocusHandle,
186}
187
188impl SelectableTextState {
189    fn new(cx: &mut Context<Self>, options: SelectableTextOptions) -> Self {
190        Self {
191            id: options.id,
192            runs: normalize_runs(options.runs, options.text.len(), options.text_color),
193            text: options.text,
194            font_size: options.font_size,
195            line_height: options.line_height,
196            text_color: options.text_color,
197            wrap: options.wrap,
198            key_context: options.key_context,
199            fill_width: options.fill_width,
200            focus_handle: cx.focus_handle(),
201        }
202    }
203
204    fn update_options(&mut self, options: SelectableTextOptions, cx: &mut Context<Self>) {
205        let runs = normalize_runs(options.runs, options.text.len(), options.text_color);
206        let changed = self.id != options.id
207            || self.text != options.text
208            || self.runs != runs
209            || self.font_size != options.font_size
210            || self.line_height != options.line_height
211            || self.text_color != options.text_color
212            || self.wrap != options.wrap
213            || self.key_context != options.key_context
214            || self.fill_width != options.fill_width;
215        if !changed {
216            return;
217        }
218
219        let old_id = self.id.clone();
220        self.id = options.id;
221        self.text = options.text;
222        self.runs = runs;
223        self.font_size = options.font_size;
224        self.line_height = options.line_height;
225        self.text_color = options.text_color;
226        self.wrap = options.wrap;
227        self.key_context = options.key_context;
228        self.fill_width = options.fill_width;
229
230        if old_id != self.id {
231            let old_range = selected_range_snapshot(&old_id);
232            with_selection_state(&self.id, |state| state.selected_range = old_range);
233        }
234        with_selection_state(&self.id, |state| {
235            state.selected_range.start = self.clamp_boundary(state.selected_range.start);
236            state.selected_range.end = self.clamp_boundary(state.selected_range.end);
237            if state.selected_range.end < state.selected_range.start {
238                state.selected_range = state.selected_range.end..state.selected_range.start;
239                state.selection_reversed = !state.selection_reversed;
240            }
241        });
242        cx.notify();
243    }
244
245    fn text_style(&self, window: &Window) -> TextStyle {
246        let mut style = window.text_style();
247        style.color = self.text_color;
248        style.font_size = self.font_size.into();
249        style.line_height = self.line_height.into();
250        style.white_space = self.wrap.white_space();
251        style.text_overflow = None;
252        style.line_clamp = None;
253        style
254    }
255
256    fn move_to(&self, state: &mut SelectableTextSelectionState, offset: usize) -> bool {
257        let offset = self.clamp_boundary(offset);
258        if state.selected_range == (offset..offset) && !state.selection_reversed {
259            return false;
260        }
261        state.selected_range = offset..offset;
262        state.selection_reversed = false;
263        true
264    }
265
266    fn select_to(&self, state: &mut SelectableTextSelectionState, offset: usize) -> bool {
267        let offset = self.clamp_boundary(offset);
268        let previous_range = state.selected_range.clone();
269        let previous_reversed = state.selection_reversed;
270        if state.selection_reversed {
271            state.selected_range.start = offset;
272        } else {
273            state.selected_range.end = offset;
274        }
275        if state.selected_range.end < state.selected_range.start {
276            state.selection_reversed = !state.selection_reversed;
277            state.selected_range = state.selected_range.end..state.selected_range.start;
278        }
279        state.selected_range != previous_range || state.selection_reversed != previous_reversed
280    }
281
282    fn clamp_boundary(&self, mut offset: usize) -> usize {
283        offset = offset.min(self.text.len());
284        while offset > 0 && !self.text.is_char_boundary(offset) {
285            offset -= 1;
286        }
287        offset
288    }
289
290    fn index_for_point(&self, pt: Point<Pixels>) -> usize {
291        let states = lock_selection_state_map();
292        let Some(state) = states.get(&selection_key(&self.id)) else {
293            return self.text.len();
294        };
295        let Some(bounds) = state.bounds.as_ref() else {
296            return self.text.len();
297        };
298        let Some(layout) = state.layout.as_ref() else {
299            return self.text.len();
300        };
301        if layout.lines.is_empty() {
302            return self.text.len();
303        }
304
305        let mut chosen = 0;
306        for (ix, line) in layout.lines.iter().enumerate() {
307            let y = state
308                .line_starts
309                .get(ix)
310                .map(|(y, _)| *y)
311                .unwrap_or(bounds.top());
312            let line_bottom = y + line.size(self.line_height).height;
313            if pt.y <= line_bottom {
314                chosen = ix;
315                break;
316            }
317            if pt.y >= y {
318                chosen = ix;
319            }
320        }
321
322        let line = &layout.lines[chosen];
323        let (y, start) = state
324            .line_starts
325            .get(chosen)
326            .copied()
327            .unwrap_or((bounds.top(), 0));
328        let position = point(pt.x - bounds.left(), pt.y - y);
329        let line_index = line
330            .closest_index_for_position(position, self.line_height)
331            .unwrap_or_else(|idx| idx);
332        self.clamp_boundary(start + line_index)
333    }
334
335    fn on_mouse_down(
336        &mut self,
337        event: &MouseDownEvent,
338        window: &mut Window,
339        cx: &mut Context<Self>,
340    ) {
341        window.focus(&self.focus_handle);
342        let idx = self.index_for_point(event.position);
343        let changed = with_selection_state(&self.id, |state| {
344            let was_selecting = state.selecting;
345            state.selecting = true;
346            if event.modifiers.shift {
347                self.select_to(state, idx) || !was_selecting
348            } else if event.click_count >= 3 {
349                let changed = state.selected_range != (0..self.text.len())
350                    || state.selection_reversed
351                    || !was_selecting;
352                state.selected_range = 0..self.text.len();
353                state.selection_reversed = false;
354                changed
355            } else if event.click_count == 2 {
356                let range = self.word_range_at(idx);
357                let changed =
358                    state.selected_range != range || state.selection_reversed || !was_selecting;
359                state.selected_range = range;
360                state.selection_reversed = false;
361                changed
362            } else {
363                self.move_to(state, idx) || !was_selecting
364            }
365        });
366        if changed {
367            cx.notify();
368        }
369    }
370
371    fn on_mouse_move(&mut self, event: &MouseMoveEvent, cx: &mut Context<Self>) {
372        let dragging = event.pressed_button == Some(MouseButton::Left);
373        let idx = dragging.then(|| self.index_for_point(event.position));
374        let changed = with_selection_state(&self.id, |state| {
375            if !dragging {
376                let changed = state.selecting;
377                state.selecting = false;
378                changed
379            } else if state.selecting {
380                self.select_to(state, idx.unwrap_or(self.text.len()))
381            } else {
382                false
383            }
384        });
385        if changed {
386            cx.notify();
387        }
388    }
389
390    fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, cx: &mut Context<Self>) {
391        let changed = with_selection_state(&self.id, |state| {
392            let changed = state.selecting;
393            state.selecting = false;
394            changed
395        });
396        if changed {
397            cx.notify();
398        }
399    }
400
401    fn clear_selection(&mut self, cx: &mut Context<Self>) {
402        let changed = with_selection_state(&self.id, |state| {
403            let changed = !state.selected_range.is_empty() || state.selecting;
404            state.selected_range = 0..0;
405            state.selection_reversed = false;
406            state.selecting = false;
407            changed
408        });
409        if changed {
410            cx.notify();
411        }
412    }
413
414    fn set_select_all(&mut self, cx: &mut Context<Self>) {
415        let changed = with_selection_state(&self.id, |state| {
416            let changed = state.selected_range != (0..self.text.len())
417                || state.selection_reversed
418                || state.selecting;
419            state.selected_range = 0..self.text.len();
420            state.selection_reversed = false;
421            state.selecting = false;
422            changed
423        });
424        if changed {
425            cx.notify();
426        }
427    }
428
429    fn select_all(&mut self, _: &SelectableTextSelectAll, _: &mut Window, cx: &mut Context<Self>) {
430        self.set_select_all(cx);
431    }
432
433    fn on_key_down(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context<Self>) {
434        if event.keystroke.key.eq_ignore_ascii_case("a")
435            && (event.keystroke.modifiers.control || event.keystroke.modifiers.platform)
436            && !event.keystroke.modifiers.alt
437            && !event.keystroke.modifiers.shift
438            && !event.keystroke.modifiers.function
439        {
440            self.set_select_all(cx);
441            cx.stop_propagation();
442        }
443    }
444
445    fn copy(&mut self, _: &SelectableTextCopy, _: &mut Window, cx: &mut Context<Self>) {
446        let selected_range = selected_range_snapshot(&self.id);
447        if !selected_range.is_empty() {
448            cx.write_to_clipboard(ClipboardItem::new_string(
449                self.text[selected_range].to_string(),
450            ));
451        }
452    }
453
454    fn word_range_at(&self, idx: usize) -> Range<usize> {
455        let text = self.text.as_ref();
456        if text.is_empty() {
457            return 0..0;
458        }
459        let idx = self.clamp_boundary(idx);
460        let mut start = idx;
461        while start > 0 {
462            let prev = self.prev_char(start);
463            let ch = text[prev..start].chars().next().unwrap_or(' ');
464            if !is_word_char(ch) {
465                break;
466            }
467            start = prev;
468        }
469        let mut end = idx;
470        while end < text.len() {
471            let next = self.next_char(end);
472            let ch = text[end..next].chars().next().unwrap_or(' ');
473            if !is_word_char(ch) {
474                break;
475            }
476            end = next;
477        }
478        start..end
479    }
480
481    fn prev_char(&self, offset: usize) -> usize {
482        if offset == 0 {
483            return 0;
484        }
485        let mut prev = offset - 1;
486        while prev > 0 && !self.text.is_char_boundary(prev) {
487            prev -= 1;
488        }
489        prev
490    }
491
492    fn next_char(&self, offset: usize) -> usize {
493        if offset >= self.text.len() {
494            return self.text.len();
495        }
496        let mut next = offset + 1;
497        while next < self.text.len() && !self.text.is_char_boundary(next) {
498            next += 1;
499        }
500        next
501    }
502}
503
504impl Focusable for SelectableTextState {
505    fn focus_handle(&self, _cx: &App) -> FocusHandle {
506        self.focus_handle.clone()
507    }
508}
509
510impl Render for SelectableTextState {
511    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
512        cx.on_blur(&self.focus_handle, window, |this, _, cx| {
513            this.clear_selection(cx);
514        })
515        .detach();
516
517        div()
518            .id(element_id(format!("{:?}-selectable", self.id)))
519            .key_context(self.key_context)
520            .track_focus(&self.focus_handle(cx))
521            .cursor_text()
522            .on_key_down(cx.listener(Self::on_key_down))
523            .on_action(cx.listener(Self::select_all))
524            .on_action(cx.listener(Self::copy))
525            .child(SelectableTextElement {
526                id: element_id(format!("{:?}-text", self.id)),
527                input: cx.entity(),
528            })
529    }
530}
531
532struct SelectableTextLayout {
533    lines: Vec<gpui::WrappedLine>,
534    width: Pixels,
535    height: Pixels,
536}
537
538struct SelectableTextElement {
539    id: ElementId,
540    input: Entity<SelectableTextState>,
541}
542
543struct SelectableTextPrepaint {
544    layout: Arc<SelectableTextLayout>,
545    selection: Vec<PaintQuad>,
546    hitbox: gpui::Hitbox,
547}
548
549impl IntoElement for SelectableTextElement {
550    type Element = Self;
551
552    fn into_element(self) -> Self::Element {
553        self
554    }
555}
556
557impl Element for SelectableTextElement {
558    type RequestLayoutState = Arc<SelectableTextLayout>;
559    type PrepaintState = SelectableTextPrepaint;
560
561    fn id(&self) -> Option<ElementId> {
562        Some(self.id.clone())
563    }
564
565    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
566        None
567    }
568
569    fn request_layout(
570        &mut self,
571        _: Option<&GlobalElementId>,
572        _: Option<&InspectorElementId>,
573        window: &mut Window,
574        cx: &mut App,
575    ) -> (LayoutId, Arc<SelectableTextLayout>) {
576        let input = self.input.read(cx);
577        let layout = build_selectable_layout(input, window);
578        let mut style = Style::default();
579        style.size.width = layout.width.into();
580        style.min_size.width = relative(1.).into();
581        style.size.height = layout.height.into();
582        if input.fill_width {
583            style.size.width = relative(1.).into();
584        }
585        (window.request_layout(style, [], cx), Arc::new(layout))
586    }
587
588    fn prepaint(
589        &mut self,
590        _: Option<&GlobalElementId>,
591        _: Option<&InspectorElementId>,
592        bounds: Bounds<Pixels>,
593        layout: &mut Arc<SelectableTextLayout>,
594        window: &mut Window,
595        cx: &mut App,
596    ) -> SelectableTextPrepaint {
597        let input = self.input.read(cx);
598        let mut selection_quads = Vec::new();
599        let selected_range = selected_range_snapshot(&input.id);
600        let mut y = bounds.top();
601        let mut line_starts = Vec::new();
602        let selection_color = cx.global::<Config>().theme.primary.base.opacity(0.28);
603
604        let mut line_start = 0;
605        for line in &layout.lines {
606            if !selected_range.is_empty() {
607                let line_end = line_start + line.len();
608                let start = selected_range.start.max(line_start);
609                let end = selected_range.end.min(line_end);
610                if start < end {
611                    add_wrapped_selection_quads(
612                        line,
613                        start - line_start,
614                        end - line_start,
615                        y,
616                        input.line_height,
617                        bounds,
618                        selection_color,
619                        &mut selection_quads,
620                    );
621                }
622            }
623            line_starts.push((y, line_start));
624            y += line.size(input.line_height).height;
625            line_start += line.len() + 1;
626        }
627
628        let hitbox = window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal);
629        set_layout_state(&input.id, layout.clone(), line_starts, bounds);
630
631        SelectableTextPrepaint {
632            layout: layout.clone(),
633            selection: selection_quads,
634            hitbox,
635        }
636    }
637
638    fn paint(
639        &mut self,
640        _: Option<&GlobalElementId>,
641        _: Option<&InspectorElementId>,
642        bounds: Bounds<Pixels>,
643        _: &mut Arc<SelectableTextLayout>,
644        prepaint: &mut SelectableTextPrepaint,
645        window: &mut Window,
646        cx: &mut App,
647    ) {
648        let focus_handle = self.input.read(cx).focus_handle.clone();
649        window.set_cursor_style(gpui::CursorStyle::IBeam, &prepaint.hitbox);
650
651        let input = self.input.clone();
652        let focus_handle_for_down = focus_handle.clone();
653        let hitbox = prepaint.hitbox.clone();
654        window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
655            if phase.bubble() && event.button == MouseButton::Left && hitbox.is_hovered(window) {
656                window.focus(&focus_handle_for_down);
657                input.update(cx, |input, cx| input.on_mouse_down(event, window, cx));
658                cx.stop_propagation();
659            }
660        });
661
662        let input = self.input.clone();
663        window.on_mouse_event(move |event: &MouseMoveEvent, phase, _window, cx| {
664            if phase.capture() {
665                input.update(cx, |input, cx| input.on_mouse_move(event, cx));
666            }
667        });
668
669        let input = self.input.clone();
670        window.on_mouse_event(move |event: &MouseUpEvent, phase, window, cx| {
671            if phase.capture() && event.button == MouseButton::Left {
672                input.update(cx, |input, cx| input.on_mouse_up(event, window, cx));
673            }
674        });
675
676        for selection in prepaint.selection.drain(..) {
677            window.paint_quad(selection);
678        }
679
680        let (line_height, text_align) = {
681            let input = self.input.read(cx);
682            (input.line_height, input.text_style(window).text_align)
683        };
684        let mut origin = bounds.origin;
685        for line in &prepaint.layout.lines {
686            let _ =
687                line.paint_background(origin, line_height, text_align, Some(bounds), window, cx);
688            let _ = line.paint(origin, line_height, text_align, Some(bounds), window, cx);
689            origin.y += line.size(line_height).height;
690        }
691    }
692}
693
694fn build_selectable_layout(
695    input: &SelectableTextState,
696    window: &mut Window,
697) -> SelectableTextLayout {
698    let wrap_width = if input.wrap == SelectableTextWrap::Normal {
699        Some(window.viewport_size().width.max(px(1.0)))
700    } else {
701        None
702    };
703
704    let lines: Vec<gpui::WrappedLine> = window
705        .text_system()
706        .shape_text(
707            input.text.clone(),
708            input.font_size,
709            &input.runs,
710            wrap_width,
711            None,
712        )
713        .map(|lines| lines.into_iter().collect())
714        .unwrap_or_default();
715
716    let mut width = px(1.0);
717    let mut height = px(0.0);
718    for line in &lines {
719        let line_size = line.size(input.line_height);
720        width = width.max(line_size.width).ceil();
721        height += line_size.height;
722    }
723
724    SelectableTextLayout {
725        lines,
726        width,
727        height,
728    }
729}
730
731fn add_wrapped_selection_quads(
732    line: &gpui::WrappedLine,
733    start: usize,
734    end: usize,
735    y: Pixels,
736    line_height: Pixels,
737    bounds: Bounds<Pixels>,
738    color: gpui::Hsla,
739    quads: &mut Vec<PaintQuad>,
740) {
741    let mut segment_start = start;
742    while segment_start < end {
743        let Some(start_pos) = line.position_for_index(segment_start, line_height) else {
744            break;
745        };
746        let mut segment_end = end;
747        let start_row = (start_pos.y / line_height).floor() as usize;
748        while segment_end > segment_start {
749            if let Some(end_pos) = line.position_for_index(segment_end, line_height) {
750                let end_row = (end_pos.y / line_height).floor() as usize;
751                if end_row == start_row {
752                    let width = (end_pos.x - start_pos.x).max(px(1.0));
753                    quads.push(fill(
754                        Bounds::new(
755                            point(bounds.left() + start_pos.x, y + start_pos.y),
756                            size(width, line_height),
757                        ),
758                        color,
759                    ));
760                    break;
761                }
762            }
763            segment_end = previous_boundary(line.text.as_ref(), segment_end);
764        }
765        if segment_end <= segment_start {
766            break;
767        }
768        segment_start = segment_end;
769    }
770}
771
772fn normalize_runs(mut runs: Vec<TextRun>, text_len: usize, color: gpui::Hsla) -> Vec<TextRun> {
773    if text_len == 0 {
774        return Vec::new();
775    }
776    if runs.is_empty() {
777        let mut run = TextStyle::default().to_run(text_len);
778        run.color = color;
779        return vec![run];
780    }
781    let mut total = 0;
782    for run in &mut runs {
783        if run.color == gpui::transparent_black() {
784            run.color = color;
785        }
786        total += run.len;
787    }
788    if total < text_len {
789        let mut run = runs
790            .last()
791            .cloned()
792            .unwrap_or_else(|| TextStyle::default().to_run(0));
793        run.len = text_len - total;
794        runs.push(run);
795    } else if total > text_len {
796        let mut remaining = text_len;
797        runs.retain_mut(|run| {
798            if remaining == 0 {
799                return false;
800            }
801            if run.len > remaining {
802                run.len = remaining;
803            }
804            remaining = remaining.saturating_sub(run.len);
805            true
806        });
807    }
808    runs
809}
810
811fn is_word_char(ch: char) -> bool {
812    ch.is_alphanumeric() || ch == '_' || ('\u{4e00}'..='\u{9fff}').contains(&ch)
813}
814
815fn previous_boundary(text: &str, mut offset: usize) -> usize {
816    offset = offset.saturating_sub(1).min(text.len());
817    while offset > 0 && !text.is_char_boundary(offset) {
818        offset -= 1;
819    }
820    offset
821}
822
823#[cfg(test)]
824mod tests {
825
826    #[test]
827    fn selectable_text_actions_include_copy_shortcuts() {
828        let source = include_str!("selectable_text.rs");
829        assert!(source.contains("SelectableTextSelectAll"));
830        assert!(source.contains("SelectableTextCopy"));
831        assert!(source.contains("KeyBinding::new(\"ctrl-a\""));
832        assert!(source.contains("KeyBinding::new(\"cmd-a\""));
833        assert!(source.contains("KeyBinding::new(\"ctrl-c\""));
834        assert!(source.contains("KeyBinding::new(\"cmd-c\""));
835        assert!(source.contains("fn set_select_all"));
836        assert!(source.contains("fn select_all"));
837        assert!(source.contains("fn on_key_down"));
838        assert!(source.contains("event.keystroke.modifiers.control"));
839        assert!(source.contains("event.keystroke.modifiers.platform"));
840        assert!(source.contains("event.click_count == 2"));
841        assert!(source.contains("window.capture_pointer"));
842        assert!(source.contains("phase.capture()"));
843    }
844}