Skip to content

Instantly share code, notes, and snippets.

@pragmatrix
Created March 31, 2023 10:31
Show Gist options
  • Save pragmatrix/9f37662327cd41443b44e0f1b587f592 to your computer and use it in GitHub Desktop.
Save pragmatrix/9f37662327cd41443b44e0f1b587f592 to your computer and use it in GitHub Desktop.
skia-safe based paragraph renderer
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(
&param.text,
param.max_width,
param.text_color,
param.font_size,
&param.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