Created
March 31, 2023 10:31
-
-
Save pragmatrix/9f37662327cd41443b44e0f1b587f592 to your computer and use it in GitHub Desktop.
skia-safe based paragraph renderer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
use crate::{ | |
default, | |
geometry::{scalar, Point, Size}, | |
UnicodeHelper, | |
}; | |
use skia_safe::{ | |
textlayout::{ | |
self, FontCollection, LineMetrics, ParagraphBuilder, ParagraphStyle, PositionWithAffinity, | |
RectHeightStyle, RectWidthStyle, TextStyle, | |
}, | |
Canvas, Color, Paint, Rect, | |
}; | |
use std::ops::Range; | |
#[derive(Debug)] | |
pub struct Param { | |
pub text: String, | |
pub max_width: Option<scalar>, | |
pub text_color: Color, | |
pub font_size: f32, | |
pub font_collection: FontCollection, | |
} | |
const FONT_SIZE_DEFAULT: f32 = 14.0; | |
impl Param { | |
pub fn new(font_collection: FontCollection) -> Self { | |
Self { | |
text: default(), | |
max_width: default(), | |
font_size: FONT_SIZE_DEFAULT, | |
text_color: default(), | |
font_collection, | |
} | |
} | |
} | |
impl From<&Param> for Paragraph { | |
fn from(param: &Param) -> Self { | |
Paragraph::new( | |
¶m.text, | |
param.max_width, | |
param.text_color, | |
param.font_size, | |
¶m.font_collection, | |
) | |
} | |
} | |
/// A layouted paragraph. | |
#[derive(Debug)] | |
pub struct Paragraph { | |
// We must keep a copy of the text around, since we can't retrieve it from Paragraph and need it | |
// to convert UTF8 to UTF16 indices the Paragraph methods use internally. | |
text: String, | |
// The layouted paragraph. | |
paragraph: textlayout::Paragraph, | |
} | |
// TODO: rename that to ParagraphState (similar to EntityState)? | |
// TODO: replace Paint by Color here. Paint is too heavy at this point. | |
#[derive(Clone, Default, Debug)] | |
pub struct Style { | |
pub selection: Option<(Range<usize>, Paint)>, | |
pub cursor: Option<(usize, Paint)>, | |
} | |
impl Paragraph { | |
pub fn new( | |
text: impl AsRef<str>, | |
max_width: Option<scalar>, | |
text_color: Color, | |
font_size: f32, | |
font_collection: &FontCollection, | |
) -> Self { | |
let text = text.as_ref(); | |
let text_style = TextStyle::new() | |
.set_color(text_color) | |
.set_font_size(font_size) | |
.clone(); | |
let style = ParagraphStyle::default() | |
.set_text_style(&text_style) | |
.clone(); | |
let mut paragraph = ParagraphBuilder::new(&style, font_collection) | |
.add_text(text) | |
.build(); | |
paragraph.layout(max_width.unwrap_or(scalar::INFINITY) as skia_safe::scalar); | |
Self { | |
text: text.to_string(), | |
paragraph, | |
} | |
} | |
/// Rectangles for the given character range. | |
/// TODO: we could make the range UTF-8 indices here now that we have the text available. | |
pub fn rects_for_range(&self, character_range: Range<usize>) -> impl Iterator<Item = Rect> { | |
// TODO: boxes should probably support into_iter(). | |
let boxes = self.paragraph.get_rects_for_range( | |
character_range, | |
RectHeightStyle::Max, | |
RectWidthStyle::Tight, | |
); | |
(0..boxes.as_slice().len()).map(move |i| boxes[i].rect) | |
} | |
pub fn line_metrics(&self) -> Vec<LineMetrics> { | |
self.paragraph.get_line_metrics() | |
} | |
pub fn position_in_line(&self, line_index: usize, x: f64) -> PositionWithAffinity { | |
let baseline = self.line_metrics()[line_index].baseline; | |
self.position((x, baseline)) | |
} | |
pub fn position(&self, p: impl Into<Point>) -> PositionWithAffinity { | |
self.paragraph.get_glyph_position_at_coordinate(p.into()) | |
} | |
pub fn size(&self) -> Size { | |
let width = if self.paragraph.line_number() != 0 { | |
// Longest line returns garbage if no lines were layouted. | |
self.paragraph.longest_line() | |
} else { | |
0.0 | |
} as scalar; | |
let height = self.paragraph.height() as scalar; | |
(width, height).into() | |
} | |
/// Returns the rect that contains all the rendered characters. | |
/// | |
/// The rect returned is relative to the paragraph's render rectangle at position (0,0). | |
pub fn tight_bounds(&self) -> Rect { | |
let last = self.text.byte_index_to_utf16_index(self.text.len()); | |
let range = Range { | |
start: 0, | |
end: last, | |
}; | |
// TODO: tight bounds does not seem to return really tight bounds (including only what's | |
// being rendered) | |
let boxes = self.paragraph.get_rects_for_range( | |
range, | |
RectHeightStyle::Tight, | |
RectWidthStyle::Tight, | |
); | |
let reduced = boxes.iter().map(|tb| tb.rect).reduce(|mut r1, r2| { | |
r1.join(r2); | |
r1 | |
}); | |
reduced.unwrap_or_default() | |
} | |
pub fn render(&self, canvas: &mut Canvas, pos: Point, style: &Style) { | |
if let Some((range, paint)) = &style.selection { | |
let range_utf16 = Range { | |
start: self.text.byte_index_to_utf16_index(range.start), | |
end: self.text.byte_index_to_utf16_index(range.end), | |
}; | |
for r in self.rects_for_range(range_utf16) { | |
canvas.draw_rect(r.with_offset(pos), paint); | |
} | |
} | |
self.paragraph.paint(canvas, pos); | |
if let Some((offset, paint)) = &style.cursor { | |
if let Some((p, height)) = self.cursor_pos_and_height(*offset) { | |
let top = p + pos; | |
let bottom = top + Size::new(0.0, height); | |
canvas.draw_line(top, bottom, paint); | |
} | |
} | |
} | |
pub fn cursor_pos_and_height(&self, cursor: usize) -> Option<(Point, f64)> { | |
let len = self.text.len(); | |
if len == 0 { | |
// No text -> use the Paragraph's size as height (luckily that is one line, but we don't | |
// have any layouted lines, though). | |
return Some((Point::default(), self.size().height)); | |
} | |
let at_end = cursor == len; | |
// Handle the situation in which the cursor is at the end and the last line has width 0. | |
if at_end { | |
let lines = self.paragraph.line_number(); | |
let metrics = &self.paragraph.get_line_metrics()[lines - 1]; | |
if metrics.width == 0.0 { | |
return Some(( | |
Point::new(metrics.left, metrics.baseline - metrics.ascent), | |
metrics.height, | |
)); | |
} | |
} | |
let (left, right) = { | |
if at_end { | |
// at the end: get one grapheme back. | |
(self.text.prev_grapheme(cursor).unwrap_or(0), cursor) | |
} else { | |
(cursor, self.text.next_grapheme(cursor).unwrap_or(len)) | |
} | |
}; | |
let range = Range { | |
start: self.text.byte_index_to_utf16_index(left), | |
end: self.text.byte_index_to_utf16_index(right), | |
}; | |
// start / end ranges are expected to be "glyph" indices, which are utf-16 indices. | |
let rect = self.rects_for_range(range).next(); | |
rect.map(|rect| { | |
let left = if at_end { rect.right } else { rect.left } as scalar; | |
( | |
Point::new(left, rect.top as scalar), | |
rect.height() as scalar, | |
) | |
}) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment