StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.
| 1 | use 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 | |
| 8 | use crate::event::ModifiersState; |
| 9 | use crate::platform::{Cursor, LineStyle}; |
| 10 | use crate::text::word_boundaries::WordBoundariesPolicy; |
| 11 | use crate::text::{IsRect, SelectionDirection, SelectionType, TextBuffer}; |
| 12 | use crate::text_layout::{ |
| 13 | ClipConfig, ComputeBaselinePositionFn, Line, StyleAndFont, TextFrame, TextStyle, |
| 14 | DEFAULT_TOP_BOTTOM_RATIO, |
| 15 | }; |
| 16 | use crate::text_offsets::CharOffset; |
| 17 | use crate::text_selection_utils::{ |
| 18 | calculate_tick_width, create_newline_tick_rect, selection_crosses_newline_row_based, |
| 19 | NewlineTickParams, |
| 20 | }; |
| 21 | use crate::Event; |
| 22 | use crate::{ |
| 23 | event::DispatchedEvent, |
| 24 | fonts::{Cache as FontCache, FamilyId, Properties}, |
| 25 | Scene, |
| 26 | }; |
| 27 | use itertools::Itertools; |
| 28 | use pathfinder_color::ColorU; |
| 29 | use pathfinder_geometry::util::EPSILON; |
| 30 | use pathfinder_geometry::vector::{vec2f, Vector2F}; |
| 31 | use std::borrow::Cow; |
| 32 | use std::mem::swap; |
| 33 | use std::{borrow::Borrow, ops::Range, sync::Arc}; |
| 34 | |
| 35 | pub const DEFAULT_UI_LINE_HEIGHT_RATIO: f32 = 1.2; |
| 36 | |
| 37 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd)] |
| 38 | pub struct TextSelectionBound { |
| 39 | row: usize, |
| 40 | glyph_index: usize, |
| 41 | } |
| 42 | |
| 43 | #[derive(Clone)] |
| 44 | enum 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 | |
| 51 | impl 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()] |
| 123 | pub 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. |
| 157 | struct SavedCharPositionIds { |
| 158 | char_index: usize, |
| 159 | position_id: String, |
| 160 | } |
| 161 | |
| 162 | pub struct Styles { |
| 163 | indices: Vec<usize>, |
| 164 | font_properties: Properties, |
| 165 | styles: TextStyle, |
| 166 | } |
| 167 | |
| 168 | #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] |
| 169 | pub struct Highlight { |
| 170 | properties: Properties, |
| 171 | pub(crate) text_style: TextStyle, |
| 172 | } |
| 173 | |
| 174 | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
| 175 | pub struct HighlightedRange { |
| 176 | pub highlight: Highlight, |
| 177 | pub highlight_indices: Vec<usize>, |
| 178 | } |
| 179 | |
| 180 | impl 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 | |
| 223 | impl 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 | |
| 255 | impl 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 | |
| 875 | impl 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 | |
| 1128 | impl 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 | |
| 1408 | impl 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"] |
| 1450 | mod tests; |
| 1451 |