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}