1679 lines
60 KiB
Rust
1679 lines
60 KiB
Rust
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<usize>, 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<T> {
|
|
prev_frame: Mutex<HashMap<CacheKeyValue, Arc<T>>>,
|
|
curr_frame: RwLock<HashMap<CacheKeyValue, Arc<T>>>,
|
|
}
|
|
|
|
impl<T> TextCache<T> {
|
|
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<Arc<T>> {
|
|
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<T>) {
|
|
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<Line>,
|
|
text_frame_cache: TextCache<TextFrame>,
|
|
}
|
|
|
|
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<f32>,
|
|
text_layout_system: &'a TextLayoutSystem<'a>,
|
|
) -> Arc<TextFrame> {
|
|
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<Line> {
|
|
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<usize>, StyleAndFont)],
|
|
) -> (&'a str, Option<Vec<StyleRun>>) {
|
|
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<H: Hasher>(&self, state: &mut H) {
|
|
self.key().hash(state)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Eq)]
|
|
pub struct CacheKeyValue {
|
|
text: String,
|
|
font_size: OrderedFloat<f32>,
|
|
line_height_ratio: OrderedFloat<f32>,
|
|
fixed_width_tab_size: Option<u8>,
|
|
style_runs: SmallVec<[(Range<usize>, StyleAndFont); 1]>,
|
|
max_width: OrderedFloat<f32>,
|
|
max_height: Option<OrderedFloat<f32>>,
|
|
alignment: TextAlignment,
|
|
first_line_head_indent: Option<OrderedFloat<f32>>,
|
|
clip_config: Option<ClipConfig>,
|
|
}
|
|
|
|
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<H: Hasher>(&self, state: &mut H) {
|
|
self.key().hash(state);
|
|
}
|
|
}
|
|
|
|
impl<'a> Borrow<dyn CacheKey + 'a> 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<usize, ColorU>,
|
|
underline: RangeMap<usize, ColorU>,
|
|
}
|
|
|
|
impl PaintStyleOverride {
|
|
pub fn with_color(mut self, color_override: RangeMap<usize, ColorU>) -> Self {
|
|
self.color = color_override;
|
|
self
|
|
}
|
|
|
|
pub fn with_underline(mut self, underline_override: RangeMap<usize, ColorU>) -> Self {
|
|
self.underline = underline_override;
|
|
self
|
|
}
|
|
}
|
|
|
|
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
|
|
pub struct CacheKeyRef<'a> {
|
|
text: &'a str,
|
|
font_size: OrderedFloat<f32>,
|
|
line_height_ratio: OrderedFloat<f32>,
|
|
fixed_width_tab_size: Option<u8>,
|
|
style_runs: &'a [(Range<usize>, StyleAndFont)],
|
|
max_width: OrderedFloat<f32>,
|
|
max_height: Option<OrderedFloat<f32>>,
|
|
alignment: TextAlignment,
|
|
first_line_head_indent: Option<OrderedFloat<f32>>,
|
|
clip_config: Option<ClipConfig>,
|
|
}
|
|
|
|
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<dyn Fn(ComputeBaselinePositionArgs) -> f32 + 'static + Send + Sync>;
|
|
|
|
#[derive(Default, Debug, Clone)]
|
|
pub struct Line {
|
|
pub width: f32,
|
|
pub trailing_whitespace_width: f32,
|
|
pub runs: Vec<Run>,
|
|
pub font_size: f32,
|
|
pub line_height_ratio: f32,
|
|
pub baseline_ratio: f32,
|
|
pub clip_config: Option<ClipConfig>,
|
|
|
|
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<CaretPosition>,
|
|
pub chars_with_missing_glyphs: Vec<char>,
|
|
}
|
|
|
|
/// 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<ColorU>,
|
|
// syntax_color is similar to foreground_color except it isn't inheritable.
|
|
pub syntax_color: Option<ColorU>,
|
|
pub background_color: Option<ColorU>,
|
|
pub border: Option<TextBorder>,
|
|
pub error_underline_color: Option<ColorU>,
|
|
pub show_strikethrough: bool,
|
|
// Underline color (of either text underline or hyperlink).
|
|
pub underline_color: Option<ColorU>,
|
|
// 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<i32>,
|
|
}
|
|
|
|
#[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<u8>,
|
|
}
|
|
|
|
#[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<Glyph>,
|
|
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<Line>,
|
|
/// The max width of any line in the text frame.
|
|
max_width: f32,
|
|
alignment: TextAlignment,
|
|
}
|
|
|
|
impl TextFrame {
|
|
pub fn new(lines: Vec1<Line>, 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<Vec<RectF>> {
|
|
let mut positions: Vec<Vec<RectF>> = Vec::new();
|
|
let mut height = origin.y();
|
|
let mut prev_hyperlink_id: Option<i32> = 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<Line> {
|
|
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<Run>) -> 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<f32> {
|
|
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<usize> {
|
|
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<usize> {
|
|
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;
|