use std::borrow::Borrow; use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::ops::Range; use std::sync::Arc; use itertools::Itertools; use ordered_float::OrderedFloat; use parking_lot::{Mutex, RwLock, RwLockUpgradableReadGuard}; use pathfinder_color::ColorU; use pathfinder_geometry::vector::Vector2F; use rangemap::RangeMap; use smallvec::SmallVec; use vec1::{vec1, Vec1}; use crate::elements::{Fill, DEFAULT_UI_LINE_HEIGHT_RATIO}; use crate::fonts::{ Cache as FontCache, FamilyId, FontId, GlyphId, Properties, RequestedFallbackFontSource, TextLayoutSystem, }; use crate::geometry::rect::RectF; use crate::geometry::vector::vec2f; use crate::platform::LineStyle; use crate::scene::{Border, CornerRadius, Dash, GlyphFade}; use crate::Scene; type StyleRun = (Range, StyleAndFont); /// The maximum width of the fade applied to text that overflows its /// container's maximum width. const LINE_FADE_MAX_PIXELS: f32 = 25.0; /// The scaling factor applied to the overflow amount to determine the fade /// width. const LINE_FADE_SCALE_FACTOR: f32 = 3.0; /// Minimum overflow threshold before applying clipping effects. /// This prevents jitter or odd behavior when text is very close to overflowing. const MIN_OVERFLOW_FOR_CLIPPING: f32 = 0.1; // How far below the origin the baseline should fall. // This means that within the em-box for the line, 80% is above the baseline and 20% is below the baseline. pub const DEFAULT_TOP_BOTTOM_RATIO: f32 = 0.8; pub const UNDERLINE_THICKNESS: f32 = 2.; pub const STRIKETHROUGH_THICKNESSS: f32 = 2.; pub const UNDERLINE_BOTTOM_PADDING: f32 = 2.; // TODO: Ideally, we would use DEFAULT_MONOSPACE_FONT_SIZE here, however that // should stay app crate-specific. Hence, we're using 13.0 as a magic number // for the purposes of scaling underline padding correctly. const DEFAULT_FONT_SIZE: f32 = 13.; // The offset for where on the text glyph the strikethrough should be drawn. const STRIKETHROUGH_FONT_OFFSET: f32 = 2.5; #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash)] pub enum TextAlignment { #[default] Left, Center, Right, } struct TextCache { prev_frame: Mutex>>, curr_frame: RwLock>>, } impl TextCache { pub fn new() -> Self { Self { prev_frame: Mutex::new(HashMap::new()), curr_frame: RwLock::new(HashMap::new()), } } pub fn finish_frame(&self) { let mut prev_frame = self.prev_frame.lock(); let mut curr_frame = self.curr_frame.write(); std::mem::swap(&mut *prev_frame, &mut *curr_frame); curr_frame.clear(); } pub fn get(&self, key: &dyn CacheKey) -> Option> { let curr_frame = self.curr_frame.upgradable_read(); if let Some(val) = curr_frame.get(key) { return Some(val.clone()); } let mut curr_frame = RwLockUpgradableReadGuard::upgrade(curr_frame); if let Some((key, val)) = self.prev_frame.lock().remove_entry(key) { curr_frame.insert(key, val.clone()); Some(val) } else { None } } pub fn insert(&self, key: CacheKeyValue, val: Arc) { let mut curr_frame = self.curr_frame.write(); curr_frame.insert(key, val); } pub fn remove(&self, key: &dyn CacheKey) { self.prev_frame.lock().remove(key); self.curr_frame.write().remove(key); } } pub struct LayoutCache { line_cache: TextCache, text_frame_cache: TextCache, } impl Default for LayoutCache { fn default() -> Self { Self::new() } } impl LayoutCache { pub fn new() -> Self { Self { line_cache: TextCache::new(), text_frame_cache: TextCache::new(), } } pub fn finish_frame(&self) { self.line_cache.finish_frame(); self.text_frame_cache.finish_frame(); } pub fn remove_line(&self, key: &dyn CacheKey) { self.line_cache.remove(key); } pub fn remove_text_frame(&self, key: &dyn CacheKey) { self.text_frame_cache.remove(key); } #[allow(clippy::too_many_arguments)] pub fn layout_text<'a>( &'a self, text: &'a str, line_style: LineStyle, styles: &'a [StyleRun], max_width: f32, max_height: f32, alignment: TextAlignment, first_line_head_indent: Option, text_layout_system: &'a TextLayoutSystem<'a>, ) -> Arc { let (text, adjusted_styles) = strip_leading_unicode_bom(text, styles); let styles = adjusted_styles .as_ref() .map_or(styles, |adjusted_styles| adjusted_styles.as_slice()); let key = &CacheKeyRef { text, font_size: OrderedFloat(line_style.font_size), line_height_ratio: line_style.line_height_ratio.into(), fixed_width_tab_size: line_style.fixed_width_tab_size, style_runs: styles, max_width: OrderedFloat(max_width), max_height: Some(max_height.into()), alignment, first_line_head_indent: first_line_head_indent .map(|first_line_head_indent_value| first_line_head_indent_value.into()), clip_config: None, } as &dyn CacheKey; if let Some(text_frame) = self.text_frame_cache.get(key) { text_frame } else { let text_frame = Arc::new(text_layout_system.layout_text( text, line_style, styles, max_width, max_height, alignment, first_line_head_indent, )); let key = CacheKeyValue { text: text.into(), font_size: line_style.font_size.into(), line_height_ratio: line_style.line_height_ratio.into(), fixed_width_tab_size: line_style.fixed_width_tab_size, style_runs: styles.into(), max_width: max_width.into(), max_height: Some(max_height.into()), alignment, first_line_head_indent: first_line_head_indent .map(|first_line_head_indent_value| first_line_head_indent_value.into()), clip_config: None, }; for line in text_frame.lines() { for ch in &line.chars_with_missing_glyphs { text_layout_system.request_fallback_font_for_char( *ch, RequestedFallbackFontSource::TextFrame(key.clone()), ); } } self.text_frame_cache.insert(key, text_frame.clone()); text_frame } } pub fn layout_line<'a>( &'a self, text: &'a str, line_style: LineStyle, style_runs: &'a [StyleRun], max_width: f32, clip_config: ClipConfig, text_layout_system: &TextLayoutSystem<'a>, ) -> Arc { let (text, adjusted_style_runs) = strip_leading_unicode_bom(text, style_runs); let style_runs = adjusted_style_runs .as_ref() .map_or(style_runs, |adjusted_style_runs| { adjusted_style_runs.as_slice() }); let key = &CacheKeyRef { text, font_size: line_style.font_size.into(), line_height_ratio: line_style.line_height_ratio.into(), fixed_width_tab_size: line_style.fixed_width_tab_size, style_runs, max_width: max_width.into(), max_height: None, clip_config: Some(clip_config), alignment: Default::default(), first_line_head_indent: None, } as &dyn CacheKey; if let Some(line) = self.line_cache.get(key) { line } else { let line = Arc::new(text_layout_system.layout_line( text, line_style, style_runs, max_width, clip_config, )); let key = CacheKeyValue { text: text.into(), font_size: line_style.font_size.into(), line_height_ratio: line_style.line_height_ratio.into(), fixed_width_tab_size: line_style.fixed_width_tab_size, style_runs: style_runs.into(), max_width: max_width.into(), max_height: None, alignment: Default::default(), first_line_head_indent: None, clip_config: Some(clip_config), }; for ch in &line.chars_with_missing_glyphs { text_layout_system.request_fallback_font_for_char( *ch, RequestedFallbackFontSource::Line(key.clone()), ); } self.line_cache.insert(key, line.clone()); line } } } /// Removes a leading UTF-8 BOM from the text and adjusts the style run offsets accordingly. /// We throw away the styling of the BOM character. fn strip_leading_unicode_bom<'a>( text: &'a str, style_runs: &'a [(Range, StyleAndFont)], ) -> (&'a str, Option>) { let bom = '\u{FEFF}'; if text .chars() .next() .is_none_or(|first_character| first_character != bom) { // There is no leading BOM. return (text, None); } let mut style_runs = style_runs.to_vec(); for style_run in style_runs.iter_mut() { let range = (style_run.0.start.saturating_sub(1))..(style_run.0.end.saturating_sub(1)); style_run.0 = range; } let text = text.get(bom.len_utf8()..).unwrap_or_else(|| { log::warn!("Unable to get the a substring of the text without a leading BOM"); text }); (text, Some(style_runs)) } pub trait CacheKey { fn key(&self) -> CacheKeyRef<'_>; } impl PartialEq for dyn CacheKey + '_ { fn eq(&self, other: &dyn CacheKey) -> bool { self.key() == other.key() } } impl Eq for dyn CacheKey + '_ {} impl Hash for dyn CacheKey + '_ { fn hash(&self, state: &mut H) { self.key().hash(state) } } #[derive(Clone, Eq)] pub struct CacheKeyValue { text: String, font_size: OrderedFloat, line_height_ratio: OrderedFloat, fixed_width_tab_size: Option, style_runs: SmallVec<[(Range, StyleAndFont); 1]>, max_width: OrderedFloat, max_height: Option>, alignment: TextAlignment, first_line_head_indent: Option>, clip_config: Option, } impl PartialEq for CacheKeyValue { fn eq(&self, other: &Self) -> bool { self.key().eq(&other.key()) } } impl CacheKey for CacheKeyValue { fn key(&self) -> CacheKeyRef<'_> { CacheKeyRef { text: self.text.as_str(), font_size: self.font_size, line_height_ratio: self.line_height_ratio, fixed_width_tab_size: self.fixed_width_tab_size, style_runs: self.style_runs.as_slice(), max_width: self.max_width, max_height: self.max_height, alignment: self.alignment, first_line_head_indent: self.first_line_head_indent, clip_config: self.clip_config, } } } impl Hash for CacheKeyValue { fn hash(&self, state: &mut H) { self.key().hash(state); } } impl<'a> Borrow for CacheKeyValue { fn borrow(&self) -> &(dyn CacheKey + 'a) { self as &dyn CacheKey } } /// A style override that is applied on paint time without relaying out the text line/frame. /// /// This should be used with caution since the overrides is applied on the character-level /// but the character indices might not map to the laid out glyphs 1-1 when there is presence of /// ligatures. To give an example, if a text frame has the content "[fi]nal" with fi laid out one /// ligature, trying to apply color on only the first character "f" will cause "fi" to be colored. #[derive(Default)] pub struct PaintStyleOverride { color: RangeMap, underline: RangeMap, } impl PaintStyleOverride { pub fn with_color(mut self, color_override: RangeMap) -> Self { self.color = color_override; self } pub fn with_underline(mut self, underline_override: RangeMap) -> Self { self.underline = underline_override; self } } #[derive(Copy, Clone, PartialEq, Eq, Hash)] pub struct CacheKeyRef<'a> { text: &'a str, font_size: OrderedFloat, line_height_ratio: OrderedFloat, fixed_width_tab_size: Option, style_runs: &'a [(Range, StyleAndFont)], max_width: OrderedFloat, max_height: Option>, alignment: TextAlignment, first_line_head_indent: Option>, clip_config: Option, } impl CacheKey for CacheKeyRef<'_> { fn key(&self) -> CacheKeyRef<'_> { *self } } /// Enum describing which way to clip the text. #[derive(Copy, Clone, Default, Debug, PartialEq, Eq, Hash)] pub enum ClipDirection { /// Clip at the end of the text (default) #[default] End, /// Clip at the front of the text Start, } #[derive(Copy, Clone, Default, Debug, PartialEq, Eq, Hash)] pub enum ClipStyle { /// Fade out the clipped text (default) #[default] Fade, /// Show an ellipsis (…) at the clipped edge Ellipsis, } /// Configuration for clipping text that overflows the available width. #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub struct ClipConfig { pub direction: ClipDirection, pub style: ClipStyle, } impl Default for ClipConfig { fn default() -> Self { Self { direction: ClipDirection::End, style: ClipStyle::Fade, } } } impl ClipConfig { pub fn end() -> Self { Self { direction: ClipDirection::End, style: ClipStyle::Fade, } } pub fn start() -> Self { Self { direction: ClipDirection::Start, style: ClipStyle::Fade, } } pub fn ellipsis() -> Self { Self { direction: ClipDirection::End, style: ClipStyle::Ellipsis, } } } pub struct ComputeBaselinePositionArgs<'a> { pub font_cache: &'a FontCache, pub font_size: f32, /// Defines how tall a line should be, used in conjunction with font size. The height of a line is defined to be /// line height ratio * font size (if we are not using glyph-based metrics e.g. ascent/descent). pub line_height_ratio: f32, /// Baseline ratio defines how far below the origin the baseline should fall. /// For example, with a ratio of 0.8, it means that within the em-box for the line, 80% is above the baseline /// and 20% is below the baseline. pub baseline_ratio: f32, /// Ascent measures the distance from the baseline to the top of the em-box. pub ascent: f32, /// Descent measures the distance from the baseline to the bottom of the em-box. pub descent: f32, } /// Closure to compute baseline position from given arguments. Note that this concept of "baseline position" /// is distinct from Core Text's concept of "baseline offset". Specifically, this is the position of the baseline /// used to render glyphs. This position is relative to the top of a given Line (see paint() method in Line for /// further details on usage). pub type ComputeBaselinePositionFn = Box f32 + 'static + Send + Sync>; #[derive(Default, Debug, Clone)] pub struct Line { pub width: f32, pub trailing_whitespace_width: f32, pub runs: Vec, pub font_size: f32, pub line_height_ratio: f32, pub baseline_ratio: f32, pub clip_config: Option, pub ascent: f32, pub descent: f32, /// Caret positions represent locations the cursor and selection endpoints /// can snap to when selecting text. /// On MacOS, CoreText gives us one caret position per visible glyphs, /// meaning that ligatures will have a single caret position. /// On winit platforms, cosmic-text gives us one caret position per /// codepoint, meaning ligatures will have multiple caret positions. pub caret_positions: Vec, pub chars_with_missing_glyphs: Vec, } /// Default baseline offset calculation for Lines. pub fn default_compute_baseline_position( font_size: f32, line_height_ratio: f32, ascent: f32, descent: f32, ) -> f32 { let line_height = font_size * line_height_ratio; // Text height is the distance from top of em-box to // baseline + distance from baseline to bottom of em-box. let text_height = ascent + descent; // We want the text to be vertically centered within the line. let padding_top = (line_height - text_height) / 2.0; // Baseline position is the distance from top of line to top // of em-box + distance from top of em-box to baseline. padding_top + ascent } /// Returns closure computing the default baseline offset for given font metrics. pub fn default_compute_baseline_position_fn() -> ComputeBaselinePositionFn { Box::new(|font_metrics| { default_compute_baseline_position( font_metrics.font_size, font_metrics.line_height_ratio, font_metrics.ascent, font_metrics.descent, ) }) } /// A caret position within a line. Generally, there is a caret position after /// every grapheme. This does not always correspond to glyphs (for example, a /// ligature is one glyph but contains multiple caret positions). It also does /// not necessarily correspond to characters (many emoji are represented by /// multiple Unicode scalar values, but only produce a single caret position). #[derive(Debug, Default, Clone)] pub struct CaretPosition { /// The x-position of this caret location, relative to the line's origin. pub position_in_line: f32, /// The starting character index corresponding to this location in the input string. /// In the case of RTL text, this may not correspond to `position_in_line`. /// That is, a caret position that's visually to the right of another may /// actually have a _lower_ `start_offset`. pub start_offset: usize, /// The index of the last character in this caret position. This is _inclusive_. pub last_offset: usize, } #[derive(Debug, Default, Copy, Clone, Hash, PartialEq, Eq)] pub struct TextStyle { pub foreground_color: Option, // syntax_color is similar to foreground_color except it isn't inheritable. pub syntax_color: Option, pub background_color: Option, pub border: Option, pub error_underline_color: Option, pub show_strikethrough: bool, // Underline color (of either text underline or hyperlink). pub underline_color: Option, // Unique id for each hyperlink in a frame, used to group parts of a hyperlink together if a hyperlink is soft-wrapped. pub hyperlink_id: Option, } #[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] pub struct TextBorder { pub color: ColorU, pub radius: u8, pub width: u8, // The line height ratio override to determine the size of the border and the background color (if there is one). // By default, the border will fit the entire line height. pub line_height_ratio_override: Option, } #[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] pub struct StyleAndFont { pub font_family: FamilyId, pub properties: Properties, pub style: TextStyle, } impl StyleAndFont { pub fn new(font_family: FamilyId, properties: Properties, style: TextStyle) -> Self { StyleAndFont { font_family, properties, style, } } } impl TextStyle { pub fn new() -> Self { Default::default() } /// Returns a new TextStyle containing only the inheritable styles from /// the current TextStyle (self). Note that this is the source of truth /// for which text styles are inheritable. pub fn filter_inheritable_styles(self) -> Self { TextStyle { foreground_color: self.foreground_color, syntax_color: None, background_color: self.background_color, error_underline_color: None, border: None, show_strikethrough: false, underline_color: None, hyperlink_id: None, } } pub fn with_foreground_color(mut self, foreground_color: ColorU) -> Self { self.foreground_color = Some(foreground_color); self } pub fn with_syntax_color(mut self, syntax_color: ColorU) -> Self { self.syntax_color = Some(syntax_color); self } pub fn with_border(mut self, border: TextBorder) -> Self { self.border = Some(border); self } pub fn with_background_color(mut self, background_color: ColorU) -> Self { self.background_color = Some(background_color); self } pub fn with_error_underline_color(mut self, error_underline_color: ColorU) -> Self { self.error_underline_color = Some(error_underline_color); self } pub fn with_show_strikethrough(mut self, show_strikethrough: bool) -> Self { self.show_strikethrough = show_strikethrough; self } pub fn with_underline_color(mut self, underline_color: ColorU) -> Self { self.underline_color = Some(underline_color); self } pub fn with_hyperlink_id(mut self, hyperlink_id: i32) -> Self { self.hyperlink_id = Some(hyperlink_id); self } } /// A series of consecutive glyphs within a run that have the same styles. #[derive(Debug, Clone)] pub struct Run { pub font_id: FontId, pub glyphs: Vec, pub styles: TextStyle, pub width: f32, } #[derive(Debug, Clone)] pub struct Glyph { pub id: GlyphId, /// Position of the glyph on its baseline. pub position_along_baseline: Vector2F, /// The starting index of the character in the original string where this glyph starts. pub index: usize, /// The width of the glyph (its advance), in pixels. pub width: f32, } /// On MacOS, CoreText includes line separators in the TextFrame's lines. /// On winit, cosmic-text strips line separators, so they do not have their /// own glyphs in the TextFrame's lines. #[derive(Default, Debug)] pub struct TextFrame { lines: Vec1, /// The max width of any line in the text frame. max_width: f32, alignment: TextAlignment, } impl TextFrame { pub fn new(lines: Vec1, max_width: f32, alignment: TextAlignment) -> Self { TextFrame { lines, max_width, alignment, } } /// A text frame with no text. It has a single line with no runs which /// is created by `Line#empty`. /// /// System APIs may return text frames with all sorts of different values so /// this API helps standardize these values for our application logic. pub fn empty(font_size: f32, line_height_ratio: f32) -> Self { TextFrame { lines: vec1![Line::empty(font_size, line_height_ratio, 0)], max_width: 0.0, alignment: Default::default(), } } // Returns the absolute bounds of all hyperlinks in the frame. The position of each link is offset by the provided `origin.` pub fn hyperlink_bounds(&self, origin: Vector2F) -> Vec> { let mut positions: Vec> = Vec::new(); let mut height = origin.y(); let mut prev_hyperlink_id: Option = None; // If the current rectangle is part of the previous hyperlink frame, just push it in a running vector. Otherwise create a new entry. for line in &self.lines { let mut width = origin.x() + self.line_x_offset(line); for run in &line.runs { if let Some(curr_hyperlink_id) = run.styles.hyperlink_id { let curr_rectangle = RectF::new( Vector2F::new(width, height), Vector2F::new(run.width, line.font_size * line.line_height_ratio), ); let mut soft_wrapped = false; if let Some(prev_id) = prev_hyperlink_id { if prev_id == curr_hyperlink_id { positions .last_mut() .expect("Positions should be non-empty") .push(curr_rectangle); soft_wrapped = true; } } if !soft_wrapped { positions.push(vec![curr_rectangle]); } prev_hyperlink_id = Some(curr_hyperlink_id); } width += run.width; } height += line.font_size * line.line_height_ratio; } positions } /// We can't mark this as cfg(test) because we need this in the warp crate tests. pub fn mock(text: &str) -> Self { let mut acc = 0; let lines = text .split('\n') .map(|line| { let glyphs: Vec<_> = line .chars() .enumerate() .map(|(index, _)| Glyph { id: Default::default(), position_along_baseline: Default::default(), index: index + acc, width: 10.0, // dummy width }) .collect(); acc += glyphs.len(); let runs = vec![Run { font_id: FontId(0), glyphs, styles: TextStyle::new(), width: Default::default(), }]; Line::mock(runs) }) .collect(); match Vec1::try_from_vec(lines) { Ok(lines) => TextFrame { lines, max_width: Default::default(), alignment: Default::default(), }, Err(_) => TextFrame::empty(Default::default(), Default::default()), } } /// Like [`Self::mock`], but lays out a single line of `text` with real glyph and caret /// positions: each glyph is `advance` pixels wide and starts at `x = index * advance`. /// This makes geometry helpers (e.g. `x_for_index`) and caret hit-testing usable in tests, /// which the zero-position [`Self::mock`] cannot support. Only supports a single line. /// /// We can't mark this as cfg(test) because warp crate tests need it too. pub fn mock_with_positions(text: &str, advance: f32) -> Self { assert!( !text.contains('\n'), "mock_with_positions only supports a single line" ); let char_count = text.chars().count(); let glyphs: Vec<_> = text .chars() .enumerate() .map(|(index, _)| Glyph { id: Default::default(), position_along_baseline: vec2f(index as f32 * advance, 0.0), index, width: advance, }) .collect(); let caret_positions = (0..char_count) .map(|index| CaretPosition { position_in_line: index as f32 * advance, start_offset: index, last_offset: index, }) .collect(); let width = char_count as f32 * advance; let line = Line { width, trailing_whitespace_width: 0.0, runs: vec![Run { font_id: FontId(0), glyphs, styles: TextStyle::new(), width, }], font_size: DEFAULT_FONT_SIZE, line_height_ratio: DEFAULT_UI_LINE_HEIGHT_RATIO, baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO, ascent: DEFAULT_FONT_SIZE * DEFAULT_TOP_BOTTOM_RATIO, descent: DEFAULT_FONT_SIZE * (1. - DEFAULT_TOP_BOTTOM_RATIO), clip_config: None, caret_positions, chars_with_missing_glyphs: vec![], }; TextFrame { lines: vec1![line], max_width: width, alignment: Default::default(), } } pub fn lines(&self) -> &Vec { self.lines.as_ref() } pub fn max_width(&self) -> f32 { self.max_width } pub fn height(&self) -> f32 { self.lines() .iter() .fold(0., |prev, line| prev + line.height()) } /// Returns the height of the frame up to the row, inclusive pub fn height_up_to_row(&self, row: usize) -> f32 { self.lines() .iter() .take(row + 1) .fold(0., |prev, line| prev + line.height()) } /// Given an index, returns the row the corresponding glyph is in the text frame. /// If the index is beyond the bounds of the text frame, we return the last row /// in the text frame. /// /// `clamp_above` is used to disambiguate whether the index is the last index /// of a line or the first index of the next line. If `clamp_above` is true, /// it's the last index of the above line and if it's falase, it's the first /// index of the below line. pub fn row_within_frame(&self, index: usize, clamp_above: bool) -> usize { for (i, line) in self.lines.iter().enumerate() { if let Some(last_glyph) = line.last_glyph() { let last_index_in_row = match clamp_above { true => last_glyph.index + 1, false => last_glyph.index, }; if last_index_in_row >= index { return i; } } } self.lines.len() - 1 } pub fn paint( &self, bounds: RectF, style_overrides: &PaintStyleOverride, default_color: ColorU, scene: &mut Scene, font_cache: &FontCache, ) { for (index, line) in self.lines.iter().enumerate() { let origin = bounds.origin() + vec2f(self.line_x_offset(line), index as f32 * line.height()); let bounds = RectF::from_points(origin, bounds.lower_right()); line.paint(bounds, style_overrides, default_color, font_cache, scene); } } // The x offset relative to the text frame origin to paint the line. pub(crate) fn line_x_offset(&self, line: &Line) -> f32 { let line_width = line.width; match self.alignment { TextAlignment::Left => 0., TextAlignment::Right => self.max_width - line_width, TextAlignment::Center => (self.max_width - line_width) / 2., } } pub fn paint_with_baseline_position( &self, bounds: RectF, style_overrides: &PaintStyleOverride, default_color: ColorU, scene: &mut Scene, font_cache: &FontCache, baseline_position_fn: &ComputeBaselinePositionFn, ) { for (index, line) in self.lines.iter().enumerate() { let origin = bounds.origin() + vec2f( 0., index as f32 * font_cache.line_height(line.font_size, line.line_height_ratio), ); let bounds = RectF::from_points(origin, bounds.lower_right()); line.paint_with_baseline_position( bounds, style_overrides, default_color, font_cache, scene, baseline_position_fn, ); } } } impl Line { /// A line with no text. It has zero width and a height equivalent to the /// line height. /// /// System APIs may return empty lines with all sorts of different values for /// width, height, etc. so we can use this API to help standardize these /// values for our application logic. pub fn empty(font_size: f32, line_height_ratio: f32, glyph_index: usize) -> Self { Line { width: 0.0, trailing_whitespace_width: 0.0, runs: vec![], font_size, line_height_ratio, baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO, ascent: font_size * DEFAULT_TOP_BOTTOM_RATIO, descent: font_size * (1. - DEFAULT_TOP_BOTTOM_RATIO), clip_config: None, caret_positions: vec![CaretPosition { position_in_line: 0.0, start_offset: glyph_index, last_offset: glyph_index, }], chars_with_missing_glyphs: vec![], } } /// We can't mark this as cfg(test) because we need this in the warp crate tests. pub fn mock(runs: Vec) -> Self { Line { width: Default::default(), trailing_whitespace_width: Default::default(), runs, font_size: DEFAULT_FONT_SIZE, line_height_ratio: DEFAULT_UI_LINE_HEIGHT_RATIO, baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO, ascent: DEFAULT_FONT_SIZE * DEFAULT_TOP_BOTTOM_RATIO, descent: DEFAULT_FONT_SIZE * (1. - DEFAULT_TOP_BOTTOM_RATIO), clip_config: None, caret_positions: Default::default(), chars_with_missing_glyphs: Default::default(), } } #[cfg(test)] pub fn mock_from_str(line: &str) -> Self { assert!(!line.contains('\n')); let glyphs: Vec<_> = line .chars() .enumerate() .map(|(index, _)| Glyph { id: Default::default(), position_along_baseline: Default::default(), index, width: 10.0, // dummy width }) .collect(); let runs = vec![Run { font_id: FontId(0), glyphs, styles: TextStyle::new(), width: Default::default(), }]; Line::mock(runs) } pub fn height(&self) -> f32 { self.line_height_ratio * self.font_size } pub fn first_glyph(&self) -> Option<&Glyph> { let first_run = self.runs.first()?; first_run.glyphs.first() } pub fn last_glyph(&self) -> Option<&Glyph> { let last_run = self.runs.last()?; last_run.glyphs.last() } pub fn x_for_index(&self, index: usize) -> f32 { for run in &self.runs { for glyph in &run.glyphs { if glyph.index == index { return glyph.position_along_baseline.x(); } } } self.width } /// The width in pixels of the glyph at this index. Returns None if the index is invalid. pub fn width_for_index(&self, index: usize) -> Option { let mut prev_glyph = self.runs.first().and_then(|run| run.glyphs.first())?; for run in &self.runs { for glyph in &run.glyphs { if glyph.index == index + 1 { return Some( glyph.position_along_baseline.x() - prev_glyph.position_along_baseline.x(), ); } prev_glyph = glyph; } } if index == self.last_index() { return Some(self.width - prev_glyph.position_along_baseline.x()); } None } /// Finds the nearest caret position for a character index. This is similar /// to [`Self::x_for_index`], but accounts for multi-character glyphs such /// as ligatures and many emojis. pub fn caret_position_for_index(&self, index: usize) -> f32 { for caret in self.caret_positions.iter() { if caret.contains_index(index) { return caret.position_in_line; } } // If `index` is out of bounds or at the extremes of the line, clamp to // either 0 or the line width. Which we choose depends on whether `index` // is before or after the line's range. if self .caret_positions .first() .is_some_and(|caret| index < caret.start_offset) { 0. } else { self.width } } fn is_x_in_bound(&self, x: f32) -> bool { x >= 0. && x < self.width } /// Returns the character index for the glyph best corresponding to `x`. pub fn index_for_x(&self, x: f32) -> Option { if !self.is_x_in_bound(x) { None } else { for run in self.runs.iter().rev() { for glyph in run.glyphs.iter().rev() { if glyph.position_along_baseline.x() <= x { return Some(glyph.index); } } } Some(0) } } /// Returns the caret index closest to the (relative) `x` position, /// but returns the first or end index if the `x` position is out of bounds. pub fn caret_index_for_x_unbounded(&self, x: f32) -> usize { let max_line_x = self.x_for_index(self.end_index()); if !self.is_x_in_bound(x) { // max_line_x should be smaller than self.width, but we check both just in case. return if x >= max_line_x || x >= self.width { self.end_index() } else { self.first_index() }; } // Handle special case where x is on the second half of the last glyph: `caret_index_for_x` // checks against glyph boundaries, but there's no glyph to the right when x lies past the // midpoint of the last glyph, so it'll always return the start of the last glyph instead of // the end. We need to handle this case separately and return the end of the last glyph. let tail_caret_position = match self.caret_positions.last() { Some(caret) => caret.position_in_line, None => return self.first_index(), }; // Note that if the text has been truncated, `max_line_x` could potentially be smaller than // `tail_caret_position`. In such cases, the below math for handling this special case becomes // incorrect, so we skip the check. let is_text_truncated = max_line_x <= tail_caret_position; if !is_text_truncated && (x - max_line_x).abs() < (x - tail_caret_position).abs() { return self.end_index(); } // Default case - we can unwrap safely since we've covered the edge cases resulting in `None` above. self.caret_index_for_x(x) .expect("None conditions should be already checked & handled") } /// Returns the starting character index for the caret position best corresponding to `x`. /// Returns `None` if `x` is out of bounds. /// Max return value is `self.last_index()` (`self.end_index() - 1`). /// /// *Important*: if you change the condition for returning `None`, make sure to update the /// checks in `caret_index_for_x_unbounded` as well. pub fn caret_index_for_x(&self, x: f32) -> Option { if !self.is_x_in_bound(x) { None } else { // Iterate backwards through the list of caret positions, and bias to the start of the // line if the search fails. Equivalently, we could iterate forwards and bias to the // end of the line. for (right, left) in self.caret_positions.iter().rev().tuple_windows() { // We want to find the two caret positions adjacent to x, and then chose the closest. // This is the first window from the back where the left caret position starts before x. if left.position_in_line <= x { if (left.position_in_line - x).abs() < (right.position_in_line - x).abs() { return Some(left.start_offset); } else { return Some(right.start_offset); } } } Some(0) } } /// The first character index that's within this line. pub fn first_index(&self) -> usize { self.caret_positions .first() .map_or(0, |caret| caret.start_offset) } /// The last character index that's within this line (inclusive). pub fn last_index(&self) -> usize { self.caret_positions .last() .map_or(0, |caret| caret.last_offset) } /// The first character index that's after this line. pub fn end_index(&self) -> usize { self.last_index() + 1 } /// Paints a run's background fill and border box, clamped horizontally to the /// span of glyphs that are actually drawn (`visible_left`..`visible_right`, in /// scene/paint coordinates). This must run BEFORE the run's glyphs and underline /// are drawn so that text decorations (notably the hyperlink underline, which is a /// filled rect in the same layer as the background) render on top of the /// background instead of being covered by it. /// /// Callers must only invoke this for a run that has at least one visible glyph /// (i.e. `visible_right > visible_left`); a fully truncated/clipped-away run paints /// no background at all. #[allow(clippy::too_many_arguments)] fn paint_run_background( &self, run: &Run, origin: Vector2F, visible_bounds: RectF, visible_left: f32, visible_right: f32, font_cache: &FontCache, scene: &mut Scene, baseline_position_fn: &ComputeBaselinePositionFn, ) { if let Some(first_glyph) = run.glyphs.first() { // We only need to draw text background if // 1) there is at least one glyph in the run // 2) either the border or the background color is present if run.styles.border.is_some() || run.styles.background_color.is_some() { // Scale the block padding based on the font size. let block_padding = if run.styles.border.is_some() { self.font_size / 10. } else { 0. }; // If the border has a line height ratio override, convert it from u8 to f32. // Else use the current text's line height ratio. let line_height_ratio = run .styles .border .and_then(|border| { border .line_height_ratio_override .map(|val| val as f32 / 100.) }) .unwrap_or(self.line_height_ratio); // The position reported by the glyph is along its baseline, so we offset // it by the baseline offset to get back to the top of the glyph. The // horizontal extent is taken from the actually-drawn glyph span // (`visible_left`/`visible_right`) rather than the full run width, so a // partially-truncated run does not paint a background past its visible // glyphs (or behind an ellipsis). The extra `block_padding` shift // accounts for kerning when bordering is turned on. let rect_top = origin.y() + first_glyph.position_along_baseline.y() - ((baseline_position_fn)(ComputeBaselinePositionArgs { font_cache, font_size: self.font_size, line_height_ratio, baseline_ratio: self.baseline_ratio, ascent: self.ascent, descent: self.descent, }) + 2. * block_padding); let text_rect = RectF::new( vec2f(visible_left - 2. * block_padding, rect_top), vec2f( (visible_right - visible_left) + 2. * block_padding, font_cache.line_height(self.font_size, line_height_ratio) + 2. * block_padding, ), ); let Some(clipped_rect) = text_rect.intersection(visible_bounds) else { // If there is no intersection, there's no need to paint the rect // (as it won't be visible). return; }; let rendered_background = scene.draw_rect_with_hit_recording(clipped_rect); if let Some(border) = run.styles.border { rendered_background .with_corner_radius(CornerRadius::with_all(crate::scene::Radius::Pixels( border.radius as f32, ))) .with_border( Border::all(border.width as f32).with_border_color(border.color), ); } if let Some(color) = run.styles.background_color { rendered_background.with_background(Fill::Solid(color)); } } } } fn paint_run_decorations( &self, glyph_color: ColorU, run: &Run, origin: Vector2F, visible_bounds: RectF, scene: &mut Scene, ) { if let Some((error_underline_color, first_glyph)) = run.styles.error_underline_color.zip(run.glyphs.first()) { // We draw the error underline at the baseline. let underline_origin = origin + first_glyph.position_along_baseline; let scaled_underline_bottom_padding = UNDERLINE_BOTTOM_PADDING * (self.font_size / DEFAULT_FONT_SIZE); let dash = Dash { dash_length: 4., gap_length: 3., // We don't want to adjust the gap width based on the length of // the run, as that would cause the dashes to wiggle as the run // changes. force_consistent_gap_length: true, }; let underline_rect = RectF::new( underline_origin, vec2f( run.width, scaled_underline_bottom_padding + UNDERLINE_THICKNESS, ), ); if let Some(clipped_rect) = underline_rect.intersection(visible_bounds) { scene .draw_rect_without_hit_recording(clipped_rect) .with_border( Border::bottom(UNDERLINE_THICKNESS) .with_dashed_border(dash) .with_border_color(error_underline_color), ); } } // Draw a strikethrough through text if boolean flag is set. if run.styles.show_strikethrough { if let Some(first_glyph) = run.glyphs.first() { let mut strikethrough_origin = origin + first_glyph.position_along_baseline; strikethrough_origin .set_y(strikethrough_origin.y() - self.font_size / STRIKETHROUGH_FONT_OFFSET); let strikethrough_rect = RectF::new( strikethrough_origin, vec2f(run.width, STRIKETHROUGH_THICKNESSS), ); if let Some(clipped_rect) = strikethrough_rect.intersection(visible_bounds) { scene .draw_rect_without_hit_recording(clipped_rect) .with_background(Fill::Solid(glyph_color)); } } } } /// Paints the line of text using given parameters. Uses default baseline offset calculation for a Line. pub fn paint( &self, bounds: RectF, style_overrides: &PaintStyleOverride, default_color: ColorU, font_cache: &FontCache, scene: &mut Scene, ) { self.paint_internal( bounds, style_overrides, default_color, font_cache, scene, &default_compute_baseline_position_fn(), ) } /// Paints the line of text using given parameters. Note that the caller can provide a custom /// closure to compute the baseline position used for the text. pub fn paint_with_baseline_position( &self, bounds: RectF, style_overrides: &PaintStyleOverride, default_color: ColorU, font_cache: &FontCache, scene: &mut Scene, baseline_position_fn: &ComputeBaselinePositionFn, ) { self.paint_internal( bounds, style_overrides, default_color, font_cache, scene, baseline_position_fn, ) } fn paint_internal( &self, bounds: RectF, style_overrides: &PaintStyleOverride, default_color: ColorU, font_cache: &FontCache, scene: &mut Scene, baseline_position_fn: &ComputeBaselinePositionFn, ) { let origin = bounds.origin(); let available_width = bounds.width(); // Fade out the line if the text in it has been clipped to max_width let width_without_trailing_whitespace = self.width - self.trailing_whitespace_width; let overflow = width_without_trailing_whitespace - available_width; let (clip_direction, clip_style) = self .clip_config .map(|config| (config.direction, config.style)) .unwrap_or_default(); let ellipsis_glyph: Option<(GlyphId, FontId, f32)> = if clip_style == ClipStyle::Ellipsis && overflow > MIN_OVERFLOW_FOR_CLIPPING { let ellipsis_run = match clip_direction { ClipDirection::Start => self.runs.last(), ClipDirection::End => self.runs.first(), }; ellipsis_run.and_then(|run| { font_cache.glyph_for_char(run.font_id, '…', false).and_then( |(glyph_id, font_id)| { font_cache .glyph_advance(font_id, self.font_size, glyph_id) .ok() .map(|advance| (glyph_id, font_id, advance.x())) }, ) }) } else { None }; let ellipsis_width = ellipsis_glyph .as_ref() .map(|(_, _, width)| *width) .unwrap_or_default(); // Set the length of the fade based on how much text is overflowing. let fade_width = LINE_FADE_MAX_PIXELS.min(overflow * LINE_FADE_SCALE_FACTOR); let fade = if overflow < MIN_OVERFLOW_FOR_CLIPPING || clip_style == ClipStyle::Ellipsis || self.clip_config.is_none() { None } else { match clip_direction { ClipDirection::End => { let fade_end = bounds.upper_right().x(); let fade_start = fade_end - fade_width; Some(GlyphFade::horizontal(fade_start, fade_end)) } ClipDirection::Start => { let fade_end = bounds.origin().x(); let fade_start = fade_end + fade_width; Some(GlyphFade::horizontal(fade_start, fade_end)) } } }; // Adjust the origin to be the baseline of the line, not the top of // the line. Note that the baseline position is consistent across the entire line, // even if we have different fonts on a single line. let baseline_position = (baseline_position_fn)(ComputeBaselinePositionArgs { font_cache, font_size: self.font_size, line_height_ratio: self.line_height_ratio, baseline_ratio: self.baseline_ratio, ascent: self.ascent, descent: self.descent, }); let line_origin = origin + vec2f(0., baseline_position); let is_start_clipping = self.clip_config.is_some() && clip_direction == ClipDirection::Start; let run_iter = if is_start_clipping { itertools::Either::Left(self.runs.iter().rev()) } else { itertools::Either::Right(self.runs.iter()) }; let mut remaining_width = match clip_style { // For ellipsis, reserve space on the side where we will draw the ellipsis. ClipStyle::Ellipsis if ellipsis_width > 0. => match clip_direction { ClipDirection::End => (available_width - ellipsis_width).max(0.), ClipDirection::Start => (available_width - ellipsis_width).max(0.), }, _ => available_width, }; // When start-clipping with an ellipsis we reserved `ellipsis_width` of // space at the LEFT for the ellipsis glyph. Visible glyphs need to be // offset by that amount so they stay flush with the right edge and do // not overlap the ellipsis. This is constant for the entire paint, so // hoist it out of the per-glyph loop. let start_ellipsis_offset = if is_start_clipping && ellipsis_width > 0. { ellipsis_width } else { 0. }; 'runs: for run in run_iter { let mut glyph_color = default_color; // We define foreground_color to overwrite syntax_color since the // foreground color was likely set explicitly somewhere (by the user // or system), whereas the syntax color is automatically added. if let Some(syntax_color) = run.styles.syntax_color { glyph_color = syntax_color; } if let Some(foreground_color) = run.styles.foreground_color { glyph_color = foreground_color; } // Paint the run's background/border BEFORE its glyphs and underline (the // hyperlink underline is a filled rect in the same layer as the background, // so painting the background afterward would cover and hide the underline on // backgrounded runs such as an inline-code link). Only backgrounded/bordered // runs need this, so the extra glyph walk to compute the visible span is // skipped entirely for normal text runs (the common hot path). if run.styles.border.is_some() || run.styles.background_color.is_some() { // Determine the horizontal span of this run's glyphs that will actually // be drawn, so the background can be clamped to it. This mirrors the // truncation/stop conditions in the glyph-drawing loop below (the // ellipsis cutoff and the `remaining_width <= 0` stop) without mutating // `remaining_width` or drawing anything; keep the two in sync. let mut visible_left = f32::INFINITY; let mut visible_right = f32::NEG_INFINITY; let mut sim_remaining_width = remaining_width; let sim_glyph_iter = if is_start_clipping { itertools::Either::Left(run.glyphs.iter().rev()) } else { itertools::Either::Right(run.glyphs.iter()) }; for glyph in sim_glyph_iter { if clip_style == ClipStyle::Ellipsis && ellipsis_width > 0. && sim_remaining_width < glyph.width { break; } if sim_remaining_width <= 0. { break; } sim_remaining_width -= glyph.width; let glyph_x = if is_start_clipping { line_origin.x() + sim_remaining_width + start_ellipsis_offset } else { line_origin.x() + glyph.position_along_baseline.x() }; visible_left = visible_left.min(glyph_x); visible_right = visible_right.max(glyph_x + glyph.width); } // Skip the background entirely when no glyphs are visible so a fully // truncated run paints no background. if visible_right > visible_left { self.paint_run_background( run, line_origin, bounds, visible_left, visible_right, font_cache, scene, baseline_position_fn, ); } } let glyph_iter = if is_start_clipping { itertools::Either::Left(run.glyphs.iter().rev()) } else { itertools::Either::Right(run.glyphs.iter()) }; let mut should_stop_after_run = false; for glyph in glyph_iter { let index = glyph.index; let override_color = style_overrides.color.get(&index).cloned(); // If we've started truncating in ellipsis mode, draw the ellipsis and stop painting glyphs. if clip_style == ClipStyle::Ellipsis && ellipsis_width > 0. && remaining_width < glyph.width { if let Some((glyph_id, font_id, _)) = ellipsis_glyph { let ellipsis_x = match clip_direction { ClipDirection::End => { (available_width - ellipsis_width - remaining_width).max(0.) } ClipDirection::Start => remaining_width, }; let ellipsis_origin = line_origin + vec2f(ellipsis_x, 0.); scene.draw_glyph( ellipsis_origin, glyph_id, font_id, self.font_size, default_color, ); } break 'runs; } // If there is not enough space to paint even part of the glyph, // stop painting glyphs but still paint run decorations // (so that the decorations are still visible even if the glyphs are partially hidden). if remaining_width <= 0. { should_stop_after_run = true; break; } remaining_width -= glyph.width; let glyph_origin = if is_start_clipping { line_origin + vec2f( remaining_width + start_ellipsis_offset, glyph.position_along_baseline.y(), ) } else { line_origin + glyph.position_along_baseline }; scene .draw_glyph( glyph_origin, glyph.id, run.font_id, self.font_size, override_color.unwrap_or(glyph_color), ) .with_fade(fade); // Draw the underline under the tag (e.g. for a hyperlink). if let Some(underline_color) = style_overrides .underline .get(&index) .copied() .or(run.styles.underline_color) { let scaled_underline_bottom_padding = UNDERLINE_BOTTOM_PADDING * (self.font_size / DEFAULT_FONT_SIZE); let underline_origin = line_origin + glyph.position_along_baseline + vec2f(0., scaled_underline_bottom_padding); scene .draw_rect_without_hit_recording(RectF::new( underline_origin, vec2f(glyph.width, UNDERLINE_THICKNESS), )) .with_background(Fill::Solid(underline_color)); } } self.paint_run_decorations(glyph_color, run, line_origin, bounds, scene); if should_stop_after_run { break; } } } } impl CaretPosition { /// The number of characters covered by this caret position. pub fn char_count(&self) -> usize { self.last_offset - self.start_offset + 1 } /// Whether or not a given character offset is within this caret position. pub fn contains_index(&self, index: usize) -> bool { index >= self.start_offset && index <= self.last_offset } } #[cfg(test)] #[path = "text_layout_tests.rs"] mod tests;