StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.
| 1 | use super::{Highlight, ListNumbering, Selection}; |
| 2 | use crate::elements::{ |
| 3 | ClickableCharRange, CornerRadius, Fill, HighlightedRange, HoverableCharRange, MouseStateHandle, |
| 4 | PartialClickableElement, Radius, SecretRange, SelectableElement, SelectionFragment, |
| 5 | SmartSelectFn, ZIndex, SELECTED_HIGHLIGHT_COLOR, |
| 6 | }; |
| 7 | use crate::event::ModifiersState; |
| 8 | use crate::fonts::Weight; |
| 9 | use crate::formatted_text::{FormattedText, FormattedTextFragment, FormattedTextLine, Hyperlink}; |
| 10 | use crate::geometry::rect::RectF; |
| 11 | use crate::platform::Cursor; |
| 12 | use crate::text::word_boundaries::WordBoundariesPolicy; |
| 13 | use crate::text::{ |
| 14 | char_slice, count_chars_up_to_byte, BlockHeaderSize, IsRect, SelectionDirection, SelectionType, |
| 15 | TextBuffer, |
| 16 | }; |
| 17 | use crate::text_layout::{ClipConfig, TextAlignment, DEFAULT_TOP_BOTTOM_RATIO}; |
| 18 | use crate::text_offsets::{ByteOffset, CharOffset}; |
| 19 | use crate::Event; |
| 20 | use crate::{ |
| 21 | elements::{Axis, Point}, |
| 22 | event::DispatchedEvent, |
| 23 | fonts::{FamilyId, Properties, Style}, |
| 24 | platform::LineStyle, |
| 25 | text_layout::{StyleAndFont, TextFrame, TextStyle}, |
| 26 | AfterLayoutContext, AppContext, Element, EventContext, LayoutContext, PaintContext, |
| 27 | SizeConstraint, |
| 28 | }; |
| 29 | use itertools::Itertools; |
| 30 | use pathfinder_color::ColorU; |
| 31 | use pathfinder_geometry::vector::{vec2f, Vector2F}; |
| 32 | use std::borrow::Cow; |
| 33 | use std::cell::RefCell; |
| 34 | use std::cmp::Reverse; |
| 35 | use std::default::Default; |
| 36 | use std::ops::Range; |
| 37 | use std::rc::Rc; |
| 38 | use std::sync::Arc; |
| 39 | use std::sync::Mutex; |
| 40 | use std::sync::Once; |
| 41 | use vec1::vec1; |
| 42 | #[derive(Debug, Clone, PartialEq)] |
| 43 | pub struct HeadingFontSizeMultipliers { |
| 44 | pub h1: f32, |
| 45 | pub h2: f32, |
| 46 | pub h3: f32, |
| 47 | pub h4: f32, |
| 48 | pub h5: f32, |
| 49 | pub h6: f32, |
| 50 | } |
| 51 | |
| 52 | impl Default for HeadingFontSizeMultipliers { |
| 53 | fn default() -> Self { |
| 54 | Self { |
| 55 | h1: BlockHeaderSize::Header1.font_size_multiplication_ratio(), |
| 56 | h2: BlockHeaderSize::Header2.font_size_multiplication_ratio(), |
| 57 | h3: BlockHeaderSize::Header3.font_size_multiplication_ratio(), |
| 58 | h4: BlockHeaderSize::Header4.font_size_multiplication_ratio(), |
| 59 | h5: BlockHeaderSize::Header5.font_size_multiplication_ratio(), |
| 60 | h6: BlockHeaderSize::Header6.font_size_multiplication_ratio(), |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | impl HeadingFontSizeMultipliers { |
| 66 | pub fn get_multiplier(&self, heading_level: usize) -> f32 { |
| 67 | match heading_level { |
| 68 | 1 => self.h1, |
| 69 | 2 => self.h2, |
| 70 | 3 => self.h3, |
| 71 | 4 => self.h4, |
| 72 | 5 => self.h5, |
| 73 | 6 => self.h6, |
| 74 | _ => 1.0, // Default to normal font size for invalid heading levels |
| 75 | } |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | pub type HighlightedHyperlink = Arc<Mutex<Option<HyperlinkPosition>>>; |
| 80 | |
| 81 | const CODE_BLOCK_OFFSET: usize = 1; |
| 82 | |
| 83 | // TODO: We should think about whether line height applies to notebooks as well. |
| 84 | // Consider whether this element really needs a different default than DEFAULT_UI_LINE_HEIGHT_RATIO |
| 85 | // used by the Text element. |
| 86 | pub const DEFAULT_LINE_HEIGHT_RATIO: f32 = 1.4; |
| 87 | const FRAME_SPACER_HEIGHT: f32 = 4.; |
| 88 | const LINE_BREAK_HEIGHT: f32 = 13.; |
| 89 | |
| 90 | const FULL_BULLET: &str = "•"; |
| 91 | const EMPTY_BULLET: &str = "◦"; |
| 92 | const SQUARE_BULLET: &str = "▪"; |
| 93 | |
| 94 | // Background color for the code block. |
| 95 | const CODE_BLOCK_BACKGROUND: u32 = 0x00000055; |
| 96 | const DEFAULT_HYPERLINK_COLOR: u32 = 0x7aa6daff; |
| 97 | |
| 98 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 99 | pub struct HyperlinkUrl { |
| 100 | pub url: String, |
| 101 | } |
| 102 | |
| 103 | impl<'a> From<&'a Hyperlink> for HyperlinkLens<'a> { |
| 104 | fn from(url_or_action: &'a Hyperlink) -> Self { |
| 105 | match url_or_action { |
| 106 | Hyperlink::Url(url) => HyperlinkLens::Url(url.as_str()), |
| 107 | Hyperlink::Action(action) => HyperlinkLens::Action(action.as_ref()), |
| 108 | } |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | /// A lens into a formatted text hyperlink. |
| 113 | pub enum HyperlinkLens<'a> { |
| 114 | Url(&'a str), |
| 115 | Action(&'a dyn crate::Action), |
| 116 | } |
| 117 | |
| 118 | #[derive(Clone, Default, PartialEq, Eq, Debug)] |
| 119 | pub struct HyperlinkPosition { |
| 120 | frame_index: usize, |
| 121 | link_range: Range<usize>, |
| 122 | } |
| 123 | |
| 124 | struct HyperlinkSupport { |
| 125 | /// The highlighted hyperlink index. |
| 126 | highlighted_hyperlink: HighlightedHyperlink, |
| 127 | /// The highlighted hyperlink index. |
| 128 | hyperlink_font_color: ColorU, |
| 129 | } |
| 130 | |
| 131 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
| 132 | pub struct FormattedTextSelectionLocation { |
| 133 | pub frame_index: usize, |
| 134 | pub row_index: usize, |
| 135 | pub glyph_index: usize, |
| 136 | } |
| 137 | |
| 138 | enum SavedGlyphPosition { |
| 139 | FormattedTextLinePosition(FormattedTextSelectionLocation), |
| 140 | LaidOutTextFramePosition(FormattedTextSelectionLocation), |
| 141 | } |
| 142 | |
| 143 | struct SavedGlyphPositionIds { |
| 144 | position: SavedGlyphPosition, |
| 145 | position_id: String, |
| 146 | } |
| 147 | |
| 148 | pub struct FormattedTextElement { |
| 149 | formatted_text: Arc<FormattedText>, |
| 150 | family_id: FamilyId, |
| 151 | code_block_family_id: FamilyId, |
| 152 | font_size: f32, |
| 153 | line_height_ratio: f32, |
| 154 | heading_to_font_size_multipliers: HeadingFontSizeMultipliers, |
| 155 | size: Option<Vector2F>, |
| 156 | origin: Option<Point>, |
| 157 | text_color: ColorU, |
| 158 | text_selection_color: ColorU, |
| 159 | laid_out_text: Vec<LaidOutTextFrame>, |
| 160 | text_frame_mouse_handlers: Vec<Rc<RefCell<FrameMouseHandlers>>>, |
| 161 | alignment: TextAlignment, |
| 162 | inline_code_font_color: Option<ColorU>, |
| 163 | inline_code_bg_color: Option<ColorU>, |
| 164 | hyperlink_support: HyperlinkSupport, |
| 165 | saved_glyph_positions: Vec<SavedGlyphPositionIds>, |
| 166 | is_selectable: bool, |
| 167 | is_mouse_interaction_disabled: bool, |
| 168 | disable_text_wrapping: bool, |
| 169 | clip_config: Option<ClipConfig>, |
| 170 | #[cfg(debug_assertions)] |
| 171 | /// Captures the location of the constructor call site. This is used for debugging purposes. |
| 172 | constructor_location: Option<&'static std::panic::Location<'static>>, |
| 173 | } |
| 174 | |
| 175 | impl FormattedTextElement { |
| 176 | #[cfg_attr(debug_assertions, track_caller)] |
| 177 | fn internal_constructor( |
| 178 | formatted_text: Arc<FormattedText>, |
| 179 | font_size: f32, |
| 180 | family_id: FamilyId, |
| 181 | code_block_family_id: FamilyId, |
| 182 | text_color: ColorU, |
| 183 | text_selection_color: ColorU, |
| 184 | hyperlink_support: HyperlinkSupport, |
| 185 | ) -> Self { |
| 186 | Self { |
| 187 | formatted_text, |
| 188 | family_id, |
| 189 | code_block_family_id, |
| 190 | font_size, |
| 191 | line_height_ratio: DEFAULT_LINE_HEIGHT_RATIO, |
| 192 | heading_to_font_size_multipliers: HeadingFontSizeMultipliers::default(), |
| 193 | text_color, |
| 194 | text_selection_color, |
| 195 | size: None, |
| 196 | origin: None, |
| 197 | laid_out_text: vec![], |
| 198 | text_frame_mouse_handlers: vec![], |
| 199 | inline_code_font_color: None, |
| 200 | inline_code_bg_color: None, |
| 201 | alignment: Default::default(), |
| 202 | hyperlink_support, |
| 203 | saved_glyph_positions: vec![], |
| 204 | is_selectable: false, |
| 205 | is_mouse_interaction_disabled: false, |
| 206 | disable_text_wrapping: false, |
| 207 | clip_config: None, |
| 208 | #[cfg(debug_assertions)] |
| 209 | constructor_location: Some(std::panic::Location::caller()), |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | /// Creates a new FormattedTextElement. Allows multiple [FormattedTextLine]s to be passed in. |
| 214 | /// This enables features like in-line hyperlinks and code blocks. If this is not needed, |
| 215 | /// consider the simpler [FormattedTextElement::from_str] constructor. |
| 216 | #[cfg_attr(debug_assertions, track_caller)] |
| 217 | pub fn new( |
| 218 | formatted_text: FormattedText, |
| 219 | font_size: f32, |
| 220 | family_id: FamilyId, |
| 221 | code_block_family_id: FamilyId, |
| 222 | text_color: ColorU, |
| 223 | highlight_index: HighlightedHyperlink, |
| 224 | ) -> Self { |
| 225 | Self::new_arc( |
| 226 | Arc::new(formatted_text), |
| 227 | font_size, |
| 228 | family_id, |
| 229 | code_block_family_id, |
| 230 | text_color, |
| 231 | highlight_index, |
| 232 | ) |
| 233 | } |
| 234 | |
| 235 | /// Like [`FormattedTextElement::new`], but accepts an already-allocated [`Arc<FormattedText>`] |
| 236 | /// so callers that have a cached Arc can avoid an extra deep clone. |
| 237 | #[cfg_attr(debug_assertions, track_caller)] |
| 238 | pub fn new_arc( |
| 239 | formatted_text: Arc<FormattedText>, |
| 240 | font_size: f32, |
| 241 | family_id: FamilyId, |
| 242 | code_block_family_id: FamilyId, |
| 243 | text_color: ColorU, |
| 244 | highlight_index: HighlightedHyperlink, |
| 245 | ) -> Self { |
| 246 | Self::internal_constructor( |
| 247 | formatted_text, |
| 248 | font_size, |
| 249 | family_id, |
| 250 | code_block_family_id, |
| 251 | text_color, |
| 252 | *SELECTED_HIGHLIGHT_COLOR, |
| 253 | HyperlinkSupport { |
| 254 | highlighted_hyperlink: highlight_index, |
| 255 | hyperlink_font_color: ColorU::from_u32(DEFAULT_HYPERLINK_COLOR), |
| 256 | }, |
| 257 | ) |
| 258 | } |
| 259 | |
| 260 | /// Creates a new FormattedTextElement from a single `str`. Use this method similar to how you'd use |
| 261 | /// [Text::new], though currently FormattedTextElement is missing many features from `Text`. If you |
| 262 | /// find yourself needing multiple styles throughout the text body, or need to make use of hyperlinks, |
| 263 | /// consider using [FormattedTextElement::new] instead. The FormattedTextElement created will have mouse |
| 264 | /// interactions disabled by default |
| 265 | #[cfg_attr(debug_assertions, track_caller)] |
| 266 | pub fn from_str( |
| 267 | text: impl Into<Cow<'static, str>>, |
| 268 | family_id: FamilyId, |
| 269 | font_size: f32, |
| 270 | ) -> Self { |
| 271 | Self::internal_constructor( |
| 272 | Arc::new(FormattedText::new([FormattedTextLine::Line(vec![ |
| 273 | FormattedTextFragment::plain_text(text.into()), |
| 274 | ])])), |
| 275 | font_size, |
| 276 | family_id, |
| 277 | family_id, |
| 278 | ColorU::white(), |
| 279 | *SELECTED_HIGHLIGHT_COLOR, |
| 280 | HyperlinkSupport { |
| 281 | highlighted_hyperlink: Default::default(), |
| 282 | hyperlink_font_color: ColorU::from_u32(DEFAULT_HYPERLINK_COLOR), |
| 283 | }, |
| 284 | ) |
| 285 | .disable_mouse_interaction() |
| 286 | } |
| 287 | |
| 288 | /// TODO (roland): There are cases where we need to set the line height to the UI default |
| 289 | /// of 1.2 used by the Text element so that a FormattedTextElement and Text element in the same |
| 290 | /// row are laid out with the same behavior and occupy the same height. If the row has height restrictions, |
| 291 | /// the larger line height of FormattedTextElement can cause the text to not render due to CoreText |
| 292 | /// thinking there isn't enough vertical space. |
| 293 | /// Consider whether this element really needs a different default of 1.4. |
| 294 | pub fn with_line_height_ratio(mut self, line_height_ratio: f32) -> Self { |
| 295 | self.line_height_ratio = line_height_ratio; |
| 296 | self |
| 297 | } |
| 298 | |
| 299 | pub fn with_heading_to_font_size_multipliers( |
| 300 | mut self, |
| 301 | heading_to_font_size_multipliers: HeadingFontSizeMultipliers, |
| 302 | ) -> Self { |
| 303 | self.heading_to_font_size_multipliers = heading_to_font_size_multipliers; |
| 304 | self |
| 305 | } |
| 306 | |
| 307 | pub fn with_color(mut self, color: ColorU) -> Self { |
| 308 | self.text_color = color; |
| 309 | self |
| 310 | } |
| 311 | |
| 312 | pub fn with_selection_color(mut self, color: ColorU) -> Self { |
| 313 | self.text_selection_color = color; |
| 314 | self |
| 315 | } |
| 316 | |
| 317 | pub fn with_weight(mut self, weight: Weight) -> Self { |
| 318 | let ft = Arc::make_mut(&mut self.formatted_text); |
| 319 | let lines = std::mem::take(&mut ft.lines); |
| 320 | ft.lines = lines |
| 321 | .into_iter() |
| 322 | .map(|mut line| { |
| 323 | line.set_weight(weight.to_custom_weight()); |
| 324 | line |
| 325 | }) |
| 326 | .collect(); |
| 327 | self |
| 328 | } |
| 329 | |
| 330 | #[allow(dead_code)] |
| 331 | pub fn with_alignment(mut self, alignment: TextAlignment) -> Self { |
| 332 | self.alignment = alignment; |
| 333 | self |
| 334 | } |
| 335 | |
| 336 | /// Register a default handler for all _URL_ hyperlinks detected by the parser. |
| 337 | /// Note that this will clear all existing registered handlers! |
| 338 | /// For a version with support for actions, see [FormattedTextElement::register_default_click_handlers_with_actions]. |
| 339 | pub fn register_default_click_handlers<S>(mut self, default_click_handler: S) -> Self |
| 340 | where |
| 341 | S: 'static + Fn(HyperlinkUrl, &mut EventContext, &AppContext), |
| 342 | { |
| 343 | self.text_frame_mouse_handlers.clear(); |
| 344 | |
| 345 | let callback = Rc::new(default_click_handler); |
| 346 | self.register_handlers(|frame_mouse_handlers, (_index, line)| { |
| 347 | line.hyperlinks(false).into_iter().fold( |
| 348 | frame_mouse_handlers, |
| 349 | |mut frame_mouse_handlers, (range, link)| { |
| 350 | let callback = callback.clone(); |
| 351 | frame_mouse_handlers = frame_mouse_handlers.with_hoverable_char_range( |
| 352 | range.clone(), |
| 353 | MouseStateHandle::default(), |
| 354 | Some(Cursor::PointingHand), |
| 355 | // Default hover handler does nothing. TODO: highlighting |
| 356 | move |_is_hovering, _ctx, _app| {}, |
| 357 | ); |
| 358 | frame_mouse_handlers = frame_mouse_handlers.with_clickable_char_range( |
| 359 | range, |
| 360 | move |_modifiers, ctx, app| { |
| 361 | if let Hyperlink::Url(url) = &link { |
| 362 | callback(HyperlinkUrl { url: url.clone() }, ctx, app); |
| 363 | } |
| 364 | }, |
| 365 | ); |
| 366 | frame_mouse_handlers |
| 367 | }, |
| 368 | ) |
| 369 | }); |
| 370 | |
| 371 | let highlighted_link_pos = self |
| 372 | .hyperlink_support |
| 373 | .highlighted_hyperlink |
| 374 | .lock() |
| 375 | .expect("Failed to acquire lock on highlighted_hyperlink") |
| 376 | .clone(); |
| 377 | |
| 378 | for (line_index, line) in self.formatted_text.lines.iter().enumerate() { |
| 379 | let style_ranges = |
| 380 | line.hyperlinks(false) |
| 381 | .into_iter() |
| 382 | .filter_map(|(range, _)| { |
| 383 | let mut text_style = TextStyle::new() |
| 384 | .with_foreground_color(self.hyperlink_support.hyperlink_font_color); |
| 385 | if highlighted_link_pos.as_ref().is_some_and(|pos| { |
| 386 | pos.frame_index == line_index && pos.link_range == range |
| 387 | }) { |
| 388 | text_style = text_style |
| 389 | .with_underline_color(self.hyperlink_support.hyperlink_font_color); |
| 390 | } |
| 391 | |
| 392 | let highlight_indices = range.clone().collect_vec(); |
| 393 | if highlight_indices.is_empty() { |
| 394 | None |
| 395 | } else { |
| 396 | Some(HighlightedRange { |
| 397 | highlight_indices, |
| 398 | highlight: Highlight::new().with_text_style(text_style), |
| 399 | }) |
| 400 | } |
| 401 | }) |
| 402 | .collect_vec(); |
| 403 | |
| 404 | let merged_range = HighlightedRange::merge_overlapping_ranges(style_ranges); |
| 405 | let sorted_range = merged_range |
| 406 | .into_iter() |
| 407 | .sorted_by_key(|range| range.highlight_indices[0]); |
| 408 | |
| 409 | if let Some(handler) = self.text_frame_mouse_handlers.get(line_index) { |
| 410 | let mut handler = handler.borrow_mut(); |
| 411 | sorted_range.for_each(|style| { |
| 412 | handler.add_style(style); |
| 413 | }); |
| 414 | } |
| 415 | } |
| 416 | |
| 417 | self |
| 418 | } |
| 419 | |
| 420 | /// Register a default handler for all hyperlinks detected by the parser. |
| 421 | /// Note that this will clear all existing registered handlers! |
| 422 | pub fn register_default_click_handlers_with_action_support<S>( |
| 423 | mut self, |
| 424 | default_click_handler: S, |
| 425 | ) -> Self |
| 426 | where |
| 427 | S: 'static + Fn(HyperlinkLens, &mut EventContext, &AppContext), |
| 428 | { |
| 429 | self.text_frame_mouse_handlers.clear(); |
| 430 | |
| 431 | let callback = Rc::new(default_click_handler); |
| 432 | self.register_handlers(|frame_mouse_handlers, (_index, line)| { |
| 433 | line.hyperlinks(false).into_iter().fold( |
| 434 | frame_mouse_handlers, |
| 435 | |mut frame_mouse_handlers, (range, link)| { |
| 436 | let callback = callback.clone(); |
| 437 | frame_mouse_handlers = frame_mouse_handlers.with_hoverable_char_range( |
| 438 | range.clone(), |
| 439 | MouseStateHandle::default(), |
| 440 | Some(Cursor::PointingHand), |
| 441 | // Default hover handler does nothing. TODO: highlighting |
| 442 | move |_is_hovering, _ctx, _app| {}, |
| 443 | ); |
| 444 | frame_mouse_handlers = frame_mouse_handlers.with_clickable_char_range( |
| 445 | range, |
| 446 | move |_modifiers, ctx, app| { |
| 447 | callback(HyperlinkLens::from(&link), ctx, app); |
| 448 | }, |
| 449 | ); |
| 450 | frame_mouse_handlers |
| 451 | }, |
| 452 | ) |
| 453 | }); |
| 454 | |
| 455 | let highlighted_link_pos = self |
| 456 | .hyperlink_support |
| 457 | .highlighted_hyperlink |
| 458 | .lock() |
| 459 | .expect("Failed to acquire lock on highlighted_hyperlink") |
| 460 | .clone(); |
| 461 | |
| 462 | for (line_index, line) in self.formatted_text.lines.iter().enumerate() { |
| 463 | let style_ranges = |
| 464 | line.hyperlinks(false) |
| 465 | .into_iter() |
| 466 | .filter_map(|(range, _)| { |
| 467 | let mut text_style = TextStyle::new() |
| 468 | .with_foreground_color(self.hyperlink_support.hyperlink_font_color); |
| 469 | if highlighted_link_pos.as_ref().is_some_and(|pos| { |
| 470 | pos.frame_index == line_index && pos.link_range == range |
| 471 | }) { |
| 472 | text_style = text_style |
| 473 | .with_underline_color(self.hyperlink_support.hyperlink_font_color); |
| 474 | } |
| 475 | |
| 476 | let highlight_indices = range.clone().collect_vec(); |
| 477 | if highlight_indices.is_empty() { |
| 478 | None |
| 479 | } else { |
| 480 | Some(HighlightedRange { |
| 481 | highlight_indices, |
| 482 | highlight: Highlight::new().with_text_style(text_style), |
| 483 | }) |
| 484 | } |
| 485 | }) |
| 486 | .collect_vec(); |
| 487 | |
| 488 | let merged_range = HighlightedRange::merge_overlapping_ranges(style_ranges); |
| 489 | let sorted_range = merged_range |
| 490 | .into_iter() |
| 491 | .sorted_by_key(|range| range.highlight_indices[0]); |
| 492 | |
| 493 | if let Some(handler) = self.text_frame_mouse_handlers.get(line_index) { |
| 494 | let mut handler = handler.borrow_mut(); |
| 495 | sorted_range.for_each(|style| { |
| 496 | handler.add_style(style); |
| 497 | }); |
| 498 | } |
| 499 | } |
| 500 | |
| 501 | self |
| 502 | } |
| 503 | |
| 504 | pub fn with_inline_code_properties( |
| 505 | mut self, |
| 506 | inline_code_font_color: Option<ColorU>, |
| 507 | inline_code_bg_color: Option<ColorU>, |
| 508 | ) -> Self { |
| 509 | self.inline_code_font_color = inline_code_font_color; |
| 510 | self.inline_code_bg_color = inline_code_bg_color; |
| 511 | self |
| 512 | } |
| 513 | |
| 514 | pub fn with_hyperlink_font_color(mut self, hyperlink_font_color: ColorU) -> Self { |
| 515 | self.hyperlink_support.hyperlink_font_color = hyperlink_font_color; |
| 516 | self |
| 517 | } |
| 518 | |
| 519 | pub fn set_selectable(mut self, selectable: bool) -> Self { |
| 520 | self.is_selectable = selectable; |
| 521 | self |
| 522 | } |
| 523 | |
| 524 | pub fn disable_mouse_interaction(mut self) -> Self { |
| 525 | self.is_mouse_interaction_disabled = true; |
| 526 | self |
| 527 | } |
| 528 | |
| 529 | /// Disables text wrapping within the element. When text doesn't fit within the available |
| 530 | /// width, the element will extend beyond its constraints rather than wrapping the text. |
| 531 | /// This forces the parent container to handle overflow by moving the element to a new line. |
| 532 | pub fn with_no_text_wrapping(mut self) -> Self { |
| 533 | self.disable_text_wrapping = true; |
| 534 | self |
| 535 | } |
| 536 | |
| 537 | /// Sets the clip configuration for text that doesn't fit within the available width. |
| 538 | /// This automatically disables text wrapping, as clipping only applies to single-line text. |
| 539 | pub fn with_clip(mut self, clip_config: ClipConfig) -> Self { |
| 540 | self.disable_text_wrapping = true; |
| 541 | self.clip_config = Some(clip_config); |
| 542 | self |
| 543 | } |
| 544 | |
| 545 | /// Given an absolute point, returns the frame, row, and glyph indexes that makes the |
| 546 | /// most sense for a caret position. |
| 547 | /// For example, with example text "here is example text", if the mouse point is over |
| 548 | /// |i|, the index could be either 5 or 6 depending which side of the |i| the mouse is |
| 549 | /// closest to. |
| 550 | /// |
| 551 | /// # Parameters |
| 552 | /// - `snapping_policy`: Defines how the function should behave when the point is not in the bounds |
| 553 | /// of any frame. The default is to snap to the beginning or the end of the frame if the point is to the |
| 554 | /// left or right of the text, but not snap to a frame if it's in between 2 frames. |
| 555 | fn position_for_point( |
| 556 | &self, |
| 557 | absolute_point: Vector2F, |
| 558 | snapping_policy: SnappingPolicy, |
| 559 | ) -> Option<FormattedTextSelectionLocation> { |
| 560 | let (Some(origin), Some(size)) = (self.origin(), self.size()) else { |
| 561 | return None; |
| 562 | }; |
| 563 | |
| 564 | let relative_point = absolute_point - origin.xy; |
| 565 | |
| 566 | // Snap to first/last character if above/below text. |
| 567 | if relative_point.y() < 0. { |
| 568 | return (!self.laid_out_text.is_empty()).then_some(FormattedTextSelectionLocation { |
| 569 | frame_index: 0, |
| 570 | row_index: 0, |
| 571 | glyph_index: 0, |
| 572 | }); |
| 573 | } else if relative_point.y() > size.y() { |
| 574 | // Get the last valid frame and its last character index. |
| 575 | let frame = self.laid_out_text.last()?; |
| 576 | let (row_index, glyph_index) = frame |
| 577 | .get_last_row_and_glyph_index(!snapping_policy.should_adjust_to_char_indices)?; |
| 578 | return Some(FormattedTextSelectionLocation { |
| 579 | frame_index: self.laid_out_text.len() - 1, |
| 580 | row_index, |
| 581 | glyph_index, |
| 582 | }); |
| 583 | } |
| 584 | |
| 585 | // Find which frame contains the point and which row and glyph index it is in. |
| 586 | // Keep track of whether the last frame was before or after the point to check if the point |
| 587 | // landed in a gap between frames. |
| 588 | let mut point_after_last_frame = false; |
| 589 | for (frame_index, frame) in self.laid_out_text.iter().enumerate() { |
| 590 | let frame_bounds = frame.get_frame_bounds(); |
| 591 | |
| 592 | let point_before_current_frame = absolute_point.y() < frame_bounds.min_y(); |
| 593 | let point_after_current_frame = absolute_point.y() > frame_bounds.max_y(); |
| 594 | if point_before_current_frame || point_after_current_frame { |
| 595 | if snapping_policy.should_snap_on_gap |
| 596 | && point_after_last_frame |
| 597 | && point_before_current_frame |
| 598 | { |
| 599 | // Snap to the beginning of the current frame |
| 600 | return Some(FormattedTextSelectionLocation { |
| 601 | frame_index, |
| 602 | row_index: 0, |
| 603 | glyph_index: 0, |
| 604 | }); |
| 605 | } |
| 606 | |
| 607 | point_after_last_frame = point_after_current_frame; |
| 608 | continue; |
| 609 | } |
| 610 | |
| 611 | return match frame { |
| 612 | LaidOutTextFrame::Text { text_frame, .. } |
| 613 | | LaidOutTextFrame::CodeBlock { text_frame, .. } |
| 614 | | LaidOutTextFrame::Indented { text_frame, .. } => { |
| 615 | let relative_y_within_frame = absolute_point.y() - frame_bounds.min_y(); |
| 616 | |
| 617 | // Find the line that the point is in. |
| 618 | let mut row_index = 0; |
| 619 | let mut line_height = 0.; |
| 620 | for line in text_frame.lines() { |
| 621 | line_height += line.height(); |
| 622 | if relative_y_within_frame > line_height { |
| 623 | row_index += 1; |
| 624 | } else { |
| 625 | break; |
| 626 | } |
| 627 | } |
| 628 | if row_index >= text_frame.lines().len() { |
| 629 | row_index = text_frame.lines().len().saturating_sub(1); |
| 630 | } |
| 631 | let line = text_frame.lines().get(row_index)?; |
| 632 | |
| 633 | // Compute the relative x position within the frame. |
| 634 | let relative_x_within_frame = |
| 635 | absolute_point.x() - frame_bounds.min_x() - text_frame.line_x_offset(line); |
| 636 | |
| 637 | let mut glyph_index = if snapping_policy.should_snap_to_ends { |
| 638 | line.caret_index_for_x_unbounded(relative_x_within_frame) |
| 639 | } else { |
| 640 | line.caret_index_for_x(relative_x_within_frame)? |
| 641 | }; |
| 642 | |
| 643 | if glyph_index == line.end_index() |
| 644 | && snapping_policy.should_adjust_to_char_indices |
| 645 | { |
| 646 | // If the point is at the end of the line, we should adjust to the last character |
| 647 | // index if the snapping policy forces it. |
| 648 | glyph_index = line.end_index() - 1; |
| 649 | } |
| 650 | |
| 651 | Some(FormattedTextSelectionLocation { |
| 652 | frame_index, |
| 653 | row_index, |
| 654 | glyph_index, |
| 655 | }) |
| 656 | } |
| 657 | LaidOutTextFrame::LineBreak { .. } => { |
| 658 | // Treat line breaks as a single newline character |
| 659 | Some(FormattedTextSelectionLocation { |
| 660 | frame_index, |
| 661 | row_index: 0, |
| 662 | glyph_index: 0, |
| 663 | }) |
| 664 | } |
| 665 | }; |
| 666 | } |
| 667 | None |
| 668 | } |
| 669 | |
| 670 | /// Determines rendering boundaries for drawing the given selection. |
| 671 | /// Assumes that [`selection_start`] comes before [`selection_end`]. |
| 672 | fn calculate_selection_bounds( |
| 673 | &self, |
| 674 | selection_start: FormattedTextSelectionLocation, |
| 675 | selection_end: FormattedTextSelectionLocation, |
| 676 | ) -> Vec<RectF> { |
| 677 | let start_frame_idx = selection_start.frame_index; |
| 678 | let start_row_idx = selection_start.row_index; |
| 679 | let end_frame_idx = selection_end.frame_index; |
| 680 | let end_row_idx = selection_end.row_index; |
| 681 | |
| 682 | let mut selection_bounds = Vec::new(); |
| 683 | |
| 684 | for (frame_index, frame) in self |
| 685 | .laid_out_text |
| 686 | .iter() |
| 687 | .enumerate() |
| 688 | .skip(start_frame_idx) |
| 689 | .take(end_frame_idx - start_frame_idx + 1) |
| 690 | { |
| 691 | match frame { |
| 692 | LaidOutTextFrame::Text { text_frame, .. } |
| 693 | | LaidOutTextFrame::CodeBlock { text_frame, .. } |
| 694 | | LaidOutTextFrame::Indented { text_frame, .. } => { |
| 695 | let frame_bounds = frame.get_frame_bounds(); |
| 696 | let mut frame_y = frame_bounds.min_y(); |
| 697 | |
| 698 | let is_starting_frame = frame_index == start_frame_idx; |
| 699 | let is_ending_frame = frame_index == end_frame_idx; |
| 700 | let line_count = text_frame.lines().len(); |
| 701 | |
| 702 | // Start drawing on the starting row if the current frame is the starting frame; |
| 703 | // otherwise, start from the first row. |
| 704 | let row_start = if is_starting_frame { start_row_idx } else { 0 }; |
| 705 | |
| 706 | // Start drawing on the ending row if the current frame is the ending frame; |
| 707 | // otherwise, draw all rows. |
| 708 | let row_end = if is_ending_frame { |
| 709 | end_row_idx |
| 710 | } else { |
| 711 | line_count.saturating_sub(1) |
| 712 | }; |
| 713 | |
| 714 | for row in text_frame.lines().iter().take(row_start) { |
| 715 | frame_y += row.height(); |
| 716 | } |
| 717 | |
| 718 | for row_index in row_start..=row_end { |
| 719 | let line = match text_frame.lines().get(row_index) { |
| 720 | Some(line) => line, |
| 721 | None => continue, |
| 722 | }; |
| 723 | let line_height = line.height(); |
| 724 | let line_origin_x = frame_bounds.min_x() + text_frame.line_x_offset(line); |
| 725 | |
| 726 | // Draw highlight on the entire line if it's not the starting or the ending row; |
| 727 | // otherwise, draw highlight that starts/ends on the selection bound. |
| 728 | let start_x = if is_starting_frame && row_index == start_row_idx { |
| 729 | line.x_for_index(selection_start.glyph_index) |
| 730 | } else { |
| 731 | 0. |
| 732 | }; |
| 733 | let end_x = if is_ending_frame && row_index == end_row_idx { |
| 734 | line.x_for_index(selection_end.glyph_index) |
| 735 | } else { |
| 736 | line.width |
| 737 | }; |
| 738 | let rect_origin = vec2f(line_origin_x + start_x, frame_y); |
| 739 | let rect_size = vec2f(end_x - start_x, line_height); |
| 740 | selection_bounds.push(RectF::new(rect_origin, rect_size)); |
| 741 | frame_y += line_height; |
| 742 | } |
| 743 | } |
| 744 | LaidOutTextFrame::LineBreak { .. } => (), |
| 745 | } |
| 746 | } |
| 747 | selection_bounds |
| 748 | } |
| 749 | |
| 750 | /// Assumes that [`selection_start`] comes before [`selection_end`]. |
| 751 | fn draw_selection( |
| 752 | &self, |
| 753 | selection_start: FormattedTextSelectionLocation, |
| 754 | selection_end: FormattedTextSelectionLocation, |
| 755 | ctx: &mut PaintContext, |
| 756 | ) { |
| 757 | if !self.is_selectable { |
| 758 | return; |
| 759 | } |
| 760 | for rect in self.calculate_selection_bounds(selection_start, selection_end) { |
| 761 | ctx.scene |
| 762 | .draw_rect_without_hit_recording(rect) |
| 763 | .with_background(Fill::Solid(self.text_selection_color)); |
| 764 | } |
| 765 | } |
| 766 | |
| 767 | fn translate_selection_bound_to_line_bound( |
| 768 | &self, |
| 769 | bound: FormattedTextSelectionLocation, |
| 770 | line_start: bool, |
| 771 | ) -> Option<Vector2F> { |
| 772 | let origin = self.origin()?; |
| 773 | |
| 774 | let base_y_offset = self.laid_out_text[..bound.frame_index] |
| 775 | .iter() |
| 776 | .map(|f| f.calculate_frame_height()) |
| 777 | .sum::<f32>(); |
| 778 | let frame = self.laid_out_text.get(bound.frame_index)?; |
| 779 | |
| 780 | let (relative_x, y_offset) = match frame { |
| 781 | LaidOutTextFrame::Text { text_frame, .. } |
| 782 | | LaidOutTextFrame::CodeBlock { text_frame, .. } |
| 783 | | LaidOutTextFrame::Indented { text_frame, .. } => { |
| 784 | let frame_bounds = frame.get_frame_bounds(); |
| 785 | let mut y_offset = frame_bounds.min_y() - origin.y(); |
| 786 | // add the height of all lines before the selected line |
| 787 | y_offset += text_frame |
| 788 | .lines() |
| 789 | .iter() |
| 790 | .take(bound.row_index) |
| 791 | .map(|line| line.height()) |
| 792 | .sum::<f32>(); |
| 793 | // use the y-pos of the middle of the line to be selected |
| 794 | y_offset += text_frame.lines().get(bound.row_index)?.height() / 2.; |
| 795 | |
| 796 | let line = text_frame.lines().get(bound.row_index)?; |
| 797 | let relative_x = frame_bounds.min_x() - origin.x() |
| 798 | + text_frame.line_x_offset(line) |
| 799 | + if line_start { 0. } else { line.width }; |
| 800 | |
| 801 | (relative_x, y_offset) |
| 802 | } |
| 803 | LaidOutTextFrame::LineBreak { frame_bounds } => { |
| 804 | // use the y-pos of the middle of the frame to select |
| 805 | (0., base_y_offset + frame_bounds.height() / 2.) |
| 806 | } |
| 807 | }; |
| 808 | |
| 809 | Some(origin.xy + vec2f(relative_x, y_offset)) |
| 810 | } |
| 811 | |
| 812 | pub fn register_handlers<F>(&mut self, register: F) |
| 813 | where |
| 814 | F: Fn(FrameMouseHandlers, (usize, &FormattedTextLine)) -> FrameMouseHandlers, |
| 815 | { |
| 816 | self.text_frame_mouse_handlers = self |
| 817 | .formatted_text |
| 818 | .lines |
| 819 | .iter() |
| 820 | .enumerate() |
| 821 | .map(|line| Rc::new(RefCell::new(register(FrameMouseHandlers::default(), line)))) |
| 822 | .collect(); |
| 823 | } |
| 824 | |
| 825 | /// Save a position_id in the position cache for a given glyph and frame in the text. |
| 826 | /// This can be used to position other elements relative to a char in the text element. |
| 827 | pub fn with_saved_glyph_position( |
| 828 | mut self, |
| 829 | glyph_index: usize, |
| 830 | frame_index: usize, |
| 831 | position_id: String, |
| 832 | ) -> Self { |
| 833 | self.saved_glyph_positions.push(SavedGlyphPositionIds { |
| 834 | position: SavedGlyphPosition::FormattedTextLinePosition( |
| 835 | FormattedTextSelectionLocation { |
| 836 | frame_index, |
| 837 | row_index: 0, |
| 838 | glyph_index, |
| 839 | }, |
| 840 | ), |
| 841 | position_id, |
| 842 | }); |
| 843 | self |
| 844 | } |
| 845 | |
| 846 | pub fn add_styles( |
| 847 | &mut self, |
| 848 | frame_index: usize, |
| 849 | sorted_styles: impl IntoIterator<Item = HighlightedRange>, |
| 850 | ) { |
| 851 | if let Some(handler) = self.text_frame_mouse_handlers.get(frame_index) { |
| 852 | let mut handler = handler.borrow_mut(); |
| 853 | sorted_styles.into_iter().for_each(|style| { |
| 854 | handler.add_style(style); |
| 855 | }); |
| 856 | } |
| 857 | } |
| 858 | |
| 859 | fn handle_mouse_moved( |
| 860 | &mut self, |
| 861 | position: Vector2F, |
| 862 | z_index: ZIndex, |
| 863 | ctx: &mut EventContext, |
| 864 | app: &AppContext, |
| 865 | ) -> bool { |
| 866 | if self.is_mouse_interaction_disabled { |
| 867 | return false; |
| 868 | } |
| 869 | |
| 870 | let is_covered = ctx.is_covered(Point::from_vec2f(position, z_index)); |
| 871 | let mut handled = false; |
| 872 | |
| 873 | let mut highlighted_link = self |
| 874 | .hyperlink_support |
| 875 | .highlighted_hyperlink |
| 876 | .lock() |
| 877 | .expect("Failed to acquire lock on highlighted_hyperlink"); |
| 878 | // Set to None. If all checks passed, we will set it to the hovered link. |
| 879 | *highlighted_link = None; |
| 880 | |
| 881 | if let Some(bounds) = self.bounds() { |
| 882 | // If the mouse is moving outside the bounds, ignore the event; otherwise, |
| 883 | // reset the cursor and clear all hover states. |
| 884 | if !bounds.contains_point(position) { |
| 885 | // If there was a link hovered, consider mouse to be moving from within the element to outside. |
| 886 | let was_hovered = self |
| 887 | .text_frame_mouse_handlers |
| 888 | .iter() |
| 889 | .any(|handlers| handlers.borrow_mut().unhover_all(ctx, app)); |
| 890 | if was_hovered { |
| 891 | ctx.reset_cursor(); |
| 892 | } |
| 893 | return false; |
| 894 | } |
| 895 | } else { |
| 896 | return false; |
| 897 | }; |
| 898 | |
| 899 | let Some(text_pos) = |
| 900 | self.position_for_point(position, SnappingPolicy::precise_char_range()) |
| 901 | else { |
| 902 | // If the mouse is within the bound of the element but not on any text frame, |
| 903 | // reset the cursor and clear all hover states. |
| 904 | for handlers in self.text_frame_mouse_handlers.iter() { |
| 905 | handlers.borrow_mut().unhover_all(ctx, app); |
| 906 | } |
| 907 | ctx.reset_cursor(); |
| 908 | return true; |
| 909 | }; |
| 910 | |
| 911 | let handlers = match self.laid_out_text.get(text_pos.frame_index) { |
| 912 | Some( |
| 913 | LaidOutTextFrame::Text { mouse_handlers, .. } |
| 914 | | LaidOutTextFrame::Indented { mouse_handlers, .. }, |
| 915 | ) => mouse_handlers, |
| 916 | _ => return false, |
| 917 | }; |
| 918 | |
| 919 | // Note that these are all char indices! |
| 920 | let mut handlers = handlers.borrow_mut(); |
| 921 | let glyph_offset = handlers.glyph_offset; |
| 922 | |
| 923 | // Unhover all other frames. |
| 924 | let was_hovered = self |
| 925 | .laid_out_text |
| 926 | .iter() |
| 927 | .enumerate() |
| 928 | .any(|(i, laid_out_frame)| { |
| 929 | if i == text_pos.frame_index { |
| 930 | return false; |
| 931 | } |
| 932 | let handlers = match laid_out_frame { |
| 933 | LaidOutTextFrame::Text { mouse_handlers, .. } |
| 934 | | LaidOutTextFrame::Indented { mouse_handlers, .. } => mouse_handlers, |
| 935 | _ => return false, |
| 936 | }; |
| 937 | handlers.borrow_mut().unhover_all(ctx, app) |
| 938 | }); |
| 939 | if was_hovered { |
| 940 | ctx.reset_cursor(); |
| 941 | } |
| 942 | |
| 943 | // If the mouse is on a frame without any hover handlers, reset the cursor |
| 944 | if handlers.hover_handlers.is_empty() { |
| 945 | ctx.reset_cursor(); |
| 946 | return true; |
| 947 | } |
| 948 | |
| 949 | handlers |
| 950 | .hover_handlers |
| 951 | .iter_mut() |
| 952 | .for_each(|hoverable_range| { |
| 953 | let was_hovered = hoverable_range.mouse_state().is_hovered(); |
| 954 | let adjusted_range = hoverable_range.char_range.start + glyph_offset.as_usize() |
| 955 | ..hoverable_range.char_range.end + glyph_offset.as_usize(); |
| 956 | let is_hovered = !is_covered && adjusted_range.contains(&text_pos.glyph_index); |
| 957 | |
| 958 | if is_hovered != was_hovered { |
| 959 | hoverable_range.mouse_state().is_hovered = is_hovered; |
| 960 | let handler = hoverable_range.hover_handler.as_mut(); |
| 961 | handler(is_hovered, ctx, app); |
| 962 | handled = true; |
| 963 | if let Some(cursor_on_hover) = hoverable_range.cursor_on_hover { |
| 964 | if is_hovered { |
| 965 | ctx.set_cursor(cursor_on_hover, z_index) |
| 966 | } else { |
| 967 | ctx.reset_cursor() |
| 968 | } |
| 969 | } |
| 970 | } |
| 971 | |
| 972 | if is_hovered { |
| 973 | *highlighted_link = Some(HyperlinkPosition { |
| 974 | // formatted text line index, not laid-out frame index |
| 975 | frame_index: self.frame_index_to_line_index(text_pos.frame_index), |
| 976 | link_range: hoverable_range.char_range.clone(), |
| 977 | }); |
| 978 | } |
| 979 | }); |
| 980 | handled |
| 981 | } |
| 982 | |
| 983 | fn handle_mouse_down( |
| 984 | &mut self, |
| 985 | position: Vector2F, |
| 986 | z_index: ZIndex, |
| 987 | modifiers: &ModifiersState, |
| 988 | ctx: &mut EventContext, |
| 989 | app: &AppContext, |
| 990 | ) -> bool { |
| 991 | if self.is_mouse_interaction_disabled |
| 992 | || ctx.is_covered(Point::from_vec2f(position, z_index)) |
| 993 | { |
| 994 | return false; |
| 995 | } |
| 996 | |
| 997 | if !self |
| 998 | .bounds() |
| 999 | .is_some_and(|bounds| bounds.contains_point(position)) |
| 1000 | { |
| 1001 | return false; |
| 1002 | } |
| 1003 | |
| 1004 | let Some(click_pos) = |
| 1005 | self.position_for_point(position, SnappingPolicy::precise_char_range()) |
| 1006 | else { |
| 1007 | return false; |
| 1008 | }; |
| 1009 | let handlers = match self.laid_out_text.get(click_pos.frame_index) { |
| 1010 | Some( |
| 1011 | LaidOutTextFrame::Text { mouse_handlers, .. } |
| 1012 | | LaidOutTextFrame::Indented { mouse_handlers, .. }, |
| 1013 | ) => mouse_handlers, |
| 1014 | _ => return false, |
| 1015 | }; |
| 1016 | |
| 1017 | let mut handlers = handlers.borrow_mut(); |
| 1018 | let glyph_offset = handlers.glyph_offset; |
| 1019 | |
| 1020 | let mut handled = false; |
| 1021 | handlers |
| 1022 | .click_handlers |
| 1023 | .iter_mut() |
| 1024 | .for_each(|clickable_range| { |
| 1025 | let handler_char_range = (clickable_range.char_range.start |
| 1026 | + glyph_offset.as_usize()) |
| 1027 | ..(clickable_range.char_range.end + glyph_offset.as_usize()); |
| 1028 | if handler_char_range.contains(&click_pos.glyph_index) { |
| 1029 | let handler = clickable_range.click_handler.as_mut(); |
| 1030 | handler(modifiers, ctx, app); |
| 1031 | handled = true; |
| 1032 | } |
| 1033 | }); |
| 1034 | handled |
| 1035 | } |
| 1036 | |
| 1037 | fn frame_index_to_line_index(&self, frame_index: usize) -> usize { |
| 1038 | // Render text line-by-line. |
| 1039 | let mut lines = self.formatted_text.lines.iter().enumerate().peekable(); |
| 1040 | // We don't use the frame_index from lines.next() because this creates inconsistencies in the frame index when we have lists |
| 1041 | let mut curr_frame_index = 0; |
| 1042 | let mut last_line_was_list_item = false; |
| 1043 | while let Some((line_index, line)) = lines.next() { |
| 1044 | if curr_frame_index == frame_index { |
| 1045 | return line_index; |
| 1046 | } |
| 1047 | |
| 1048 | let line_type = LineType::from(line); |
| 1049 | // Since list items are flattened into multiple lines rather than a single list segment, |
| 1050 | // this ends up causing line breaks between each individual list item. To avoid this, |
| 1051 | // we only show the line break if we already started a list and there isn't a following list item. |
| 1052 | let curr_line_is_line_break = matches!(line_type, LineType::LineBreak); |
| 1053 | let next_line_is_list_item = matches!( |
| 1054 | lines.peek(), |
| 1055 | Some((_, FormattedTextLine::UnorderedList(_))) |
| 1056 | | Some((_, FormattedTextLine::OrderedList(_))) |
| 1057 | ); |
| 1058 | if last_line_was_list_item && curr_line_is_line_break && next_line_is_list_item { |
| 1059 | continue; |
| 1060 | } |
| 1061 | |
| 1062 | last_line_was_list_item = |
| 1063 | matches!(line_type, LineType::OrderedList | LineType::UnorderedList); |
| 1064 | |
| 1065 | curr_frame_index += 1; |
| 1066 | } |
| 1067 | |
| 1068 | curr_frame_index |
| 1069 | } |
| 1070 | |
| 1071 | /// Guarantees that any returned ranges are non-inverted (i.e. start before end) |
| 1072 | fn calculate_point_ranges( |
| 1073 | &self, |
| 1074 | current_selection: Option<Selection>, |
| 1075 | ) -> Option< |
| 1076 | Vec<( |
| 1077 | FormattedTextSelectionLocation, |
| 1078 | FormattedTextSelectionLocation, |
| 1079 | )>, |
| 1080 | > { |
| 1081 | let current_selection = current_selection?; |
| 1082 | let ((start_bound, start_pos), (end_bound, end_pos)) = { |
| 1083 | let start_point = self.position_for_point( |
| 1084 | current_selection.start, |
| 1085 | SnappingPolicy::default().snap_on_gap(), |
| 1086 | )?; |
| 1087 | let end_point = self.position_for_point( |
| 1088 | current_selection.end, |
| 1089 | SnappingPolicy::default().snap_on_gap(), |
| 1090 | )?; |
| 1091 | match start_point.frame_index.cmp(&end_point.frame_index) { |
| 1092 | std::cmp::Ordering::Equal => { |
| 1093 | if end_point.glyph_index >= start_point.glyph_index { |
| 1094 | ( |
| 1095 | (start_point, current_selection.start), |
| 1096 | (end_point, current_selection.end), |
| 1097 | ) |
| 1098 | } else { |
| 1099 | ( |
| 1100 | (end_point, current_selection.end), |
| 1101 | (start_point, current_selection.start), |
| 1102 | ) |
| 1103 | } |
| 1104 | } |
| 1105 | std::cmp::Ordering::Less => ( |
| 1106 | (start_point, current_selection.start), |
| 1107 | (end_point, current_selection.end), |
| 1108 | ), |
| 1109 | std::cmp::Ordering::Greater => ( |
| 1110 | (end_point, current_selection.end), |
| 1111 | (start_point, current_selection.start), |
| 1112 | ), |
| 1113 | } |
| 1114 | }; |
| 1115 | |
| 1116 | match current_selection.is_rect { |
| 1117 | IsRect::True => self.compute_rect_selection_bounds( |
| 1118 | start_bound, |
| 1119 | end_bound, |
| 1120 | start_pos.x(), |
| 1121 | end_pos.x(), |
| 1122 | ), |
| 1123 | IsRect::False => Some(vec![(start_bound, end_bound)]), |
| 1124 | } |
| 1125 | } |
| 1126 | |
| 1127 | fn compute_rect_selection_bounds( |
| 1128 | &self, |
| 1129 | start_bound: FormattedTextSelectionLocation, |
| 1130 | end_bound: FormattedTextSelectionLocation, |
| 1131 | selection_start_x: f32, |
| 1132 | selection_end_x: f32, |
| 1133 | ) -> Option< |
| 1134 | Vec<( |
| 1135 | FormattedTextSelectionLocation, |
| 1136 | FormattedTextSelectionLocation, |
| 1137 | )>, |
| 1138 | > { |
| 1139 | if start_bound == end_bound { |
| 1140 | return None; |
| 1141 | } |
| 1142 | let mut rows_bounds = Vec::new(); |
| 1143 | |
| 1144 | for (frame_index, frame) in self |
| 1145 | .laid_out_text |
| 1146 | .iter() |
| 1147 | .enumerate() |
| 1148 | .skip(start_bound.frame_index) |
| 1149 | .take(end_bound.frame_index - start_bound.frame_index + 1) |
| 1150 | { |
| 1151 | let text_frame = match frame { |
| 1152 | LaidOutTextFrame::Text { text_frame, .. } |
| 1153 | | LaidOutTextFrame::CodeBlock { text_frame, .. } |
| 1154 | | LaidOutTextFrame::Indented { text_frame, .. } => text_frame, |
| 1155 | LaidOutTextFrame::LineBreak { .. } => { |
| 1156 | continue; |
| 1157 | } |
| 1158 | }; |
| 1159 | let lines = text_frame.lines(); |
| 1160 | |
| 1161 | let start_row_index = if frame_index == start_bound.frame_index { |
| 1162 | start_bound.row_index |
| 1163 | } else { |
| 1164 | 0 |
| 1165 | }; |
| 1166 | let end_row_index = if frame_index == end_bound.frame_index { |
| 1167 | end_bound.row_index |
| 1168 | } else { |
| 1169 | lines.len().saturating_sub(1) |
| 1170 | }; |
| 1171 | |
| 1172 | for (row_index, line) in lines |
| 1173 | .iter() |
| 1174 | .enumerate() |
| 1175 | .skip(start_row_index) |
| 1176 | .take(end_row_index - start_row_index + 1) |
| 1177 | { |
| 1178 | let line_origin_x = |
| 1179 | frame.get_frame_bounds().min_x() + text_frame.line_x_offset(line); |
| 1180 | let start_caret_index = |
| 1181 | line.caret_index_for_x_unbounded(selection_start_x - line_origin_x); |
| 1182 | let end_caret_index = |
| 1183 | line.caret_index_for_x_unbounded(selection_end_x - line_origin_x); |
| 1184 | rows_bounds.push(( |
| 1185 | FormattedTextSelectionLocation { |
| 1186 | frame_index, |
| 1187 | row_index, |
| 1188 | glyph_index: start_caret_index, |
| 1189 | }, |
| 1190 | FormattedTextSelectionLocation { |
| 1191 | frame_index, |
| 1192 | row_index, |
| 1193 | glyph_index: end_caret_index, |
| 1194 | }, |
| 1195 | )); |
| 1196 | } |
| 1197 | } |
| 1198 | |
| 1199 | Some(rows_bounds) |
| 1200 | } |
| 1201 | |
| 1202 | fn build_regular_selection_text( |
| 1203 | &self, |
| 1204 | start_bound: FormattedTextSelectionLocation, |
| 1205 | end_bound: FormattedTextSelectionLocation, |
| 1206 | ) -> Option<String> { |
| 1207 | // Handle selection within a single frame |
| 1208 | if start_bound.frame_index == end_bound.frame_index { |
| 1209 | let frame = self.laid_out_text.get(start_bound.frame_index)?; |
| 1210 | let text = frame.get_raw_text(); |
| 1211 | |
| 1212 | let start_glyph_index = start_bound.glyph_index; |
| 1213 | let end_glyph_index = end_bound.glyph_index.min(text.chars().count()); |
| 1214 | |
| 1215 | Some(char_slice(text, start_glyph_index, end_glyph_index)?.to_owned()) |
| 1216 | } else { |
| 1217 | // Handle selection across multiple frames |
| 1218 | let mut result = String::new(); |
| 1219 | |
| 1220 | // Handle start frame |
| 1221 | let start_frame = self.laid_out_text.get(start_bound.frame_index)?; |
| 1222 | let start_text = start_frame.get_raw_text(); |
| 1223 | |
| 1224 | // The `if let` is necessary because `glyph_index` might point to the end of the line. |
| 1225 | // In such cases, `get_selection()` should ignore the starting line and resume from the next line. |
| 1226 | if let Some((start_byte_index, _)) = |
| 1227 | start_text.char_indices().nth(start_bound.glyph_index) |
| 1228 | { |
| 1229 | result.push_str(&start_text[start_byte_index..]); |
| 1230 | } |
| 1231 | |
| 1232 | // Handle middle frames |
| 1233 | for frame in self |
| 1234 | .laid_out_text |
| 1235 | .iter() |
| 1236 | .skip(start_bound.frame_index + 1) |
| 1237 | .take(end_bound.frame_index - start_bound.frame_index - 1) |
| 1238 | { |
| 1239 | let text = frame.get_raw_text(); |
| 1240 | result.push('\n'); |
| 1241 | result.push_str(text); |
| 1242 | } |
| 1243 | |
| 1244 | // Handle end frame |
| 1245 | let end_frame = self.laid_out_text.get(end_bound.frame_index)?; |
| 1246 | let end_text = end_frame.get_raw_text(); |
| 1247 | let end_glyph_index = end_bound.glyph_index.min(end_text.chars().count()); |
| 1248 | |
| 1249 | // This is to prevent slicing an empty string (i.e. a newline frame) and returning a None. |
| 1250 | if let Some(text) = char_slice(end_text, 0, end_glyph_index) { |
| 1251 | if !text.is_empty() { |
| 1252 | result.push('\n'); |
| 1253 | result.push_str(text); |
| 1254 | } |
| 1255 | } |
| 1256 | |
| 1257 | Some(result) |
| 1258 | } |
| 1259 | } |
| 1260 | |
| 1261 | /// Returns a reference to the formatted text content of this element. |
| 1262 | pub fn formatted_text(&self) -> &FormattedText { |
| 1263 | &self.formatted_text |
| 1264 | } |
| 1265 | } |
| 1266 | |
| 1267 | enum LaidOutTextFrame { |
| 1268 | Text { |
| 1269 | text_frame: Arc<TextFrame>, |
| 1270 | frame_bounds: RectF, |
| 1271 | bottom_padding: f32, |
| 1272 | raw_text: String, |
| 1273 | mouse_handlers: Rc<RefCell<FrameMouseHandlers>>, |
| 1274 | }, |
| 1275 | CodeBlock { |
| 1276 | text_frame: Arc<TextFrame>, |
| 1277 | frame_bounds: RectF, |
| 1278 | bottom_padding: f32, |
| 1279 | raw_text: String, |
| 1280 | }, |
| 1281 | Indented { |
| 1282 | text_frame: Arc<TextFrame>, |
| 1283 | indent: usize, |
| 1284 | frame_bounds: RectF, |
| 1285 | top_padding: f32, |
| 1286 | bottom_padding: f32, |
| 1287 | left_padding: f32, |
| 1288 | raw_text: String, |
| 1289 | mouse_handlers: Rc<RefCell<FrameMouseHandlers>>, |
| 1290 | }, |
| 1291 | LineBreak { |
| 1292 | frame_bounds: RectF, |
| 1293 | }, |
| 1294 | } |
| 1295 | |
| 1296 | impl LaidOutTextFrame { |
| 1297 | /// Returns if a frame has a certain position inside of it. |
| 1298 | #[allow(dead_code)] |
| 1299 | fn contains(&self, position: Vector2F) -> bool { |
| 1300 | self.get_frame_bounds().contains_point(position) |
| 1301 | } |
| 1302 | |
| 1303 | fn get_frame_bounds(&self) -> &RectF { |
| 1304 | match self { |
| 1305 | LaidOutTextFrame::Text { frame_bounds, .. } |
| 1306 | | LaidOutTextFrame::CodeBlock { frame_bounds, .. } |
| 1307 | | LaidOutTextFrame::Indented { frame_bounds, .. } |
| 1308 | | LaidOutTextFrame::LineBreak { frame_bounds, .. } => frame_bounds, |
| 1309 | } |
| 1310 | } |
| 1311 | |
| 1312 | /// Helper function to calculate the x ofset of a text frame. |
| 1313 | pub fn calculate_x_offset( |
| 1314 | &self, |
| 1315 | font_size: f32, |
| 1316 | x_origin: f32, |
| 1317 | alignment: TextAlignment, |
| 1318 | frame_width: f32, |
| 1319 | ) -> f32 { |
| 1320 | let indent = match self { |
| 1321 | LaidOutTextFrame::Text { .. } | LaidOutTextFrame::LineBreak { .. } => 0, |
| 1322 | LaidOutTextFrame::CodeBlock { .. } => CODE_BLOCK_OFFSET, |
| 1323 | LaidOutTextFrame::Indented { indent, .. } => *indent, |
| 1324 | }; |
| 1325 | |
| 1326 | match alignment { |
| 1327 | TextAlignment::Left => x_origin + font_size * indent as f32, |
| 1328 | TextAlignment::Right => x_origin + frame_width - self.width(), |
| 1329 | TextAlignment::Center => x_origin + (frame_width - self.width()) / 2., |
| 1330 | } |
| 1331 | } |
| 1332 | |
| 1333 | pub fn width(&self) -> f32 { |
| 1334 | match self { |
| 1335 | LaidOutTextFrame::CodeBlock { text_frame, .. } |
| 1336 | | LaidOutTextFrame::Indented { text_frame, .. } |
| 1337 | | LaidOutTextFrame::Text { text_frame, .. } => text_frame.max_width(), |
| 1338 | LaidOutTextFrame::LineBreak { .. } => 0., |
| 1339 | } |
| 1340 | } |
| 1341 | |
| 1342 | /// Helper function to get the frame height. |
| 1343 | pub fn calculate_frame_height(&self) -> f32 { |
| 1344 | match self { |
| 1345 | LaidOutTextFrame::Text { |
| 1346 | text_frame, |
| 1347 | bottom_padding, |
| 1348 | .. |
| 1349 | } |
| 1350 | | LaidOutTextFrame::CodeBlock { |
| 1351 | text_frame, |
| 1352 | bottom_padding, |
| 1353 | .. |
| 1354 | } => text_frame.height() + bottom_padding, |
| 1355 | LaidOutTextFrame::Indented { |
| 1356 | text_frame, |
| 1357 | top_padding, |
| 1358 | bottom_padding, |
| 1359 | .. |
| 1360 | } => text_frame.height() + bottom_padding + top_padding, |
| 1361 | LaidOutTextFrame::LineBreak { .. } => LINE_BREAK_HEIGHT, |
| 1362 | } |
| 1363 | } |
| 1364 | |
| 1365 | /// Returns the last row index and glyph index as `(row_index, glyph_index)` if there is at least one Line; |
| 1366 | /// otherwise returns `None`. |
| 1367 | /// # Parameters |
| 1368 | /// - `use_end_index`: If true, returns the end index of the last line; otherwise, returns the index of the last character. |
| 1369 | pub fn get_last_row_and_glyph_index(&self, use_end_index: bool) -> Option<(usize, usize)> { |
| 1370 | match self { |
| 1371 | LaidOutTextFrame::Text { text_frame, .. } |
| 1372 | | LaidOutTextFrame::CodeBlock { text_frame, .. } |
| 1373 | | LaidOutTextFrame::Indented { text_frame, .. } => { |
| 1374 | text_frame.lines().last().map(|line| { |
| 1375 | ( |
| 1376 | text_frame.lines().len() - 1, |
| 1377 | if use_end_index { |
| 1378 | line.end_index() |
| 1379 | } else { |
| 1380 | line.last_index() |
| 1381 | }, |
| 1382 | ) |
| 1383 | }) |
| 1384 | } |
| 1385 | LaidOutTextFrame::LineBreak { .. } => { |
| 1386 | // Treat line breaks as a single newline character |
| 1387 | Some((0, 0)) |
| 1388 | } |
| 1389 | } |
| 1390 | } |
| 1391 | |
| 1392 | pub fn get_raw_text(&self) -> &str { |
| 1393 | match self { |
| 1394 | LaidOutTextFrame::Text { raw_text, .. } |
| 1395 | | LaidOutTextFrame::CodeBlock { raw_text, .. } |
| 1396 | | LaidOutTextFrame::Indented { raw_text, .. } => raw_text, |
| 1397 | LaidOutTextFrame::LineBreak { .. } => "", |
| 1398 | } |
| 1399 | } |
| 1400 | } |
| 1401 | |
| 1402 | #[derive(Default)] |
| 1403 | pub struct FrameMouseHandlers { |
| 1404 | // contains the clickable char ranges and the corresponding click |
| 1405 | // handler for each char range |
| 1406 | click_handlers: Vec<ClickableCharRange>, |
| 1407 | // contains the hoverable char ranges and the corresponding hover |
| 1408 | // handler for each char range |
| 1409 | hover_handlers: Vec<HoverableCharRange>, |
| 1410 | glyph_offset: CharOffset, |
| 1411 | byte_offset: ByteOffset, |
| 1412 | secret_replacement: Vec<(SecretRange, Cow<'static, str>)>, |
| 1413 | styles: Vec<HighlightedRange>, |
| 1414 | } |
| 1415 | |
| 1416 | impl FrameMouseHandlers { |
| 1417 | fn add_offset(&mut self, offset: CharOffset, byte_offset: ByteOffset) { |
| 1418 | self.glyph_offset = offset; |
| 1419 | self.byte_offset = byte_offset; |
| 1420 | } |
| 1421 | |
| 1422 | /// Returns `true` if any of the hoverable ranges was hovered. |
| 1423 | fn unhover_all(&mut self, ctx: &mut EventContext, app: &AppContext) -> bool { |
| 1424 | let mut any_hovered = false; |
| 1425 | self.hover_handlers.iter_mut().for_each(|hoverable_range| { |
| 1426 | let hovered = hoverable_range.mouse_state().is_hovered(); |
| 1427 | any_hovered |= hovered; |
| 1428 | if hovered { |
| 1429 | let handler = hoverable_range.hover_handler.as_mut(); |
| 1430 | handler(false, ctx, app); |
| 1431 | hoverable_range.mouse_state().is_hovered = false; |
| 1432 | } |
| 1433 | }); |
| 1434 | any_hovered |
| 1435 | } |
| 1436 | |
| 1437 | fn add_style(&mut self, style: HighlightedRange) { |
| 1438 | self.styles.push(style); |
| 1439 | } |
| 1440 | } |
| 1441 | |
| 1442 | static SECRET_REPLACEMENT_OOB_ONCE: Once = Once::new(); |
| 1443 | |
| 1444 | impl PartialClickableElement for FrameMouseHandlers { |
| 1445 | fn with_clickable_char_range<F>( |
| 1446 | mut self, |
| 1447 | clickable_char_range: Range<usize>, |
| 1448 | callback: F, |
| 1449 | ) -> Self |
| 1450 | where |
| 1451 | F: 'static + FnMut(&ModifiersState, &mut EventContext, &AppContext), |
| 1452 | { |
| 1453 | self.click_handlers.push(ClickableCharRange { |
| 1454 | char_range: clickable_char_range, |
| 1455 | click_handler: Box::new(callback), |
| 1456 | }); |
| 1457 | self |
| 1458 | } |
| 1459 | |
| 1460 | fn with_hoverable_char_range<F>( |
| 1461 | mut self, |
| 1462 | hoverable_char_range: Range<usize>, |
| 1463 | mouse_state: MouseStateHandle, |
| 1464 | cursor_on_hover: Option<Cursor>, |
| 1465 | callback: F, |
| 1466 | ) -> Self |
| 1467 | where |
| 1468 | F: 'static + FnMut(bool, &mut EventContext, &AppContext), |
| 1469 | { |
| 1470 | self.hover_handlers.push(HoverableCharRange { |
| 1471 | char_range: hoverable_char_range, |
| 1472 | hover_handler: Box::new(callback), |
| 1473 | cursor_on_hover, |
| 1474 | mouse_state, |
| 1475 | }); |
| 1476 | self |
| 1477 | } |
| 1478 | |
| 1479 | fn replace_text_range(&mut self, range: SecretRange, replacement: Cow<'static, str>) { |
| 1480 | self.secret_replacement.push((range, replacement)); |
| 1481 | } |
| 1482 | } |
| 1483 | |
| 1484 | /// Applies secret replacements to the given text using char indices adjusted by glyph_offset. |
| 1485 | /// Replacements are applied in descending order of start position to avoid shifting ranges. |
| 1486 | fn apply_secret_replacements( |
| 1487 | text: &mut String, |
| 1488 | glyph_offset: usize, |
| 1489 | secret_replacements: &[(SecretRange, Cow<'static, str>)], |
| 1490 | ) { |
| 1491 | let mut replacements = secret_replacements.to_vec(); |
| 1492 | replacements.sort_by_key(|(range, _)| Reverse(range.char_range.start)); |
| 1493 | |
| 1494 | let total_chars = text.chars().count(); |
| 1495 | let mut out_of_bound_message: Option<String> = None; |
| 1496 | |
| 1497 | for (range, replacement) in replacements.iter() { |
| 1498 | let start_char = range.char_range.start + glyph_offset; |
| 1499 | let end_char = range.char_range.end + glyph_offset; |
| 1500 | |
| 1501 | if start_char >= end_char { |
| 1502 | continue; |
| 1503 | } |
| 1504 | if start_char > total_chars { |
| 1505 | out_of_bound_message = Some(format!( |
| 1506 | "Secret redaction OOB: char start beyond length. range={:?}, start_char={}, end_char={}, total_chars={}, byte_len={}", |
| 1507 | start_char..end_char, |
| 1508 | start_char, |
| 1509 | end_char, |
| 1510 | total_chars, |
| 1511 | text.len() |
| 1512 | )); |
| 1513 | continue; |
| 1514 | } |
| 1515 | |
| 1516 | let start_byte = if start_char == total_chars { |
| 1517 | text.len() |
| 1518 | } else if let Some((byte_idx, _)) = text.char_indices().nth(start_char) { |
| 1519 | byte_idx |
| 1520 | } else { |
| 1521 | out_of_bound_message = Some(format!( |
| 1522 | "Secret redaction OOB: failed to map start_char to byte index. range={:?}, start_char={}, end_char={}, total_chars={}, byte_len={}", |
| 1523 | start_char..end_char, |
| 1524 | start_char, |
| 1525 | end_char, |
| 1526 | total_chars, |
| 1527 | text.len() |
| 1528 | )); |
| 1529 | continue; |
| 1530 | }; |
| 1531 | |
| 1532 | let end_byte = if end_char >= total_chars { |
| 1533 | text.len() |
| 1534 | } else if let Some((byte_idx, _)) = text.char_indices().nth(end_char) { |
| 1535 | byte_idx |
| 1536 | } else { |
| 1537 | out_of_bound_message = Some(format!( |
| 1538 | "Secret redaction OOB: failed to map end_char to byte index. range={:?}, start_char={}, end_char={}, total_chars={}, byte_len={}", |
| 1539 | start_char..end_char, |
| 1540 | start_char, |
| 1541 | end_char, |
| 1542 | total_chars, |
| 1543 | text.len() |
| 1544 | )); |
| 1545 | continue; |
| 1546 | }; |
| 1547 | |
| 1548 | if start_byte > end_byte || end_byte > text.len() { |
| 1549 | out_of_bound_message = Some(format!( |
| 1550 | "Secret redaction OOB: byte range invalid. start_byte={}, end_byte={}, byte_len={}, start_char={}, end_char={}, total_chars={}", |
| 1551 | start_byte, |
| 1552 | end_byte, |
| 1553 | text.len(), |
| 1554 | start_char, |
| 1555 | end_char, |
| 1556 | total_chars |
| 1557 | )); |
| 1558 | continue; |
| 1559 | } |
| 1560 | |
| 1561 | text.replace_range(start_byte..end_byte, replacement); |
| 1562 | } |
| 1563 | |
| 1564 | if let Some(msg) = out_of_bound_message { |
| 1565 | SECRET_REPLACEMENT_OOB_ONCE.call_once(|| log::error!("{msg}")); |
| 1566 | } |
| 1567 | } |
| 1568 | |
| 1569 | #[derive(Debug, Clone, Eq, PartialEq)] |
| 1570 | enum LineType { |
| 1571 | OrderedList, |
| 1572 | UnorderedList, |
| 1573 | FormattedLine, |
| 1574 | CodeBlock, |
| 1575 | LineBreak, |
| 1576 | } |
| 1577 | |
| 1578 | impl From<&FormattedTextLine> for LineType { |
| 1579 | fn from(line: &FormattedTextLine) -> Self { |
| 1580 | match line { |
| 1581 | FormattedTextLine::OrderedList(_) => LineType::OrderedList, |
| 1582 | FormattedTextLine::UnorderedList(_) => LineType::UnorderedList, |
| 1583 | FormattedTextLine::Heading(_) |
| 1584 | | FormattedTextLine::Line(_) |
| 1585 | | FormattedTextLine::TaskList(_) |
| 1586 | | FormattedTextLine::Table(_) => LineType::FormattedLine, |
| 1587 | FormattedTextLine::CodeBlock(_) => LineType::CodeBlock, |
| 1588 | FormattedTextLine::LineBreak |
| 1589 | | FormattedTextLine::HorizontalRule |
| 1590 | | FormattedTextLine::Embedded(_) |
| 1591 | | FormattedTextLine::Image(_) => LineType::LineBreak, |
| 1592 | } |
| 1593 | } |
| 1594 | } |
| 1595 | |
| 1596 | impl Element for FormattedTextElement { |
| 1597 | fn layout( |
| 1598 | &mut self, |
| 1599 | constraint: SizeConstraint, |
| 1600 | ctx: &mut LayoutContext, |
| 1601 | app: &AppContext, |
| 1602 | ) -> Vector2F { |
| 1603 | self.laid_out_text = vec![]; |
| 1604 | let max_width = constraint.max_along(Axis::Horizontal); |
| 1605 | let max_height = constraint.max_along(Axis::Vertical); |
| 1606 | |
| 1607 | // Frame width should at least be the minimum constraint width. |
| 1608 | let mut frame_width = constraint.min.x(); |
| 1609 | let mut frame_height = 0.; |
| 1610 | let mut should_expand_to_max_width = false; |
| 1611 | |
| 1612 | // Render text line-by-line. |
| 1613 | let mut lines = self.formatted_text.lines.iter().enumerate().peekable(); |
| 1614 | let mut last_line_was_list_item = false; |
| 1615 | let mut list_numbering = ListNumbering::new(); |
| 1616 | |
| 1617 | // We don't use the frame_index from lines.next() because this creates inconsistencies in the frame index when we have lists |
| 1618 | let mut frame_index = 0; |
| 1619 | while let Some((line_index, line)) = lines.next() { |
| 1620 | let mut res = Vec::new(); |
| 1621 | let (font_size, texts, indent, line_type) = match line { |
| 1622 | FormattedTextLine::Heading(header) => ( |
| 1623 | self.heading_to_font_size_multipliers |
| 1624 | .get_multiplier(header.heading_size.into()) |
| 1625 | * self.font_size, |
| 1626 | &header.text, |
| 1627 | 0, |
| 1628 | LineType::FormattedLine, |
| 1629 | ), |
| 1630 | FormattedTextLine::Line(texts) => { |
| 1631 | (self.font_size, texts, 0, LineType::FormattedLine) |
| 1632 | } |
| 1633 | // TODO: Update when we support task lists. |
| 1634 | FormattedTextLine::TaskList(list) => { |
| 1635 | (self.font_size, &list.text, 0, LineType::FormattedLine) |
| 1636 | } |
| 1637 | // Increment indent_level by 1 since even if no indent is given, still need to format list to the right. |
| 1638 | FormattedTextLine::OrderedList(texts) => ( |
| 1639 | self.font_size, |
| 1640 | &texts.indented_text.text, |
| 1641 | texts.indented_text.indent_level + 1, |
| 1642 | LineType::OrderedList, |
| 1643 | ), |
| 1644 | FormattedTextLine::UnorderedList(texts) => ( |
| 1645 | self.font_size, |
| 1646 | &texts.text, |
| 1647 | texts.indent_level + 1, |
| 1648 | LineType::UnorderedList, |
| 1649 | ), |
| 1650 | FormattedTextLine::CodeBlock(texts) => { |
| 1651 | // If there are any code block lines, this element should expand to max width. |
| 1652 | // This is because we want the code block background to take up the full width even |
| 1653 | // if the text itself is very short. |
| 1654 | should_expand_to_max_width = true; |
| 1655 | // Add a line of padding before and after the code body. |
| 1656 | let new_line = "\n".to_owned(); |
| 1657 | let formatted = new_line.clone() + &texts.code + &new_line; |
| 1658 | |
| 1659 | // TODO: In the future, can use the first parameter (the lang field) to modify the actual style of the text fragment. |
| 1660 | res.push(FormattedTextFragment::plain_text(formatted)); |
| 1661 | |
| 1662 | (self.font_size, &res, 1, LineType::CodeBlock) |
| 1663 | } |
| 1664 | FormattedTextLine::Table(table) => { |
| 1665 | res.push(FormattedTextFragment::plain_text(table.to_plain_text())); |
| 1666 | (self.font_size, &res, 0, LineType::FormattedLine) |
| 1667 | } |
| 1668 | FormattedTextLine::LineBreak |
| 1669 | | FormattedTextLine::HorizontalRule |
| 1670 | | FormattedTextLine::Embedded(_) |
| 1671 | | FormattedTextLine::Image(_) => (self.font_size, &res, 0, LineType::LineBreak), |
| 1672 | }; |
| 1673 | |
| 1674 | // Appends either the number or bullet type in the case of list based on the indent. |
| 1675 | let mut text = match line { |
| 1676 | FormattedTextLine::OrderedList(texts) => { |
| 1677 | format!( |
| 1678 | "{}. ", |
| 1679 | list_numbering |
| 1680 | // Subtracting by 1 as `indent` of lists starts at 1, |
| 1681 | // but list_numbering expects it to start at 0. |
| 1682 | .advance(indent.saturating_sub(1), Some(texts.number)) |
| 1683 | .display_label |
| 1684 | ) |
| 1685 | } |
| 1686 | |
| 1687 | FormattedTextLine::UnorderedList(_) => { |
| 1688 | let bullet = match indent { |
| 1689 | 1 => FULL_BULLET, |
| 1690 | 2 => EMPTY_BULLET, |
| 1691 | _ => SQUARE_BULLET, |
| 1692 | }; |
| 1693 | // Align with the ordered list - assuming the ordered list indices are only single digits. |
| 1694 | format!("{bullet:<3}") |
| 1695 | } |
| 1696 | _ => String::new(), |
| 1697 | }; |
| 1698 | // Offset to account for the bullet or number. |
| 1699 | let glyph_offset = text.chars().count(); |
| 1700 | let byte_offset = text.len(); |
| 1701 | |
| 1702 | // Reset ordered list numbering if this item isn't an ordered list. |
| 1703 | if !matches!(line, FormattedTextLine::OrderedList(_)) { |
| 1704 | list_numbering.reset(); |
| 1705 | } |
| 1706 | |
| 1707 | // Keep a vec of running styles and the range of indices they are decorating. |
| 1708 | let mut styles = vec![]; |
| 1709 | |
| 1710 | // If there is a prefix (e.g. bullet points, numbers, etc), accounts for the style of it which will be default. |
| 1711 | if !text.is_empty() { |
| 1712 | let style = match line { |
| 1713 | // Round bullets are a bit small comparing to the square bullet, so we bold them to increase their size. |
| 1714 | FormattedTextLine::UnorderedList(_) if indent == 1 || indent == 2 => { |
| 1715 | Properties::default().weight(Weight::Bold) |
| 1716 | } |
| 1717 | _ => Properties::default(), |
| 1718 | }; |
| 1719 | styles.push(( |
| 1720 | 0..glyph_offset, |
| 1721 | StyleAndFont::new(self.family_id, style, TextStyle::new()), |
| 1722 | )); |
| 1723 | } |
| 1724 | |
| 1725 | // Scope to contain a borrow of the mouse handlers. |
| 1726 | { |
| 1727 | // Preserves formatting for the innner text in case of list. |
| 1728 | let mut prev_index = glyph_offset; |
| 1729 | |
| 1730 | let borrowed_handler = self |
| 1731 | .text_frame_mouse_handlers |
| 1732 | .get(line_index) |
| 1733 | .map(|handler| handler.borrow()); |
| 1734 | let mut link_styles_iter = if let Some(borrowed_handler) = &borrowed_handler { |
| 1735 | borrowed_handler.styles.iter() |
| 1736 | } else { |
| 1737 | [].iter() |
| 1738 | } |
| 1739 | .peekable(); |
| 1740 | |
| 1741 | let mut current_link_style: Option<HighlightedRange> = None; |
| 1742 | |
| 1743 | for inline in texts { |
| 1744 | let fragment_char_count = inline.text.chars().count(); |
| 1745 | let mut character_count = 0; |
| 1746 | |
| 1747 | while character_count < fragment_char_count { |
| 1748 | let mut style = Properties::default(); |
| 1749 | let mut text_style = TextStyle::default(); |
| 1750 | |
| 1751 | if let Some(style) = link_styles_iter.peek() { |
| 1752 | if style.highlight_indices[0] + glyph_offset |
| 1753 | == prev_index + character_count |
| 1754 | { |
| 1755 | current_link_style = Some((*style).clone()); |
| 1756 | link_styles_iter.next(); |
| 1757 | } |
| 1758 | } |
| 1759 | |
| 1760 | let start_char_index = prev_index + character_count; |
| 1761 | let style_char_len; |
| 1762 | |
| 1763 | if let Some(link_style) = ¤t_link_style { |
| 1764 | text_style = link_style.highlight.text_style; |
| 1765 | let end_char_index = *link_style.highlight_indices.last().unwrap_or(&0) |
| 1766 | + 1 |
| 1767 | + glyph_offset; |
| 1768 | if end_char_index - start_char_index |
| 1769 | <= fragment_char_count - character_count |
| 1770 | { |
| 1771 | // If the length of the currently registered link style is less than the remaining length of the current fragment, |
| 1772 | // then the style should be applied to the link only. |
| 1773 | style_char_len = end_char_index - start_char_index; |
| 1774 | // Reset the current link style since it has been fully applied. |
| 1775 | current_link_style = None; |
| 1776 | } else { |
| 1777 | // If the length of the currently registered link style is greater than the remaining length of the current fragment, |
| 1778 | // then the style should be applied to the entire fragment. |
| 1779 | style_char_len = fragment_char_count - character_count; |
| 1780 | // We keep the current_link_style, as it might overflow to the next fragment. |
| 1781 | } |
| 1782 | } else if let Some(style) = link_styles_iter.peek() { |
| 1783 | // If we are not currently working with a link style, then the length of the current style should be the minimum |
| 1784 | // of the remaining length of the fragment or the remaining length until the next link style within the fragment. |
| 1785 | style_char_len = (style.highlight_indices[0] + glyph_offset |
| 1786 | - start_char_index) |
| 1787 | .min(fragment_char_count - character_count); |
| 1788 | } else { |
| 1789 | // If there's no more link styles, then the length of the current style should be the remaining length of the fragment. |
| 1790 | style_char_len = fragment_char_count - character_count; |
| 1791 | } |
| 1792 | |
| 1793 | if inline.styles.weight.is_some() { |
| 1794 | style.weight = Weight::from_custom_weight(inline.styles.weight); |
| 1795 | } |
| 1796 | if inline.styles.italic { |
| 1797 | style.style = Style::Italic; |
| 1798 | } |
| 1799 | if inline.styles.strikethrough { |
| 1800 | text_style = text_style.with_show_strikethrough(true); |
| 1801 | } |
| 1802 | if inline.styles.underline { |
| 1803 | let underline_color = |
| 1804 | text_style.foreground_color.unwrap_or(self.text_color); |
| 1805 | text_style = text_style.with_underline_color(underline_color) |
| 1806 | } |
| 1807 | if inline.styles.inline_code { |
| 1808 | // If we have existing background and foreground highlighting from, for example, |
| 1809 | // a link or a search, we don't want to override it. |
| 1810 | if let Some(font_color) = self.inline_code_font_color { |
| 1811 | if text_style.foreground_color.is_none() { |
| 1812 | text_style.foreground_color = Some(font_color); |
| 1813 | } |
| 1814 | } |
| 1815 | if let Some(bg_color) = self.inline_code_bg_color { |
| 1816 | if text_style.background_color.is_none() { |
| 1817 | text_style.background_color = Some(bg_color); |
| 1818 | } |
| 1819 | } |
| 1820 | } |
| 1821 | |
| 1822 | let font_family_id = if matches!(line, FormattedTextLine::CodeBlock(_)) |
| 1823 | || inline.styles.inline_code |
| 1824 | { |
| 1825 | self.code_block_family_id |
| 1826 | } else { |
| 1827 | self.family_id |
| 1828 | }; |
| 1829 | |
| 1830 | styles.push(( |
| 1831 | start_char_index..start_char_index + style_char_len, |
| 1832 | StyleAndFont::new(font_family_id, style, text_style), |
| 1833 | )); |
| 1834 | |
| 1835 | character_count += style_char_len; |
| 1836 | } |
| 1837 | prev_index += character_count; |
| 1838 | text.push_str(&inline.text); |
| 1839 | // TODO: ensure the constructed text is the same as `line.raw_text()` (test?) |
| 1840 | } |
| 1841 | } |
| 1842 | |
| 1843 | // Since list items are flattened into multiple lines rather than a single list segment, |
| 1844 | // this ends up causing line breaks between each individual list item. To avoid this, |
| 1845 | // we only show the line break if we already started a list and there isn't a following list item. |
| 1846 | let curr_line_is_line_break = matches!(line_type, LineType::LineBreak); |
| 1847 | let next_line_is_list_item = matches!( |
| 1848 | lines.peek(), |
| 1849 | Some((_, FormattedTextLine::UnorderedList(_))) |
| 1850 | | Some((_, FormattedTextLine::OrderedList(_))) |
| 1851 | ); |
| 1852 | if last_line_was_list_item && curr_line_is_line_break && next_line_is_list_item { |
| 1853 | continue; |
| 1854 | } |
| 1855 | |
| 1856 | if let Some(handler) = self.text_frame_mouse_handlers.get(line_index) { |
| 1857 | let mut handler = handler.borrow_mut(); |
| 1858 | if glyph_offset > 0 { |
| 1859 | handler.add_offset(glyph_offset.into(), byte_offset.into()); |
| 1860 | } |
| 1861 | apply_secret_replacements( |
| 1862 | &mut text, |
| 1863 | glyph_offset, |
| 1864 | handler.secret_replacement.as_slice(), |
| 1865 | ); |
| 1866 | } |
| 1867 | |
| 1868 | // Indent should only be considered with left text alignment. This matches the behavior |
| 1869 | // of other text editors (Google Docs). |
| 1870 | let should_layout_with_indent = line_type != LineType::FormattedLine |
| 1871 | && matches!(self.alignment, TextAlignment::Left); |
| 1872 | |
| 1873 | let text_frame_width = if self.disable_text_wrapping && self.clip_config.is_none() { |
| 1874 | // Use a very large width to prevent text wrapping. |
| 1875 | f32::MAX |
| 1876 | } else if should_layout_with_indent { |
| 1877 | (max_width - font_size * indent as f32).max(0.) |
| 1878 | } else { |
| 1879 | max_width |
| 1880 | }; |
| 1881 | |
| 1882 | // If clip_config is set we layout a line, otherwise we use layout_text which uses a TextFrame for soft-wrapping. |
| 1883 | let text_frame = if let Some(clip_config) = self.clip_config { |
| 1884 | let line = ctx.text_layout_cache.layout_line( |
| 1885 | &text, |
| 1886 | LineStyle { |
| 1887 | font_size, |
| 1888 | line_height_ratio: self.line_height_ratio, |
| 1889 | baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO, |
| 1890 | fixed_width_tab_size: matches!(line, FormattedTextLine::CodeBlock(_)) |
| 1891 | .then_some(4), |
| 1892 | }, |
| 1893 | styles.as_slice(), |
| 1894 | text_frame_width, |
| 1895 | clip_config, |
| 1896 | &app.font_cache().text_layout_system(), |
| 1897 | ); |
| 1898 | |
| 1899 | Arc::new(TextFrame::new( |
| 1900 | vec1![(*line).clone()], |
| 1901 | text_frame_width, |
| 1902 | self.alignment, |
| 1903 | )) |
| 1904 | } else { |
| 1905 | ctx.text_layout_cache.layout_text( |
| 1906 | &text, |
| 1907 | LineStyle { |
| 1908 | font_size, |
| 1909 | line_height_ratio: self.line_height_ratio, |
| 1910 | baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO, |
| 1911 | fixed_width_tab_size: matches!(line, FormattedTextLine::CodeBlock(_)) |
| 1912 | .then_some(4), |
| 1913 | }, |
| 1914 | styles.as_slice(), |
| 1915 | text_frame_width, |
| 1916 | max_height, |
| 1917 | self.alignment, |
| 1918 | None, |
| 1919 | &app.font_cache().text_layout_system(), |
| 1920 | ) |
| 1921 | }; |
| 1922 | |
| 1923 | // Adjust frame width if there is an indent. |
| 1924 | frame_width = frame_width.max(if should_layout_with_indent { |
| 1925 | text_frame.max_width() + font_size * indent as f32 |
| 1926 | } else { |
| 1927 | text_frame.max_width() |
| 1928 | }); |
| 1929 | |
| 1930 | // Give certain lines additional breathing room underneath. This padding is only added |
| 1931 | // if there's more formatted text below, and omitted for the last line of text. |
| 1932 | let bottom_padding = if let Some((_, next_line)) = lines.peek() { |
| 1933 | match line { |
| 1934 | FormattedTextLine::Heading(_) => match next_line { |
| 1935 | // If the heading is followed by a line break, don't add any |
| 1936 | // additional padding (as the line break will take care of it). |
| 1937 | FormattedTextLine::LineBreak => 0., |
| 1938 | _ => LINE_BREAK_HEIGHT, |
| 1939 | }, |
| 1940 | FormattedTextLine::OrderedList(_) | FormattedTextLine::UnorderedList(_) |
| 1941 | if !next_line_is_list_item => |
| 1942 | { |
| 1943 | LINE_BREAK_HEIGHT |
| 1944 | } |
| 1945 | FormattedTextLine::CodeBlock(_) => LINE_BREAK_HEIGHT, |
| 1946 | _ => 0., |
| 1947 | } |
| 1948 | } else { |
| 1949 | 0. |
| 1950 | }; |
| 1951 | |
| 1952 | // The saved_glyph_positions vector should be relatively small, so the performance should be acceptable. |
| 1953 | self.saved_glyph_positions |
| 1954 | .iter_mut() |
| 1955 | .for_each(|saved_glyph_position| { |
| 1956 | if let SavedGlyphPosition::FormattedTextLinePosition(pos) = |
| 1957 | saved_glyph_position.position |
| 1958 | { |
| 1959 | if pos.frame_index != line_index { |
| 1960 | return; |
| 1961 | } |
| 1962 | |
| 1963 | let mut row_index = 0; |
| 1964 | let mut glyph_accum = 0; |
| 1965 | for row in text_frame.lines() { |
| 1966 | if row.end_index() > (pos.glyph_index + glyph_offset) - glyph_accum { |
| 1967 | break; |
| 1968 | } |
| 1969 | row_index += 1; |
| 1970 | glyph_accum += row.end_index(); |
| 1971 | } |
| 1972 | |
| 1973 | saved_glyph_position.position = |
| 1974 | SavedGlyphPosition::LaidOutTextFramePosition( |
| 1975 | FormattedTextSelectionLocation { |
| 1976 | frame_index, |
| 1977 | row_index, |
| 1978 | glyph_index: pos.glyph_index + glyph_offset, |
| 1979 | }, |
| 1980 | ); |
| 1981 | } |
| 1982 | }); |
| 1983 | |
| 1984 | let laid_out_frame = match line_type { |
| 1985 | LineType::FormattedLine => LaidOutTextFrame::Text { |
| 1986 | text_frame, |
| 1987 | frame_bounds: RectF::default(), |
| 1988 | bottom_padding, |
| 1989 | raw_text: text, |
| 1990 | mouse_handlers: self |
| 1991 | .text_frame_mouse_handlers |
| 1992 | .get(line_index) |
| 1993 | .cloned() |
| 1994 | .unwrap_or(Rc::new(RefCell::new(FrameMouseHandlers::default()))), |
| 1995 | }, |
| 1996 | LineType::CodeBlock => LaidOutTextFrame::CodeBlock { |
| 1997 | text_frame, |
| 1998 | frame_bounds: RectF::default(), |
| 1999 | bottom_padding, |
| 2000 | raw_text: text, |
| 2001 | }, |
| 2002 | LineType::OrderedList | LineType::UnorderedList => LaidOutTextFrame::Indented { |
| 2003 | text_frame, |
| 2004 | indent, |
| 2005 | frame_bounds: RectF::default(), |
| 2006 | top_padding: FRAME_SPACER_HEIGHT, |
| 2007 | bottom_padding, |
| 2008 | left_padding: font_size * indent as f32, |
| 2009 | raw_text: text, |
| 2010 | mouse_handlers: self |
| 2011 | .text_frame_mouse_handlers |
| 2012 | .get(line_index) |
| 2013 | .cloned() |
| 2014 | .unwrap_or(Rc::new(RefCell::new(FrameMouseHandlers::default()))), |
| 2015 | }, |
| 2016 | LineType::LineBreak => LaidOutTextFrame::LineBreak { |
| 2017 | frame_bounds: RectF::default(), |
| 2018 | }, |
| 2019 | }; |
| 2020 | |
| 2021 | frame_height += laid_out_frame.calculate_frame_height(); |
| 2022 | self.laid_out_text.push(laid_out_frame); |
| 2023 | |
| 2024 | last_line_was_list_item = |
| 2025 | matches!(line_type, LineType::OrderedList | LineType::UnorderedList); |
| 2026 | |
| 2027 | frame_index += 1; |
| 2028 | } |
| 2029 | |
| 2030 | let mut size = vec2f(frame_width, frame_height); |
| 2031 | if should_expand_to_max_width { |
| 2032 | if max_width.is_infinite() { |
| 2033 | panic!("The max width was infinite when stretch set to true"); |
| 2034 | } |
| 2035 | size.set_x(max_width); |
| 2036 | } |
| 2037 | self.size = Some(size); |
| 2038 | size |
| 2039 | } |
| 2040 | |
| 2041 | fn after_layout(&mut self, _: &mut AfterLayoutContext, _: &AppContext) {} |
| 2042 | |
| 2043 | fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, _: &AppContext) { |
| 2044 | self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index())); |
| 2045 | let mut mut_origin = origin; |
| 2046 | let size = self.size().expect("Expected size to not be none"); |
| 2047 | |
| 2048 | for saved_glyph_position in self.saved_glyph_positions.iter() { |
| 2049 | let SavedGlyphPosition::LaidOutTextFramePosition(pos) = saved_glyph_position.position |
| 2050 | else { |
| 2051 | continue; |
| 2052 | }; |
| 2053 | let Some(laid_out_frame) = self.laid_out_text.get(pos.frame_index) else { |
| 2054 | continue; |
| 2055 | }; |
| 2056 | |
| 2057 | let frame = match laid_out_frame { |
| 2058 | LaidOutTextFrame::Text { text_frame, .. } |
| 2059 | | LaidOutTextFrame::Indented { text_frame, .. } |
| 2060 | | LaidOutTextFrame::CodeBlock { text_frame, .. } => text_frame, |
| 2061 | _ => continue, |
| 2062 | }; |
| 2063 | let Some(line) = frame.lines().get(pos.row_index) else { |
| 2064 | continue; |
| 2065 | }; |
| 2066 | |
| 2067 | let Some(glyph_width) = line.width_for_index(pos.glyph_index) else { |
| 2068 | continue; |
| 2069 | }; |
| 2070 | |
| 2071 | let x_offset = if let LaidOutTextFrame::Indented { left_padding, .. } = laid_out_frame { |
| 2072 | *left_padding |
| 2073 | } else { |
| 2074 | 0. |
| 2075 | } + line.x_for_index(pos.glyph_index); |
| 2076 | |
| 2077 | let y_offset = self |
| 2078 | .laid_out_text |
| 2079 | .iter() |
| 2080 | .take(pos.frame_index) |
| 2081 | .map(|frame| frame.calculate_frame_height()) |
| 2082 | .sum::<f32>() |
| 2083 | + frame |
| 2084 | .lines() |
| 2085 | .iter() |
| 2086 | .take(pos.row_index) |
| 2087 | .map(|line| line.height()) |
| 2088 | .sum::<f32>(); |
| 2089 | |
| 2090 | ctx.position_cache.cache_position_indefinitely( |
| 2091 | saved_glyph_position.position_id.clone(), |
| 2092 | RectF::new( |
| 2093 | origin + vec2f(x_offset, y_offset), |
| 2094 | vec2f(glyph_width, line.height()), |
| 2095 | ), |
| 2096 | ); |
| 2097 | } |
| 2098 | |
| 2099 | // Add indent by moving origin point of the frame. |
| 2100 | for laid_out_frame in &mut self.laid_out_text { |
| 2101 | // Get the x-offset for this laid out frame. |
| 2102 | let x_offset = laid_out_frame.calculate_x_offset( |
| 2103 | self.font_size, |
| 2104 | mut_origin.x(), |
| 2105 | self.alignment, |
| 2106 | size.x(), |
| 2107 | ); |
| 2108 | let frame_height = laid_out_frame.calculate_frame_height(); |
| 2109 | |
| 2110 | let (frame, frame_height, bounds) = match laid_out_frame { |
| 2111 | LaidOutTextFrame::Text { |
| 2112 | text_frame, |
| 2113 | frame_bounds, |
| 2114 | bottom_padding, |
| 2115 | .. |
| 2116 | } => { |
| 2117 | let curr_origin = vec2f(x_offset, mut_origin.y()); |
| 2118 | *frame_bounds = RectF::new( |
| 2119 | curr_origin, |
| 2120 | Vector2F::new(size.x(), frame_height - *bottom_padding), |
| 2121 | ); |
| 2122 | (Some(text_frame), frame_height, *frame_bounds) |
| 2123 | } |
| 2124 | LaidOutTextFrame::Indented { |
| 2125 | text_frame, |
| 2126 | frame_bounds, |
| 2127 | top_padding, |
| 2128 | bottom_padding, |
| 2129 | .. |
| 2130 | } => { |
| 2131 | let curr_origin = vec2f(x_offset, mut_origin.y() + *top_padding); |
| 2132 | *frame_bounds = RectF::new( |
| 2133 | curr_origin, |
| 2134 | Vector2F::new(size.x(), frame_height - *top_padding - *bottom_padding), |
| 2135 | ); |
| 2136 | (Some(text_frame), frame_height, *frame_bounds) |
| 2137 | } |
| 2138 | LaidOutTextFrame::CodeBlock { |
| 2139 | text_frame, |
| 2140 | frame_bounds, |
| 2141 | bottom_padding, |
| 2142 | .. |
| 2143 | } => { |
| 2144 | let curr_origin = vec2f(x_offset, mut_origin.y()); |
| 2145 | |
| 2146 | #[cfg(debug_assertions)] |
| 2147 | ctx.scene |
| 2148 | .set_location_for_panic_logging(self.constructor_location); |
| 2149 | |
| 2150 | // Draw code block rectangle. |
| 2151 | ctx.scene |
| 2152 | .draw_rect_with_hit_recording(RectF::new( |
| 2153 | curr_origin, |
| 2154 | vec2f(size.x(), frame_height - *bottom_padding), |
| 2155 | )) |
| 2156 | .with_background(Fill::Solid(ColorU::from_u32(CODE_BLOCK_BACKGROUND))) |
| 2157 | .with_corner_radius(CornerRadius::with_all(Radius::Pixels(10.))); |
| 2158 | |
| 2159 | *frame_bounds = RectF::new( |
| 2160 | curr_origin, |
| 2161 | Vector2F::new(size.x(), frame_height - *bottom_padding), |
| 2162 | ); |
| 2163 | (Some(text_frame), frame_height, *frame_bounds) |
| 2164 | } |
| 2165 | LaidOutTextFrame::LineBreak { frame_bounds } => { |
| 2166 | let curr_origin = vec2f(x_offset, mut_origin.y()); |
| 2167 | *frame_bounds = RectF::new(curr_origin, Vector2F::new(size.x(), frame_height)); |
| 2168 | (None, LINE_BREAK_HEIGHT, *frame_bounds) |
| 2169 | } |
| 2170 | }; |
| 2171 | |
| 2172 | if let Some(frame) = frame { |
| 2173 | frame.paint( |
| 2174 | bounds, |
| 2175 | &Default::default(), |
| 2176 | self.text_color, |
| 2177 | ctx.scene, |
| 2178 | ctx.font_cache, |
| 2179 | ); |
| 2180 | } |
| 2181 | mut_origin += vec2f(0., frame_height); |
| 2182 | } |
| 2183 | |
| 2184 | // Draw selection if there is one |
| 2185 | if let Some(point_ranges) = self.calculate_point_ranges(ctx.current_selection) { |
| 2186 | for (start, end) in point_ranges { |
| 2187 | self.draw_selection(start, end, ctx); |
| 2188 | } |
| 2189 | } |
| 2190 | } |
| 2191 | |
| 2192 | fn size(&self) -> Option<Vector2F> { |
| 2193 | self.size |
| 2194 | } |
| 2195 | |
| 2196 | fn dispatch_event( |
| 2197 | &mut self, |
| 2198 | event: &DispatchedEvent, |
| 2199 | ctx: &mut EventContext, |
| 2200 | app: &AppContext, |
| 2201 | ) -> bool { |
| 2202 | let Some(z_index) = self.z_index() else { |
| 2203 | return false; |
| 2204 | }; |
| 2205 | match event.at_z_index(z_index, ctx) { |
| 2206 | Some(Event::MouseMoved { position, .. }) => { |
| 2207 | let link_pos_before = self |
| 2208 | .hyperlink_support |
| 2209 | .highlighted_hyperlink |
| 2210 | .lock() |
| 2211 | .expect("Failed to acquire lock on highlighted_hyperlink") |
| 2212 | .clone(); |
| 2213 | let result = self.handle_mouse_moved(*position, z_index, ctx, app); |
| 2214 | let link_pos_after = self |
| 2215 | .hyperlink_support |
| 2216 | .highlighted_hyperlink |
| 2217 | .lock() |
| 2218 | .expect("Failed to acquire lock on highlighted_hyperlink") |
| 2219 | .clone(); |
| 2220 | |
| 2221 | if link_pos_before != link_pos_after { |
| 2222 | ctx.notify(); |
| 2223 | } |
| 2224 | result |
| 2225 | } |
| 2226 | Some(Event::LeftMouseDown { |
| 2227 | position, |
| 2228 | modifiers, |
| 2229 | .. |
| 2230 | }) => { |
| 2231 | self.handle_mouse_down(*position, z_index, modifiers, ctx, app); |
| 2232 | false |
| 2233 | } |
| 2234 | _ => false, |
| 2235 | } |
| 2236 | |
| 2237 | // false |
| 2238 | } |
| 2239 | |
| 2240 | fn origin(&self) -> Option<Point> { |
| 2241 | self.origin |
| 2242 | } |
| 2243 | |
| 2244 | fn as_selectable_element(&self) -> Option<&dyn SelectableElement> { |
| 2245 | if self.is_selectable { |
| 2246 | Some(self) |
| 2247 | } else { |
| 2248 | None |
| 2249 | } |
| 2250 | } |
| 2251 | } |
| 2252 | |
| 2253 | impl SelectableElement for FormattedTextElement { |
| 2254 | fn get_selection( |
| 2255 | &self, |
| 2256 | selection_start: Vector2F, |
| 2257 | selection_end: Vector2F, |
| 2258 | is_rect: IsRect, |
| 2259 | ) -> Option<Vec<SelectionFragment>> { |
| 2260 | let mut start_bound = |
| 2261 | self.position_for_point(selection_start, SnappingPolicy::default().snap_on_gap())?; |
| 2262 | let mut end_bound = |
| 2263 | self.position_for_point(selection_end, SnappingPolicy::default().snap_on_gap())?; |
| 2264 | |
| 2265 | // If the start and end are at the same position, selection is empty. |
| 2266 | if start_bound.frame_index == end_bound.frame_index |
| 2267 | && start_bound.glyph_index == end_bound.glyph_index |
| 2268 | { |
| 2269 | return None; |
| 2270 | } |
| 2271 | |
| 2272 | // Ensure start comes before end |
| 2273 | let (selection_start, selection_end) = if start_bound.frame_index > end_bound.frame_index |
| 2274 | || (start_bound.frame_index == end_bound.frame_index |
| 2275 | && start_bound.glyph_index > end_bound.glyph_index) |
| 2276 | { |
| 2277 | std::mem::swap(&mut start_bound, &mut end_bound); |
| 2278 | (selection_end, selection_start) |
| 2279 | } else { |
| 2280 | (selection_start, selection_end) |
| 2281 | }; |
| 2282 | |
| 2283 | let text = match is_rect { |
| 2284 | IsRect::True => { |
| 2285 | let selection_bounds = self.compute_rect_selection_bounds( |
| 2286 | start_bound, |
| 2287 | end_bound, |
| 2288 | selection_start.x(), |
| 2289 | selection_end.x(), |
| 2290 | )?; |
| 2291 | selection_bounds |
| 2292 | .into_iter() |
| 2293 | .filter_map(|(start_bound, end_bound)| { |
| 2294 | let frame = self.laid_out_text.get(start_bound.frame_index)?; |
| 2295 | char_slice( |
| 2296 | frame.get_raw_text(), |
| 2297 | start_bound.glyph_index, |
| 2298 | end_bound.glyph_index, |
| 2299 | ) |
| 2300 | }) |
| 2301 | .join("\n") |
| 2302 | } |
| 2303 | IsRect::False => self.build_regular_selection_text(start_bound, end_bound)?, |
| 2304 | }; |
| 2305 | |
| 2306 | Some(vec![SelectionFragment { |
| 2307 | text, |
| 2308 | origin: self.origin?, |
| 2309 | }]) |
| 2310 | } |
| 2311 | |
| 2312 | fn expand_selection( |
| 2313 | &self, |
| 2314 | absolute_point: Vector2F, |
| 2315 | direction: SelectionDirection, |
| 2316 | unit: SelectionType, |
| 2317 | word_boundaries_policy: &WordBoundariesPolicy, |
| 2318 | ) -> Option<Vector2F> { |
| 2319 | if matches!(unit, SelectionType::Simple | SelectionType::Rect) { |
| 2320 | return None; |
| 2321 | } |
| 2322 | |
| 2323 | let bound = self.bounds()?; |
| 2324 | |
| 2325 | // Handle points above/below bounds |
| 2326 | if absolute_point.y() < bound.min_y() { |
| 2327 | return match direction { |
| 2328 | SelectionDirection::Backward => Some(bound.origin()), |
| 2329 | SelectionDirection::Forward => None, |
| 2330 | }; |
| 2331 | } |
| 2332 | if absolute_point.y() > bound.max_y() { |
| 2333 | return match direction { |
| 2334 | SelectionDirection::Backward => None, |
| 2335 | SelectionDirection::Forward => Some(absolute_point), |
| 2336 | }; |
| 2337 | } |
| 2338 | |
| 2339 | match unit { |
| 2340 | SelectionType::Simple | SelectionType::Rect => None, |
| 2341 | SelectionType::Semantic => { |
| 2342 | let text_selection_bound = self |
| 2343 | .position_for_point(absolute_point, SnappingPolicy::default().snap_on_gap())?; |
| 2344 | let frame = self.laid_out_text.get(text_selection_bound.frame_index)?; |
| 2345 | |
| 2346 | match frame { |
| 2347 | LaidOutTextFrame::Text { |
| 2348 | text_frame, |
| 2349 | raw_text, |
| 2350 | .. |
| 2351 | } |
| 2352 | | LaidOutTextFrame::CodeBlock { |
| 2353 | text_frame, |
| 2354 | raw_text, |
| 2355 | .. |
| 2356 | } |
| 2357 | | LaidOutTextFrame::Indented { |
| 2358 | text_frame, |
| 2359 | raw_text, |
| 2360 | .. |
| 2361 | } => { |
| 2362 | let frame_bounds = frame.get_frame_bounds(); |
| 2363 | let inner_point = if matches!(direction, SelectionDirection::Backward) { |
| 2364 | raw_text.word_starts_backward_from_offset_exclusive(CharOffset::from( |
| 2365 | text_selection_bound.glyph_index, |
| 2366 | )) |
| 2367 | } else { |
| 2368 | raw_text.word_ends_from_offset_exclusive(CharOffset::from( |
| 2369 | text_selection_bound.glyph_index, |
| 2370 | )) |
| 2371 | } |
| 2372 | .ok()? |
| 2373 | .with_policy(word_boundaries_policy) |
| 2374 | .next()?; |
| 2375 | |
| 2376 | let offset = raw_text.to_offset(inner_point).ok()?.as_usize(); |
| 2377 | |
| 2378 | let line = text_frame.lines().get(text_selection_bound.row_index)?; |
| 2379 | let first_glyph = line.first_glyph()?; |
| 2380 | let last_glyph = line.last_glyph()?; |
| 2381 | let relative_x = |
| 2382 | if first_glyph.index <= offset && offset <= last_glyph.index + 1 { |
| 2383 | line.x_for_index(offset) |
| 2384 | } else if matches!(direction, SelectionDirection::Backward) { |
| 2385 | 0. |
| 2386 | } else { |
| 2387 | line.width |
| 2388 | }; |
| 2389 | let absolute_x = |
| 2390 | frame_bounds.min_x() + text_frame.line_x_offset(line) + relative_x; |
| 2391 | |
| 2392 | Some(vec2f(absolute_x, absolute_point.y())) |
| 2393 | } |
| 2394 | LaidOutTextFrame::LineBreak { .. } => None, |
| 2395 | } |
| 2396 | } |
| 2397 | SelectionType::Lines => { |
| 2398 | let text_selection_bound = |
| 2399 | self.position_for_point(absolute_point, SnappingPolicy::default())?; |
| 2400 | let snap_to = self.translate_selection_bound_to_line_bound( |
| 2401 | text_selection_bound, |
| 2402 | matches!(direction, SelectionDirection::Backward), |
| 2403 | )?; |
| 2404 | Some(snap_to) |
| 2405 | } |
| 2406 | } |
| 2407 | } |
| 2408 | |
| 2409 | fn is_point_semantically_before( |
| 2410 | &self, |
| 2411 | absolute_point_1: Vector2F, |
| 2412 | absolute_point_2: Vector2F, |
| 2413 | ) -> Option<bool> { |
| 2414 | let bounds = self.bounds()?; |
| 2415 | match ( |
| 2416 | bounds.contains_point(absolute_point_1), |
| 2417 | bounds.contains_point(absolute_point_2), |
| 2418 | ) { |
| 2419 | (false, false) => None, |
| 2420 | (false, true) => { |
| 2421 | if absolute_point_1.y() < bounds.min_y() { |
| 2422 | Some(true) |
| 2423 | } else if absolute_point_1.y() > bounds.max_y() { |
| 2424 | Some(false) |
| 2425 | } else { |
| 2426 | Some(absolute_point_1.x() < bounds.min_x()) |
| 2427 | } |
| 2428 | } |
| 2429 | (true, false) => { |
| 2430 | if absolute_point_2.y() < bounds.min_y() { |
| 2431 | Some(false) |
| 2432 | } else if absolute_point_2.y() > bounds.max_y() { |
| 2433 | Some(true) |
| 2434 | } else { |
| 2435 | Some(absolute_point_2.x() > bounds.max_x()) |
| 2436 | } |
| 2437 | } |
| 2438 | (true, true) => { |
| 2439 | let point_1 = self.position_for_point( |
| 2440 | absolute_point_1, |
| 2441 | SnappingPolicy::default().snap_on_gap(), |
| 2442 | )?; |
| 2443 | let point_2 = self.position_for_point( |
| 2444 | absolute_point_2, |
| 2445 | SnappingPolicy::default().snap_on_gap(), |
| 2446 | )?; |
| 2447 | Some( |
| 2448 | ( |
| 2449 | point_1.frame_index, |
| 2450 | point_1.row_index, |
| 2451 | point_1.glyph_index, |
| 2452 | absolute_point_1.x(), |
| 2453 | ) < ( |
| 2454 | point_2.frame_index, |
| 2455 | point_2.row_index, |
| 2456 | point_2.glyph_index, |
| 2457 | absolute_point_2.x(), |
| 2458 | ), |
| 2459 | ) |
| 2460 | } |
| 2461 | } |
| 2462 | } |
| 2463 | |
| 2464 | fn smart_select( |
| 2465 | &self, |
| 2466 | absolute_point: Vector2F, |
| 2467 | smart_select_fn: SmartSelectFn, |
| 2468 | ) -> Option<(Vector2F, Vector2F)> { |
| 2469 | let bound = self.bounds()?; |
| 2470 | if absolute_point.y() < bound.min_y() || absolute_point.y() > bound.max_y() { |
| 2471 | return None; |
| 2472 | } |
| 2473 | if absolute_point.x() < bound.min_x() || absolute_point.x() > bound.max_x() { |
| 2474 | return None; |
| 2475 | } |
| 2476 | |
| 2477 | let text_selection_bound = |
| 2478 | self.position_for_point(absolute_point, SnappingPolicy::default())?; |
| 2479 | let frame = self.laid_out_text.get(text_selection_bound.frame_index)?; |
| 2480 | |
| 2481 | match frame { |
| 2482 | LaidOutTextFrame::Text { |
| 2483 | text_frame, |
| 2484 | raw_text, |
| 2485 | .. |
| 2486 | } |
| 2487 | | LaidOutTextFrame::CodeBlock { |
| 2488 | text_frame, |
| 2489 | raw_text, |
| 2490 | .. |
| 2491 | } |
| 2492 | | LaidOutTextFrame::Indented { |
| 2493 | text_frame, |
| 2494 | raw_text, |
| 2495 | .. |
| 2496 | } => { |
| 2497 | // Get row that we initially clicked on. |
| 2498 | // We need to do this because the same char offset in the raw text buffer |
| 2499 | // could be either the end of one line or the start of the next line. |
| 2500 | let line = text_frame.lines().get(text_selection_bound.row_index)?; |
| 2501 | let first_glyph = line.first_glyph()?; |
| 2502 | let last_glyph = line.last_glyph()?; |
| 2503 | // 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. |
| 2504 | // Snap it within the line's index range so we do smart selection as if we clicked on the last glyph in the line. |
| 2505 | let char_offset = text_selection_bound |
| 2506 | .glyph_index |
| 2507 | .clamp(first_glyph.index, last_glyph.index); |
| 2508 | let byte_offset = raw_text.char_indices().nth(char_offset)?.0.into(); |
| 2509 | |
| 2510 | let smart_select_range = smart_select_fn(raw_text, byte_offset)?; |
| 2511 | // convert the byte offset to glyph index |
| 2512 | let smart_select_start = |
| 2513 | count_chars_up_to_byte(raw_text, smart_select_range.start)?.as_usize(); |
| 2514 | let smart_select_end = |
| 2515 | count_chars_up_to_byte(raw_text, smart_select_range.end)?.as_usize(); |
| 2516 | // After smart selection, the start and end offsets can be on different lines if a word wrapped. |
| 2517 | // Find the lines for the start and end offsets. |
| 2518 | let start_row = text_frame.row_within_frame(smart_select_start, false); |
| 2519 | let end_row = text_frame.row_within_frame(smart_select_end, true); |
| 2520 | let start_line = text_frame.lines().get(start_row)?; |
| 2521 | let end_line = text_frame.lines().get(end_row)?; |
| 2522 | |
| 2523 | let start_relative_x = start_line.x_for_index(smart_select_start); |
| 2524 | let end_relative_x = end_line.x_for_index(smart_select_end); |
| 2525 | // We subtract half the line's height to put the position in the center of the line. |
| 2526 | let start_relative_y = |
| 2527 | text_frame.height_up_to_row(start_row) - start_line.height() / 2.; |
| 2528 | let end_relative_y = text_frame.height_up_to_row(end_row) - end_line.height() / 2.; |
| 2529 | let frame_bounds = frame.get_frame_bounds(); |
| 2530 | |
| 2531 | Some(( |
| 2532 | vec2f( |
| 2533 | frame_bounds.min_x() |
| 2534 | + text_frame.line_x_offset(start_line) |
| 2535 | + start_relative_x, |
| 2536 | frame_bounds.min_y() + start_relative_y, |
| 2537 | ), |
| 2538 | vec2f( |
| 2539 | frame_bounds.min_x() + text_frame.line_x_offset(end_line) + end_relative_x, |
| 2540 | frame_bounds.min_y() + end_relative_y, |
| 2541 | ), |
| 2542 | )) |
| 2543 | } |
| 2544 | LaidOutTextFrame::LineBreak { .. } => None, |
| 2545 | } |
| 2546 | } |
| 2547 | |
| 2548 | fn calculate_clickable_bounds(&self, current_selection: Option<Selection>) -> Vec<RectF> { |
| 2549 | if self.origin.is_none() { |
| 2550 | return vec![]; |
| 2551 | } |
| 2552 | |
| 2553 | self.calculate_point_ranges(current_selection) |
| 2554 | .unwrap_or_default() |
| 2555 | .iter() |
| 2556 | .flat_map(|(start_point, end_point)| { |
| 2557 | self.calculate_selection_bounds(*start_point, *end_point) |
| 2558 | }) |
| 2559 | .collect() |
| 2560 | } |
| 2561 | } |
| 2562 | |
| 2563 | /// A policy that determines how the snapping should behave when converting a point position |
| 2564 | /// to a position of a glyph. |
| 2565 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 2566 | struct SnappingPolicy { |
| 2567 | /// If true, the snapping will snap to the beginning of the next frame if the point is in |
| 2568 | /// the gap between two frames; otherwise, it will return `None`. |
| 2569 | should_snap_on_gap: bool, |
| 2570 | /// If true, the snapping will snap to the beginning or the end of a frame if the point is |
| 2571 | /// outside the frame bounds; otherwise, it will return `None`. |
| 2572 | should_snap_to_ends: bool, |
| 2573 | /// If true, the returned result will not exceed the last char index. The last caret index |
| 2574 | /// will simply be reduced to the last char index. |
| 2575 | should_adjust_to_char_indices: bool, |
| 2576 | } |
| 2577 | |
| 2578 | impl SnappingPolicy { |
| 2579 | fn snap_on_gap(mut self) -> Self { |
| 2580 | self.should_snap_on_gap = true; |
| 2581 | self |
| 2582 | } |
| 2583 | |
| 2584 | fn precise_char_range() -> Self { |
| 2585 | Self { |
| 2586 | should_snap_on_gap: false, |
| 2587 | should_snap_to_ends: false, |
| 2588 | should_adjust_to_char_indices: true, |
| 2589 | } |
| 2590 | } |
| 2591 | } |
| 2592 | |
| 2593 | impl Default for SnappingPolicy { |
| 2594 | fn default() -> Self { |
| 2595 | Self { |
| 2596 | should_snap_on_gap: false, |
| 2597 | should_snap_to_ends: true, |
| 2598 | should_adjust_to_char_indices: false, |
| 2599 | } |
| 2600 | } |
| 2601 | } |
| 2602 | |
| 2603 | #[cfg(test)] |
| 2604 | #[path = "formatted_text_element_tests.rs"] |
| 2605 | mod tests; |
| 2606 |