Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-core/src/elements/text.rs
1use super::{
2 AfterLayoutContext, AppContext, Axis, ClickableCharRange, Element, EventContext, Fill,
3 HoverableCharRange, LayoutContext, MouseStateHandle, PaintContext, PartialClickableElement,
4 Point, RectF, SecretRange, SelectableElement, Selection, SelectionFragment, SizeConstraint,
5 SELECTED_HIGHLIGHT_COLOR,
6};
7 
8use crate::event::ModifiersState;
9use crate::platform::{Cursor, LineStyle};
10use crate::text::word_boundaries::WordBoundariesPolicy;
11use crate::text::{IsRect, SelectionDirection, SelectionType, TextBuffer};
12use crate::text_layout::{
13 ClipConfig, ComputeBaselinePositionFn, Line, StyleAndFont, TextFrame, TextStyle,
14 DEFAULT_TOP_BOTTOM_RATIO,
15};
16use crate::text_offsets::CharOffset;
17use crate::text_selection_utils::{
18 calculate_tick_width, create_newline_tick_rect, selection_crosses_newline_row_based,
19 NewlineTickParams,
20};
21use crate::Event;
22use crate::{
23 event::DispatchedEvent,
24 fonts::{Cache as FontCache, FamilyId, Properties},
25 Scene,
26};
27use itertools::Itertools;
28use pathfinder_color::ColorU;
29use pathfinder_geometry::util::EPSILON;
30use pathfinder_geometry::vector::{vec2f, Vector2F};
31use std::borrow::Cow;
32use std::mem::swap;
33use std::{borrow::Borrow, ops::Range, sync::Arc};
34 
35pub const DEFAULT_UI_LINE_HEIGHT_RATIO: f32 = 1.2;
36 
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd)]
38pub struct TextSelectionBound {
39 row: usize,
40 glyph_index: usize,
41}
42 
43#[derive(Clone)]
44enum LaidOutText {
45 // The text hasn't been laid out or doesn't fit given the size constraints.
46 None,
47 Line(Arc<Line>),
48 Frame(Arc<TextFrame>),
49}
50 
51impl LaidOutText {
52 fn width(&self) -> f32 {
53 match self {
54 LaidOutText::Line(line) => line.width,
55 LaidOutText::Frame(frame) => frame.max_width(),
56 LaidOutText::None => 0.,
57 }
58 }
59 
60 fn height(&self) -> f32 {
61 match self {
62 LaidOutText::Line(line) => line.height(),
63 LaidOutText::Frame(frame) => frame.height(),
64 LaidOutText::None => 0.,
65 }
66 }
67 
68 fn paint(
69 &self,
70 bounds: RectF,
71 default_color: ColorU,
72 scene: &mut Scene,
73 font_cache: &FontCache,
74 compute_baseline_position_fn: Option<&ComputeBaselinePositionFn>,
75 ) {
76 match self {
77 LaidOutText::Line(line) => {
78 if let Some(compute_baseline_position_fn) = compute_baseline_position_fn {
79 line.paint_with_baseline_position(
80 bounds,
81 &Default::default(),
82 default_color,
83 font_cache,
84 scene,
85 compute_baseline_position_fn,
86 )
87 } else {
88 line.paint(
89 bounds,
90 &Default::default(),
91 default_color,
92 font_cache,
93 scene,
94 )
95 }
96 }
97 LaidOutText::Frame(frame) => {
98 if let Some(compute_baseline_position_fn) = compute_baseline_position_fn {
99 frame.paint_with_baseline_position(
100 bounds,
101 &Default::default(),
102 default_color,
103 scene,
104 font_cache,
105 compute_baseline_position_fn,
106 )
107 } else {
108 frame.paint(
109 bounds,
110 &Default::default(),
111 default_color,
112 scene,
113 font_cache,
114 )
115 }
116 }
117 LaidOutText::None => {}
118 }
119 }
120}
121 
122#[derive()]
123pub struct Text {
124 text: Cow<'static, str>,
125 family_id: FamilyId,
126 font_properties: Properties,
127 font_size: f32,
128 line_height_ratio: f32,
129 styles: Vec<Styles>,
130 laid_out_text: LaidOutText,
131 text_color: ColorU,
132 text_selection_color: ColorU,
133 size: Option<Vector2F>,
134 origin: Option<Point>,
135 soft_wrap: bool,
136 autosize_text: Option<f32>,
137 /// Sets the desired clip configuration used if the single line text gets clipped
138 clip_config: ClipConfig,
139 /// Contains the clickable char ranges and the corresponding click
140 /// handler for each char range
141 click_handlers: Vec<ClickableCharRange>,
142 /// Contains the hoverable char ranges and the corresponding hover
143 /// handler for each char range
144 hover_handlers: Vec<HoverableCharRange>,
145 saved_char_positions: Vec<SavedCharPositionIds>,
146 /// Optional override on the baseline position computation.
147 compute_baseline_position_fn: Option<ComputeBaselinePositionFn>,
148 /// Whether the text is selectable when rendered as a descendant of a [`SelectableArea`].
149 is_selectable: bool,
150 #[cfg(debug_assertions)]
151 /// Captures the location of the constructor call site. This is used for debugging purposes.
152 constructor_location: Option<&'static std::panic::Location<'static>>,
153}
154 
155/// Contains a char index for the text that we want to save position_id for.
156/// This can be used to position other elements relative to a char in the text element.
157struct SavedCharPositionIds {
158 char_index: usize,
159 position_id: String,
160}
161 
162pub struct Styles {
163 indices: Vec<usize>,
164 font_properties: Properties,
165 styles: TextStyle,
166}
167 
168#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
169pub struct Highlight {
170 properties: Properties,
171 pub(crate) text_style: TextStyle,
172}
173 
174#[derive(Clone, Debug, Default, PartialEq, Eq)]
175pub struct HighlightedRange {
176 pub highlight: Highlight,
177 pub highlight_indices: Vec<usize>,
178}
179 
180impl HighlightedRange {
181 pub fn merge_overlapping_ranges(mut ranges: Vec<HighlightedRange>) -> Vec<HighlightedRange> {
182 if ranges.is_empty() {
183 return ranges;
184 }
185 
186 // Sort the ranges by the start index of the range.
187 ranges.sort_by_key(|range| range.highlight_indices[0]);
188 
189 let mut merged_ranges = Vec::new();
190 let mut current_range = ranges[0].clone();
191 
192 for range in ranges.into_iter().skip(1) {
193 let current_end = *current_range
194 .highlight_indices
195 .last()
196 .expect("Expected non-empty range");
197 let next_start = range.highlight_indices[0];
198 
199 // Check if the current range overlaps or is contiguous with the next range.
200 if next_start <= current_end + 1 {
201 // Extend the current range to include the next range, avoiding duplicates.
202 let new_end = *range
203 .highlight_indices
204 .last()
205 .expect("Expected non-empty range");
206 current_range
207 .highlight_indices
208 .extend((current_end + 1..=new_end).filter(|&i| i <= new_end));
209 } else {
210 // Push the current range to the merged list and start a new range.
211 merged_ranges.push(current_range);
212 current_range = range;
213 }
214 }
215 
216 // Push the final range.
217 merged_ranges.push(current_range);
218 
219 merged_ranges
220 }
221}
222 
223impl Highlight {
224 pub fn new() -> Self {
225 Highlight {
226 properties: Default::default(),
227 text_style: Default::default(),
228 }
229 }
230 
231 pub fn with_properties(mut self, properties: Properties) -> Self {
232 self.properties = properties;
233 self
234 }
235 
236 pub fn with_text_style(mut self, text_style: TextStyle) -> Self {
237 self.text_style = text_style;
238 self
239 }
240 
241 pub fn with_foreground_color(mut self, color: ColorU) -> Self {
242 self.text_style = TextStyle::new().with_foreground_color(color);
243 self
244 }
245 
246 pub fn text_style(&self) -> &TextStyle {
247 &self.text_style
248 }
249 
250 pub fn properties(&self) -> Properties {
251 self.properties
252 }
253}
254 
255impl Text {
256 /// We've changed [`Text::new`] to default to enabling soft-wrap. All usages of [`Text::new_inline`] have not been audited.
257 /// Consider [`new_inline`](`Text::new_inline`) as deprecated and use [`new`](`Text::new`) instead, with [`soft_warp(false)`](`Text::soft_wrap`) if needed.
258 #[cfg_attr(debug_assertions, track_caller)]
259 pub fn new_inline(
260 text: impl Into<Cow<'static, str>>,
261 family_id: FamilyId,
262 font_size: f32,
263 ) -> Self {
264 Self {
265 soft_wrap: false,
266 ..Self::new(text, family_id, font_size)
267 }
268 }
269 
270 /// **Note!!** Text now defaults to `soft_wrap: true`. If you want to disable soft wrapping,
271 /// use [`soft_warp(false)`](`Text::soft_wrap`) after creating the text element.
272 #[cfg_attr(debug_assertions, track_caller)]
273 pub fn new(text: impl Into<Cow<'static, str>>, family_id: FamilyId, font_size: f32) -> Self {
274 Self {
275 text: text.into(),
276 soft_wrap: true,
277 family_id,
278 font_properties: Properties::default(),
279 font_size,
280 line_height_ratio: DEFAULT_UI_LINE_HEIGHT_RATIO,
281 styles: vec![],
282 laid_out_text: LaidOutText::None,
283 text_color: ColorU::white(),
284 text_selection_color: *SELECTED_HIGHLIGHT_COLOR,
285 size: None,
286 origin: None,
287 autosize_text: None,
288 clip_config: ClipConfig::default(),
289 click_handlers: vec![],
290 hover_handlers: vec![],
291 saved_char_positions: vec![],
292 compute_baseline_position_fn: None,
293 is_selectable: true,
294 #[cfg(debug_assertions)]
295 constructor_location: Some(std::panic::Location::caller()),
296 }
297 }
298 
299 /// Set how to clip the text with both direction and style
300 pub fn with_clip(mut self, clip_config: ClipConfig) -> Self {
301 self.clip_config = clip_config;
302 self
303 }
304 
305 pub fn with_color(mut self, color: ColorU) -> Self {
306 self.text_color = color;
307 self
308 }
309 
310 pub fn with_selection_color(mut self, color: ColorU) -> Self {
311 self.text_selection_color = color;
312 self
313 }
314 
315 pub fn with_line_height_ratio(mut self, line_height_ratio: f32) -> Self {
316 self.line_height_ratio = line_height_ratio;
317 self
318 }
319 
320 /// Set optional override for the baseline position computation function, to be used when
321 /// painting Lines within the Text.
322 pub fn with_compute_baseline_position_fn(
323 mut self,
324 compute_baseline_position_fn: ComputeBaselinePositionFn,
325 ) -> Self {
326 self.compute_baseline_position_fn = Some(compute_baseline_position_fn);
327 self
328 }
329 
330 /// Save a position_id in the position cache for a given char_index in the text.
331 /// This can be used to position other elements relative to a char in the text element.
332 pub fn with_saved_char_position(mut self, char_index: usize, position_id: String) -> Self {
333 self.saved_char_positions.push(SavedCharPositionIds {
334 char_index,
335 position_id,
336 });
337 self
338 }
339 
340 /// Returns a text in which characters at indices are highlighted.
341 /// Note that indices are char indices.
342 pub fn with_single_highlight(mut self, highlight: Highlight, indices: Vec<usize>) -> Self {
343 self.styles.push(Styles {
344 indices,
345 font_properties: highlight.properties,
346 styles: highlight.text_style,
347 });
348 
349 self
350 }
351 
352 pub fn with_highlights(
353 mut self,
354 sorted_highlights: impl IntoIterator<Item = HighlightedRange>,
355 ) -> Self {
356 self.styles = sorted_highlights
357 .into_iter()
358 .map(|highlighted_range| Styles {
359 indices: highlighted_range.highlight_indices,
360 font_properties: highlighted_range.highlight.properties,
361 styles: highlighted_range.highlight.text_style,
362 })
363 .collect();
364 
365 self
366 }
367 
368 // Registers a callback that is called when a character in the given hoverable_char_range
369 // is hovered or unhovered.
370 pub fn with_hoverable_char_range<F>(
371 mut self,
372 hoverable_char_range: Range<usize>,
373 mouse_state: MouseStateHandle,
374 cursor_on_hover: Option<Cursor>,
375 callback: F,
376 ) -> Self
377 where
378 F: 'static + FnMut(bool, &mut EventContext, &AppContext),
379 {
380 self.hover_handlers.push(HoverableCharRange {
381 char_range: hoverable_char_range,
382 hover_handler: Box::new(callback),
383 cursor_on_hover,
384 mouse_state,
385 });
386 self
387 }
388 
389 /// Replace the text in the given byte range with the replacement text.
390 pub fn replace_byte_range(&mut self, range: Range<usize>, replacement: &str) {
391 if let Cow::Owned(ref mut owned_string) = self.text {
392 owned_string.replace_range(range, replacement);
393 } else {
394 // If the text is borrowed, convert it to owned before modifying.
395 let mut owned_string = self.text.clone().into_owned();
396 owned_string.replace_range(range, replacement);
397 self.text = Cow::Owned(owned_string);
398 }
399 }
400 
401 fn handle_mouse_down(
402 &mut self,
403 clicked_pos: &Vector2F,
404 modifiers: &ModifiersState,
405 ctx: &mut EventContext,
406 app: &AppContext,
407 ) -> bool {
408 let is_covered = ctx.is_covered(Point::from_vec2f(
409 *clicked_pos,
410 self.z_index()
411 .expect("z index should be set before dispatching"),
412 ));
413 if is_covered {
414 return false;
415 }
416 let mut handled = false;
417 // Note that these are all char indices!
418 if let Some(clicked_char_idx) = self.get_char_index(clicked_pos) {
419 self.click_handlers.iter_mut().for_each(|clickable_range| {
420 if clickable_range.char_range.contains(&clicked_char_idx) {
421 let handler = clickable_range.click_handler.as_mut();
422 handler(modifiers, ctx, app);
423 handled = true;
424 }
425 })
426 }
427 handled
428 }
429 
430 /// Given a position, returns the index of the character the position is over.
431 /// Returns None if the position is not over any character.
432 fn get_char_index(&self, position: &Vector2F) -> Option<usize> {
433 let origin = self.origin?;
434 let distance_x = position.x() - origin.x();
435 match self.laid_out_text.clone() {
436 LaidOutText::Frame(text_frame) => {
437 let origin_y = origin.y();
438 let mut line_height_from_origin = 0.;
439 for line in text_frame.lines() {
440 line_height_from_origin += line.height();
441 let distance_y = position.y() - origin_y;
442 if distance_y >= 0. && distance_y <= line_height_from_origin {
443 return line.index_for_x(distance_x);
444 }
445 }
446 None
447 }
448 LaidOutText::Line(line) => {
449 let distance_y = position.y() - origin.y();
450 if distance_y < 0. || distance_y > line.height() {
451 return None;
452 }
453 line.index_for_x(distance_x)
454 }
455 _ => None,
456 }
457 }
458 
459 // Returns the bounding box of the character at the given index.
460 fn get_char_bounding_box(&self, char_index: usize) -> Option<RectF> {
461 let origin = self.origin?.xy();
462 match &self.laid_out_text {
463 LaidOutText::None => None,
464 LaidOutText::Line(line) => {
465 let glyph_width = line.width_for_index(char_index)?;
466 let relative_x = line.x_for_index(char_index);
467 Some(RectF::new(
468 origin + vec2f(relative_x, 0.),
469 vec2f(glyph_width, line.height()),
470 ))
471 }
472 LaidOutText::Frame(frame) => {
473 let mut relative_y = 0.;
474 for line in frame.lines() {
475 let Some(glyph_width) = line.width_for_index(char_index) else {
476 relative_y += line.height();
477 continue;
478 };
479 let relative_x = line.x_for_index(char_index);
480 return Some(RectF::new(
481 origin + vec2f(relative_x, relative_y),
482 vec2f(glyph_width, line.height()),
483 ));
484 }
485 None
486 }
487 }
488 }
489 
490 fn handle_mouse_moved(
491 &mut self,
492 mouse_pos: &Vector2F,
493 ctx: &mut EventContext,
494 app: &AppContext,
495 ) -> bool {
496 let is_covered = ctx.is_covered(Point::from_vec2f(
497 *mouse_pos,
498 self.z_index()
499 .expect("z index should be set before dispatching"),
500 ));
501 let mut handled = false;
502 let hovered_char_index = self.get_char_index(mouse_pos);
503 let Some(z_index) = self.z_index() else {
504 return false;
505 };
506 // Note that these are all char indices!
507 self.hover_handlers.iter_mut().for_each(|hoverable_range| {
508 let was_hovered = hoverable_range.mouse_state().is_hovered();
509 let is_hovered = !is_covered
510 && hovered_char_index.is_some_and(|hovered_char_index| {
511 hoverable_range.char_range.contains(&hovered_char_index)
512 });
513 if is_hovered != was_hovered {
514 hoverable_range.mouse_state().is_hovered = is_hovered;
515 let handler = hoverable_range.hover_handler.as_mut();
516 handler(is_hovered, ctx, app);
517 handled = true;
518 if let Some(cursor_on_hover) = hoverable_range.cursor_on_hover {
519 if is_hovered {
520 ctx.set_cursor(cursor_on_hover, z_index)
521 } else {
522 ctx.reset_cursor()
523 }
524 }
525 }
526 });
527 handled
528 }
529 
530 // Autosize the text so that it fits within the bounds if the text doesn't fit
531 // with the given font size. If the text does not fit with the minimum_font_size,
532 // the text is not rendered.
533 pub fn autosize_text(mut self, minimum_font_size: f32) -> Self {
534 self.autosize_text = Some(minimum_font_size);
535 self
536 }
537 
538 pub fn add_text_with_highlights(
539 &mut self,
540 text: impl AsRef<str>,
541 color: ColorU,
542 font_properties: Properties,
543 ) {
544 let text = text.as_ref();
545 let text_len = self.text.chars().count();
546 
547 self.styles.push(Styles {
548 indices: (text_len..text_len + text.chars().count()).collect_vec(),
549 font_properties,
550 styles: TextStyle::new().with_foreground_color(color),
551 });
552 
553 match &mut self.text {
554 Cow::Borrowed(inner) => {
555 let mut temp = inner.to_owned();
556 temp.push_str(text);
557 self.text = temp.into();
558 }
559 Cow::Owned(inner) => inner.push_str(text),
560 }
561 }
562 
563 pub fn with_style(mut self, font_properties: Properties) -> Self {
564 self.font_properties = font_properties;
565 self
566 }
567 
568 /// Whether to soft wrap the text. If the text is soft-wrapped it will wrap onto a new line
569 /// if there is not enough horizontal space to fit the text. If not soft-wrapped, the text will
570 /// be positioned as if there were unlimited horizontal space.
571 pub fn soft_wrap(mut self, soft_wrap: bool) -> Self {
572 self.soft_wrap = soft_wrap;
573 self
574 }
575 
576 pub fn text(&self) -> &str {
577 &self.text
578 }
579 
580 fn line_height(&self) -> f32 {
581 self.font_size * self.line_height_ratio
582 }
583 
584 /// Determines rendering boundaries for drawing the given selection.
585 /// Assumes that [`selection_start`] comes before [`selection_end`].
586 fn calculate_selection_bounds(
587 &self,
588 content_origin: Vector2F,
589 selection_start: TextSelectionBound,
590 selection_end: TextSelectionBound,
591 ) -> Vec<RectF> {
592 let is_selection_empty = selection_start == selection_end;
593 if is_selection_empty {
594 return vec![];
595 }
596 
597 let line_height = self.line_height();
598 let mut selection_bounds = Vec::new();
599 
600 for row in selection_start.row..=selection_end.row {
601 let line = match &self.laid_out_text {
602 LaidOutText::None => return selection_bounds,
603 LaidOutText::Line(line) => Some(line.borrow()),
604 LaidOutText::Frame(frame) => frame.lines().get(row),
605 };
606 
607 let Some(line) = line else {
608 return selection_bounds;
609 };
610 
611 let start_x = if row == selection_start.row {
612 line.x_for_index(selection_start.glyph_index)
613 } else {
614 0.
615 };
616 let end_x = if row == selection_end.row {
617 line.x_for_index(selection_end.glyph_index)
618 } else {
619 line.width
620 };
621 
622 let rect_origin = content_origin + vec2f(start_x, row as f32 * line_height);
623 let rect_size = vec2f(end_x - start_x, line_height);
624 selection_bounds.push(RectF::new(rect_origin, rect_size));
625 
626 let is_last_line = match &self.laid_out_text {
627 LaidOutText::Line(_) => row == 0,
628 LaidOutText::Frame(frame) => row == frame.lines().len() - 1,
629 LaidOutText::None => true,
630 };
631 let selection_crosses_newline = selection_crosses_newline_row_based(
632 row,
633 is_last_line,
634 selection_start.row,
635 selection_end.row,
636 selection_end.glyph_index,
637 line.last_index(),
638 );
639 if selection_crosses_newline {
640 let tick_width = calculate_tick_width(self.font_size);
641 let tick_origin = content_origin + vec2f(line.width, row as f32 * line_height);
642 selection_bounds.push(create_newline_tick_rect(NewlineTickParams {
643 tick_origin,
644 tick_width,
645 tick_height: line_height,
646 }));
647 }
648 }
649 selection_bounds
650 }
651 
652 /// Assumes that [`selection_start`] comes before [`selection_end`].
653 fn draw_selection(
654 &self,
655 content_origin: Vector2F,
656 selection_start: TextSelectionBound,
657 selection_end: TextSelectionBound,
658 ctx: &mut PaintContext,
659 ) {
660 if !self.is_selectable {
661 return;
662 }
663 
664 #[cfg(debug_assertions)]
665 ctx.scene
666 .set_location_for_panic_logging(self.constructor_location);
667 
668 for rect in self.calculate_selection_bounds(content_origin, selection_start, selection_end)
669 {
670 ctx.scene
671 .draw_rect_without_hit_recording(rect)
672 .with_background(Fill::Solid(self.text_selection_color));
673 }
674 }
675 
676 fn y_bound_to_row_index(&self, y_bound: f32) -> usize {
677 (y_bound / (self.line_height_ratio * self.font_size)) as usize
678 }
679 
680 /// Guarantees that any returned ranges are non-inverted (i.e. start before end)
681 fn calculate_point_ranges(
682 &self,
683 current_selection: Option<Selection>,
684 ) -> Option<Vec<(TextSelectionBound, TextSelectionBound)>> {
685 let current_selection = current_selection?;
686 let is_rect = current_selection.is_rect;
687 let mut point_ranges = match is_rect {
688 IsRect::False => {
689 let start_point = self.position_for_point(current_selection.start);
690 let end_point = self.position_for_point(current_selection.end);
691 start_point.zip(end_point).map(|tuple| vec![tuple])
692 }
693 IsRect::True => self.calculate_row_bounds_for_rect_selection(
694 current_selection.start,
695 current_selection.end,
696 ),
697 };
698 
699 if let Some(ranges) = &mut point_ranges {
700 for (start_point, end_point) in ranges {
701 if end_point.glyph_index < start_point.glyph_index {
702 swap(start_point, end_point);
703 }
704 }
705 }
706 point_ranges
707 }
708 
709 /// Given an absolute start and end position, calculate the bounds for each row in the text
710 /// element for rect selection.
711 fn calculate_row_bounds_for_rect_selection(
712 &self,
713 start: Vector2F,
714 end: Vector2F,
715 ) -> Option<Vec<(TextSelectionBound, TextSelectionBound)>> {
716 let origin = self.origin()?;
717 let size = self.size()?;
718 
719 let relative_start = start - origin.xy;
720 let relative_end = end - origin.xy;
721 
722 // Early return if the selection range does not overlap with the element bounds.
723 if relative_end.y() < 0. || relative_start.y() > size.y() {
724 return None;
725 }
726 
727 match &self.laid_out_text {
728 LaidOutText::None => None,
729 LaidOutText::Line(line) => {
730 let start_bound = TextSelectionBound {
731 row: 0,
732 glyph_index: line.caret_index_for_x_unbounded(relative_start.x()),
733 };
734 let end_bound = TextSelectionBound {
735 row: 0,
736 glyph_index: line.caret_index_for_x_unbounded(relative_end.x()),
737 };
738 Some(vec![(start_bound, end_bound)])
739 }
740 LaidOutText::Frame(frame) => {
741 let start_index = self.y_bound_to_row_index(relative_start.y());
742 let end_index = self.y_bound_to_row_index(relative_end.y());
743 
744 let mut rows = Vec::new();
745 let lines = frame.lines();
746 
747 // Construct the start and end text selection bounds for each row.
748 for index in start_index..=end_index {
749 let bounds = lines.get(index).map(|line| {
750 let start_bound = TextSelectionBound {
751 row: index,
752 glyph_index: line.caret_index_for_x_unbounded(relative_start.x()),
753 };
754 let end_bound = TextSelectionBound {
755 row: index,
756 glyph_index: line.caret_index_for_x_unbounded(relative_end.x()),
757 };
758 (start_bound, end_bound)
759 });
760 
761 match bounds {
762 Some(bounds) => rows.push(bounds),
763 None => break,
764 };
765 }
766 
767 Some(rows)
768 }
769 }
770 }
771 
772 /// Given an absolute point, returns the row and glyph index that makes the most sense for a
773 /// caret position. For example, with example text "here is example text",
774 /// if the mouse point is over |i|, the index could be either 5 or 6
775 /// depending on which side of the |i| the mouse is closest to.
776 /// This snaps the point to somewhere in bounds.
777 fn position_for_point(&self, absolute_point: Vector2F) -> Option<TextSelectionBound> {
778 let (Some(origin), Some(size)) = (self.origin(), self.size()) else {
779 return None;
780 };
781 
782 let relative_point = absolute_point - origin.xy;
783 
784 // Snap to the first or last character if we are above or below the text
785 if relative_point.y() < 0. {
786 (!matches!(&self.laid_out_text, LaidOutText::None)).then_some(TextSelectionBound {
787 row: 0,
788 glyph_index: 0,
789 })
790 } else if relative_point.y() > size.y() {
791 let row_and_glyph_index = match &self.laid_out_text {
792 LaidOutText::None => None,
793 LaidOutText::Line(line) => Some((0usize, line.end_index())),
794 LaidOutText::Frame(frame) => frame
795 .lines()
796 .last()
797 .map(|line| (frame.lines().len() - 1usize, line.end_index())),
798 };
799 row_and_glyph_index.map(|(row, glyph_index)| TextSelectionBound { row, glyph_index })
800 } else {
801 let (row_index, line) = match &self.laid_out_text {
802 LaidOutText::None => None,
803 LaidOutText::Line(line) => Some((0, line.borrow())),
804 LaidOutText::Frame(frame) => {
805 let row_index = self.y_bound_to_row_index(relative_point.y());
806 frame.lines().get(row_index).map(|line| (row_index, line))
807 }
808 }?;
809 
810 Some(TextSelectionBound {
811 row: row_index,
812 glyph_index: line.caret_index_for_x_unbounded(relative_point.x()),
813 })
814 }
815 }
816 
817 /// Converts the text selection bound to the start of the line if line_start is true,
818 /// and the end of the line otherwise.
819 fn translate_selection_bound_to_line_bound(
820 &self,
821 bound: TextSelectionBound,
822 line_start: bool,
823 ) -> Option<Vector2F> {
824 let line_height = self.line_height();
825 let origin = self.origin()?;
826 let line = match &self.laid_out_text {
827 LaidOutText::None => return None,
828 LaidOutText::Line(line) => Some(line.borrow()),
829 LaidOutText::Frame(frame) => frame.lines().get(bound.row),
830 }?;
831 
832 let relative_x = if line_start { 0. } else { line.width };
833 
834 Some(origin.xy + vec2f(relative_x, bound.row as f32 * line_height))
835 }
836 
837 pub fn with_selectable(mut self, is_selectable: bool) -> Self {
838 self.is_selectable = is_selectable;
839 self
840 }
841 
842 /// Return the substring of text from start_glyph_index to end_glyph_index (end exclusive).
843 fn text_for_index_range(
844 &self,
845 mut start_glyph_index: usize,
846 mut end_glyph_index: usize,
847 ) -> Option<&str> {
848 if start_glyph_index == end_glyph_index {
849 return None;
850 }
851 
852 if start_glyph_index > end_glyph_index {
853 // Ensure the start point is before the end point.
854 std::mem::swap(&mut start_glyph_index, &mut end_glyph_index);
855 }
856 
857 let text = self.text();
858 // Convert the `TextSelectionBound`'s glyph_index's to byte indices to slice the text by
859 // byte offsets. glyph_index is the index of the character in the string, while byte index
860 // is in terms of bytes, which matters since UTF-8 glyphs have variable byte widths (for
861 // instance a simple ASCII char is a single byte while an emoji is multiple bytes).
862 let (start_byte_index, _) = text.char_indices().nth(start_glyph_index)?;
863 if end_glyph_index == text.char_indices().count() {
864 Some(&text[start_byte_index..])
865 } else {
866 let end_byte_index = text
867 .char_indices()
868 .nth(end_glyph_index)
869 .map(|(offset, _)| offset)?;
870 Some(&text[start_byte_index..end_byte_index])
871 }
872 }
873}
874 
875impl Element for Text {
876 fn layout(
877 &mut self,
878 constraint: SizeConstraint,
879 ctx: &mut LayoutContext,
880 app: &AppContext,
881 ) -> Vector2F {
882 let text_len = self.text.chars().count();
883 let mut styles = vec![];
884 let mut prev_index = 0;
885 
886 for style in &self.styles {
887 let mut pending_style: Option<(Range<usize>, TextStyle)> = None;
888 
889 for ix in &style.indices {
890 if let Some((style_range, text_style)) = pending_style.as_mut() {
891 // Extend the range if the current index is consecutive from the last.
892 if *ix == style_range.end {
893 style_range.end += 1;
894 } else {
895 // If the current index is not consecutive from the last,
896 // we push the pending highlight to styles and colors and fill
897 // the gap with default font id and color.
898 styles.push((
899 style_range.clone(),
900 StyleAndFont::new(self.family_id, style.font_properties, *text_style),
901 ));
902 styles.push((
903 style_range.end..*ix,
904 StyleAndFont::new(
905 self.family_id,
906 self.font_properties,
907 TextStyle::new(),
908 ),
909 ));
910 *style_range = *ix..*ix + 1;
911 }
912 } else {
913 // Fill the gap between highlights with default font id and color.
914 styles.push((
915 prev_index..*ix,
916 StyleAndFont::new(self.family_id, self.font_properties, TextStyle::new()),
917 ));
918 pending_style = Some((*ix..*ix + 1, style.styles));
919 }
920 prev_index = *ix + 1;
921 }
922 
923 if let Some((style_range, text_style)) = pending_style.as_mut() {
924 styles.push((
925 style_range.clone(),
926 StyleAndFont::new(self.family_id, style.font_properties, *text_style),
927 ));
928 } else {
929 styles.push((
930 0..prev_index,
931 StyleAndFont::new(self.family_id, self.font_properties, TextStyle::new()),
932 ));
933 }
934 }
935 
936 if text_len > prev_index {
937 styles.push((
938 prev_index..text_len,
939 StyleAndFont::new(self.family_id, self.font_properties, TextStyle::new()),
940 ));
941 }
942 
943 let max_width = constraint.max_along(Axis::Horizontal);
944 let max_height = constraint.max_along(Axis::Vertical);
945 
946 let text_frame = if self.soft_wrap {
947 let frame = ctx.text_layout_cache.layout_text(
948 &self.text,
949 LineStyle {
950 font_size: self.font_size,
951 line_height_ratio: self.line_height_ratio,
952 baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
953 fixed_width_tab_size: None,
954 },
955 styles.as_slice(),
956 max_width,
957 max_height,
958 Default::default(),
959 None,
960 &app.font_cache().text_layout_system(),
961 );
962 LaidOutText::Frame(frame)
963 } else {
964 let single_line = ctx.text_layout_cache.layout_line(
965 &self.text,
966 LineStyle {
967 font_size: self.font_size,
968 line_height_ratio: self.line_height_ratio,
969 baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
970 fixed_width_tab_size: None,
971 },
972 styles.as_slice(),
973 max_width,
974 self.clip_config,
975 &app.font_cache().text_layout_system(),
976 );
977 
978 // Don't render the line if it won't fit.
979 // Here we are using EPSILON from pathfinder because the margin of error
980 // in the floating point calculation in pathfinder is larger than standard
981 // f32. For example, adding 15.756032 to 12 in Vector2F will result in 27.756031
982 // -- a 1e-6 error which is larger than f32::EPSILON (1e-7). The reason for
983 // this problem in pathfinder could be because internally Vector2F is representing
984 // f32 using u64 instead of the native type.
985 if (single_line.height() - max_height) > EPSILON {
986 let mut state = LaidOutText::None;
987 
988 if let Some(minimum_size) = self.autosize_text {
989 let mut size = minimum_size;
990 while size < self.font_size {
991 let line = ctx.text_layout_cache.layout_line(
992 &self.text,
993 LineStyle {
994 font_size: size,
995 line_height_ratio: self.line_height_ratio,
996 baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
997 fixed_width_tab_size: None,
998 },
999 styles.as_slice(),
1000 max_width,
1001 self.clip_config,
1002 &app.font_cache().text_layout_system(),
1003 );
1004 
1005 if (line.height() - max_height) > EPSILON {
1006 break;
1007 } else {
1008 state = LaidOutText::Line(line);
1009 }
1010 
1011 size += 1.;
1012 }
1013 }
1014 
1015 state
1016 } else {
1017 LaidOutText::Line(single_line)
1018 }
1019 };
1020 
1021 let size = vec2f(
1022 text_frame
1023 .width()
1024 .max(constraint.min.x())
1025 .min(constraint.max.x()),
1026 text_frame.height(),
1027 );
1028 
1029 self.laid_out_text = text_frame;
1030 self.size = Some(size);
1031 size
1032 }
1033 
1034 fn after_layout(&mut self, _: &mut AfterLayoutContext, _: &AppContext) {}
1035 
1036 fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
1037 self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
1038 let bounds = RectF::from_points(
1039 origin,
1040 origin
1041 + self
1042 .size()
1043 .expect("layout() should have been called before paint()"),
1044 );
1045 for saved_char_position in &self.saved_char_positions {
1046 let Some(char_bounding_box) =
1047 self.get_char_bounding_box(saved_char_position.char_index)
1048 else {
1049 continue;
1050 };
1051 ctx.position_cache.cache_position_indefinitely(
1052 saved_char_position.position_id.clone(),
1053 char_bounding_box,
1054 );
1055 }
1056 self.laid_out_text.paint(
1057 bounds,
1058 self.text_color,
1059 ctx.scene,
1060 app.font_cache(),
1061 self.compute_baseline_position_fn.as_ref(),
1062 );
1063 
1064 if let Some(ranges) = self.calculate_point_ranges(ctx.current_selection) {
1065 for (start_point, end_point) in ranges {
1066 self.draw_selection(origin, start_point, end_point, ctx);
1067 }
1068 }
1069 }
1070 
1071 fn size(&self) -> Option<Vector2F> {
1072 self.size
1073 }
1074 
1075 fn dispatch_event(
1076 &mut self,
1077 event: &DispatchedEvent,
1078 ctx: &mut EventContext,
1079 app: &AppContext,
1080 ) -> bool {
1081 let Some(z_index) = self.z_index() else {
1082 return false;
1083 };
1084 
1085 match event.at_z_index(z_index, ctx) {
1086 Some(Event::LeftMouseDown {
1087 position,
1088 modifiers,
1089 click_count,
1090 ..
1091 }) => {
1092 if *click_count == 1 {
1093 self.handle_mouse_down(position, modifiers, ctx, app);
1094 }
1095 }
1096 Some(Event::MouseMoved { position, .. }) => {
1097 self.handle_mouse_moved(position, ctx, app);
1098 }
1099 Some(Event::LeftMouseDragged { position, .. }) => {
1100 self.handle_mouse_moved(position, ctx, app);
1101 }
1102 _ => (),
1103 };
1104 
1105 // Always propagate events to parent, since for now this is the only behavior we want.
1106 // We may need to make this configurable in the future.
1107 false
1108 }
1109 
1110 fn origin(&self) -> Option<Point> {
1111 self.origin
1112 }
1113 
1114 fn as_selectable_element(&self) -> Option<&dyn SelectableElement> {
1115 if self.is_selectable {
1116 Some(self as &dyn SelectableElement)
1117 } else {
1118 None
1119 }
1120 }
1121 
1122 #[cfg(any(test, feature = "test-util"))]
1123 fn debug_text_content(&self) -> Option<String> {
1124 Some(self.text.to_string())
1125 }
1126}
1127 
1128impl SelectableElement for Text {
1129 fn get_selection(
1130 &self,
1131 selection_start: Vector2F,
1132 selection_end: Vector2F,
1133 is_rect: IsRect,
1134 ) -> Option<Vec<SelectionFragment>> {
1135 let text = match is_rect {
1136 // If the active selection is not a rect selection, directly return the substring of the text from selection_start
1137 // to selection_end.
1138 IsRect::False => {
1139 let start_glyph_index = self.position_for_point(selection_start)?.glyph_index;
1140 let end_glyph_index = self.position_for_point(selection_end)?.glyph_index;
1141 
1142 self.text_for_index_range(start_glyph_index, end_glyph_index)?
1143 .to_owned()
1144 }
1145 // If the active selection is a rect selection, first calculate the rows covered by this selection. Then for each
1146 // selection, find its corresponding substring. They are then concatenated together in the end.
1147 // Note that since we are already joining each fragment with \n, we should strip away any active trailing newline
1148 // in each text fragment.
1149 IsRect::True => {
1150 let selection_bounds =
1151 self.calculate_row_bounds_for_rect_selection(selection_start, selection_end)?;
1152 selection_bounds
1153 .into_iter()
1154 .filter_map(|(start, end)| {
1155 self.text_for_index_range(start.glyph_index, end.glyph_index)
1156 .map(|s| s.trim_end_matches('\n'))
1157 })
1158 .join("\n")
1159 }
1160 };
1161 
1162 Some(vec![SelectionFragment {
1163 text,
1164 origin: self.origin?,
1165 }])
1166 }
1167 
1168 fn expand_selection(
1169 &self,
1170 absolute_point: Vector2F,
1171 direction: SelectionDirection,
1172 unit: SelectionType,
1173 word_boundaries_policy: &WordBoundariesPolicy,
1174 ) -> Option<Vector2F> {
1175 // We never need to do expansion for Char units.
1176 if matches!(unit, SelectionType::Simple | SelectionType::Rect) {
1177 return None;
1178 }
1179 let bound = self.bounds()?;
1180 // If we're above the bound and expanding backward, expand to the origin.
1181 // This is needed to render the selection to the beginning of the element
1182 // if the mouse goes above the element.
1183 if absolute_point.y() < bound.min_y() {
1184 return match direction {
1185 SelectionDirection::Backward => Some(bound.origin()),
1186 SelectionDirection::Forward => None,
1187 };
1188 }
1189 // If we're below the bound and expanding forward, we really want
1190 // to expand to the lower right corner of the bound,
1191 // but selection rendering is not inclusive of the lower right corner,
1192 // so we return the original point below the bound instead.
1193 if absolute_point.y() > bound.max_y() {
1194 return match direction {
1195 SelectionDirection::Backward => None,
1196 SelectionDirection::Forward => Some(absolute_point),
1197 };
1198 }
1199 // If we're outside the x bounds, return None. This prevents cells in the same row
1200 // (which share Y bounds) from all responding to a double-click meant for one cell.
1201 if absolute_point.x() < bound.min_x() || absolute_point.x() > bound.max_x() {
1202 return None;
1203 }
1204 match unit {
1205 SelectionType::Simple | SelectionType::Rect => None,
1206 SelectionType::Semantic => {
1207 let text = self.text();
1208 // TODO (roland): if the mouse is over a character, position_for_point could put us to the left or
1209 // right of the character depending which side it's closer to. For semantic selections, if we're expanding to start
1210 // we always want it to the right of the character, and if we're expanding to the end we want it on the left.
1211 // For instance, given some text "first |m|iddl|e| second",
1212 // if we double click anywhere on "m" or "e", we should expand to select "middle".
1213 // Currently if we double click on the left side of "m", all of "first middle" would be selected,
1214 // and if we double click on the right side of "e" all of "middle second" would be selected.
1215 let text_selection_bound = self.position_for_point(absolute_point)?;
1216 let inner_point = if matches!(direction, SelectionDirection::Backward) {
1217 text.word_starts_backward_from_offset_exclusive(CharOffset::from(
1218 text_selection_bound.glyph_index,
1219 ))
1220 .ok()?
1221 .with_policy(word_boundaries_policy)
1222 .next()?
1223 } else {
1224 text.word_ends_from_offset_exclusive(CharOffset::from(
1225 text_selection_bound.glyph_index,
1226 ))
1227 .ok()?
1228 .with_policy(word_boundaries_policy)
1229 .next()?
1230 };
1231 
1232 let offset = text.to_offset(inner_point).ok()?.as_usize();
1233 let origin = self.origin()?.xy;
1234 match &self.laid_out_text {
1235 LaidOutText::None => None,
1236 LaidOutText::Line(line) => {
1237 let relative_x = line.x_for_index(offset);
1238 Some(vec2f(origin.x() + relative_x, absolute_point.y()))
1239 }
1240 LaidOutText::Frame(frame) => {
1241 // Get row that we initially clicked on. Semantic selection should only
1242 // expand within this row.
1243 // We need to do this because the same char offset in the raw text buffer
1244 // could be either the end of one line or the start of the next line.
1245 let line = frame.lines().get(text_selection_bound.row)?;
1246 let first_glyph = line.first_glyph()?;
1247 let last_glyph = line.last_glyph()?;
1248 
1249 // If we're expanding to the end of the line, we can have offset == last_glyph.index + 1.
1250 let relative_x =
1251 if first_glyph.index <= offset && offset <= last_glyph.index + 1 {
1252 line.x_for_index(offset)
1253 } else if matches!(direction, SelectionDirection::Backward) {
1254 0.
1255 } else {
1256 line.width
1257 };
1258 Some(vec2f(origin.x() + relative_x, absolute_point.y()))
1259 }
1260 }
1261 }
1262 SelectionType::Lines => {
1263 let text_selection_bound = self.position_for_point(absolute_point)?;
1264 let mut snap_to = self.translate_selection_bound_to_line_bound(
1265 text_selection_bound,
1266 matches!(direction, SelectionDirection::Backward),
1267 )?;
1268 snap_to.set_y(absolute_point.y());
1269 Some(snap_to)
1270 }
1271 }
1272 }
1273 
1274 fn is_point_semantically_before(
1275 &self,
1276 absolute_point_1: Vector2F,
1277 absolute_point_2: Vector2F,
1278 ) -> Option<bool> {
1279 let bounds = self.bounds()?;
1280 match (
1281 bounds.contains_point(absolute_point_1),
1282 bounds.contains_point(absolute_point_2),
1283 ) {
1284 // If neither point is within the bounds, we don't have enough information
1285 // to tell which point is semantically before the other. This is because y value
1286 // alone is not sufficient, since a range of y values can map to the same line.
1287 (false, false) => None,
1288 (false, true) => {
1289 if absolute_point_1.y() < bounds.min_y() {
1290 return Some(true);
1291 } else if absolute_point_1.y() > bounds.max_y() {
1292 return Some(false);
1293 }
1294 Some(absolute_point_1.x() < bounds.min_x())
1295 }
1296 (true, false) => {
1297 if absolute_point_2.y() < bounds.min_y() {
1298 return Some(false);
1299 } else if absolute_point_2.y() > bounds.max_y() {
1300 return Some(true);
1301 }
1302 Some(absolute_point_2.x() > bounds.max_x())
1303 }
1304 (true, true) => {
1305 let point_1 = self.position_for_point(absolute_point_1)?;
1306 let point_2 = self.position_for_point(absolute_point_2)?;
1307 Some(
1308 point_1.row < point_2.row
1309 || (point_1.row == point_2.row
1310 && point_1.glyph_index < point_2.glyph_index)
1311 || (point_1.row == point_2.row
1312 && point_1.glyph_index == point_2.glyph_index
1313 && absolute_point_1.x() < absolute_point_2.x()),
1314 )
1315 }
1316 }
1317 }
1318 
1319 fn smart_select(
1320 &self,
1321 absolute_point: Vector2F,
1322 smart_select_fn: super::SmartSelectFn,
1323 ) -> Option<(Vector2F, Vector2F)> {
1324 let bound = self.bounds()?;
1325 if bound.min_y() > absolute_point.y() || absolute_point.y() > bound.max_y() {
1326 return None;
1327 }
1328 if bound.min_x() > absolute_point.x() || absolute_point.x() > bound.max_x() {
1329 return None;
1330 }
1331 let origin = self.origin()?.xy;
1332 let text = self.text();
1333 let text_selection_bound = self.position_for_point(absolute_point)?;
1334 match &self.laid_out_text {
1335 LaidOutText::None => None,
1336 LaidOutText::Line(line) => {
1337 let char_offset = text_selection_bound.glyph_index;
1338 let byte_offset = text.char_indices().nth(char_offset)?.0.into();
1339 
1340 let smart_select_range = smart_select_fn(text, byte_offset)?;
1341 // convert to glyph (char) index
1342 let smart_select_start =
1343 text[..smart_select_range.start.as_usize()].chars().count();
1344 let smart_select_end = text[..smart_select_range.end.as_usize()].chars().count();
1345 
1346 let start_relative_x = line.x_for_index(smart_select_start);
1347 let end_relative_x = line.x_for_index(smart_select_end);
1348 Some((
1349 vec2f(origin.x() + start_relative_x, absolute_point.y()),
1350 vec2f(origin.x() + end_relative_x, absolute_point.y()),
1351 ))
1352 }
1353 LaidOutText::Frame(frame) => {
1354 // Get row that we initially clicked on.
1355 // We need to do this because the same char offset in the raw text buffer
1356 // could be either the end of one line or the start of the next line.
1357 let line = frame.lines().get(text_selection_bound.row)?;
1358 let first_glyph = line.first_glyph()?;
1359 let last_glyph = line.last_glyph()?;
1360 // If we clicked to the right of a line, the text_selection_bound's glyph index would be one larger than the last glyph's index.
1361 // Snap it within the line's index range so we do smart selection as if we clicked on the last glyph in the line.
1362 let char_offset = text_selection_bound
1363 .glyph_index
1364 .min(last_glyph.index)
1365 .max(first_glyph.index);
1366 let byte_offset = text.char_indices().nth(char_offset)?.0.into();
1367 
1368 let smart_select_range = smart_select_fn(text, byte_offset)?;
1369 // convert to glyph (char) index
1370 let smart_select_start =
1371 text[..smart_select_range.start.as_usize()].chars().count();
1372 let smart_select_end = text[..smart_select_range.end.as_usize()].chars().count();
1373 // After smart selection, the start and end offsets can be on different lines if a word wrapped.
1374 // Find the lines for the start and end offsets.
1375 let start_row = frame.row_within_frame(smart_select_start, false);
1376 let end_row = frame.row_within_frame(smart_select_end, true);
1377 let start_line = frame.lines().get(start_row)?;
1378 let end_line = frame.lines().get(end_row)?;
1379 
1380 let start_relative_x = start_line.x_for_index(smart_select_start);
1381 let end_relative_x = end_line.x_for_index(smart_select_end);
1382 // We subtract half the line's height to put the position in the center of the line.
1383 let start_relative_y = frame.height_up_to_row(start_row) - start_line.height() / 2.;
1384 let end_relative_y = frame.height_up_to_row(end_row) - end_line.height() / 2.;
1385 Some((
1386 vec2f(origin.x() + start_relative_x, origin.y() + start_relative_y),
1387 vec2f(origin.x() + end_relative_x, origin.y() + end_relative_y),
1388 ))
1389 }
1390 }
1391 }
1392 
1393 fn calculate_clickable_bounds(&self, current_selection: Option<Selection>) -> Vec<RectF> {
1394 let Some(content_origin) = self.origin else {
1395 return vec![];
1396 };
1397 
1398 self.calculate_point_ranges(current_selection)
1399 .unwrap_or_default()
1400 .iter()
1401 .flat_map(|(start_point, end_point)| {
1402 self.calculate_selection_bounds(content_origin.xy(), *start_point, *end_point)
1403 })
1404 .collect()
1405 }
1406}
1407 
1408impl PartialClickableElement for Text {
1409 fn with_clickable_char_range<F>(
1410 mut self,
1411 clickable_char_range: Range<usize>,
1412 callback: F,
1413 ) -> Self
1414 where
1415 F: 'static + FnMut(&ModifiersState, &mut EventContext, &AppContext),
1416 {
1417 self.click_handlers.push(ClickableCharRange {
1418 char_range: clickable_char_range,
1419 click_handler: Box::new(callback),
1420 });
1421 self
1422 }
1423 
1424 fn with_hoverable_char_range<F>(
1425 mut self,
1426 hoverable_char_range: Range<usize>,
1427 mouse_state: MouseStateHandle,
1428 cursor_on_hover: Option<Cursor>,
1429 callback: F,
1430 ) -> Self
1431 where
1432 F: 'static + FnMut(bool, &mut EventContext, &AppContext),
1433 {
1434 self.hover_handlers.push(HoverableCharRange {
1435 char_range: hoverable_char_range,
1436 hover_handler: Box::new(callback),
1437 cursor_on_hover,
1438 mouse_state,
1439 });
1440 self
1441 }
1442 
1443 fn replace_text_range(&mut self, range: SecretRange, replacement: Cow<'static, str>) {
1444 self.replace_byte_range(range.byte_range, &replacement);
1445 }
1446}
1447 
1448#[cfg(test)]
1449#[path = "text_test.rs"]
1450mod tests;
1451