Merge pull request 'Text wrapping' (#52) from textwrap into main
Reviewed-on: #52
This commit is contained in:
commit
109af8793b
|
@ -4,6 +4,7 @@ members = [
|
|||
"apps/music-player",
|
||||
"apps/sandbox",
|
||||
"crates/script",
|
||||
"crates/textwrap",
|
||||
"scripts/music-player",
|
||||
"scripts/sao-ui",
|
||||
]
|
||||
|
|
|
@ -101,16 +101,22 @@ impl Panel {
|
|||
}
|
||||
|
||||
#[repr(transparent)]
|
||||
#[derive(Copy, Clone)]
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct Font(u32);
|
||||
|
||||
impl Font {
|
||||
pub fn new(family: &str) -> Self {
|
||||
unsafe { Self(font_load(family.as_ptr() as u32, family.len() as u32)) }
|
||||
}
|
||||
|
||||
/// Retrieves the script-local identifier of this font.
|
||||
pub fn get_id(&self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(transparent)]
|
||||
#[derive(Debug)]
|
||||
pub struct TextLayout(u32);
|
||||
|
||||
impl TextLayout {
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
[package]
|
||||
name = "canary-textwrap"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
canary-script = { path = "../script" }
|
||||
textwrap = "0.16"
|
|
@ -0,0 +1,248 @@
|
|||
// Copyright (c) 2022 Marceline Cramer
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use canary_script::api::{DrawContext, Font, TextLayout};
|
||||
use canary_script::{Color, Vec2};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TextCache {
|
||||
layouts: Vec<OwnedText>,
|
||||
fonts: Vec<HashMap<String, usize>>,
|
||||
}
|
||||
|
||||
impl TextCache {
|
||||
pub fn insert(&mut self, font: Font, text: &str) -> (usize, f32) {
|
||||
let font_idx = font.get_id() as usize;
|
||||
if let Some(font_cache) = self.fonts.get(font_idx) {
|
||||
if let Some(layout_idx) = font_cache.get(text) {
|
||||
return (*layout_idx, self.get(*layout_idx).width);
|
||||
}
|
||||
} else {
|
||||
let new_size = font_idx + 1;
|
||||
self.fonts.resize_with(new_size, Default::default);
|
||||
}
|
||||
|
||||
let index = self.layouts.len();
|
||||
let layout = TextLayout::new(&font, text);
|
||||
let width = layout.get_bounds().width().max(0.0);
|
||||
|
||||
self.layouts.push(OwnedText {
|
||||
font,
|
||||
layout,
|
||||
width,
|
||||
});
|
||||
|
||||
self.fonts[font_idx].insert(text.to_string(), index);
|
||||
|
||||
(index, width)
|
||||
}
|
||||
|
||||
pub fn get(&self, index: usize) -> Text<'_> {
|
||||
self.layouts[index].borrow()
|
||||
}
|
||||
}
|
||||
|
||||
/// Processed text that can be laid out.
|
||||
pub struct Content {
|
||||
pub words: Vec<Word>,
|
||||
}
|
||||
|
||||
impl Content {
|
||||
pub fn from_plain(cache: &mut TextCache, font: Font, text: &str) -> Self {
|
||||
use textwrap::word_splitters::{split_words, WordSplitter};
|
||||
use textwrap::WordSeparator;
|
||||
|
||||
let separator = WordSeparator::new();
|
||||
let words = separator.find_words(text);
|
||||
|
||||
// TODO: crate feature to enable hyphenation support?
|
||||
let splitter = WordSplitter::NoHyphenation;
|
||||
let split_words = split_words(words, &splitter);
|
||||
|
||||
let mut words = Vec::new();
|
||||
for split_word in split_words {
|
||||
let (word, word_width) = cache.insert(font, split_word.word);
|
||||
let (whitespace, whitespace_width) = cache.insert(font, "_");
|
||||
let (penalty, penalty_width) = cache.insert(font, split_word.penalty);
|
||||
|
||||
words.push(Word {
|
||||
word,
|
||||
word_width,
|
||||
whitespace,
|
||||
whitespace_width,
|
||||
penalty,
|
||||
penalty_width,
|
||||
});
|
||||
}
|
||||
|
||||
Self { words }
|
||||
}
|
||||
|
||||
pub fn layout(&self, cache: &TextCache, width: f32) -> Layout {
|
||||
use textwrap::wrap_algorithms::wrap_optimal_fit;
|
||||
let fragments = self.words.as_slice();
|
||||
let line_widths = &[width as f64];
|
||||
let penalties = Default::default();
|
||||
|
||||
// Should never fail with reasonable input. Check [wrap_optimal_fit] docs for more info.
|
||||
let wrapped_lines = wrap_optimal_fit(fragments, line_widths, &penalties).unwrap();
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for line in wrapped_lines {
|
||||
lines.push(Line::from_word_line(cache, line));
|
||||
}
|
||||
|
||||
Layout { lines }
|
||||
}
|
||||
}
|
||||
|
||||
/// An atomic fragment of processed text that is ready to be laid out.
|
||||
///
|
||||
/// May or may not correspond to a single English "word".
|
||||
///
|
||||
/// Please see [textwrap::core::Word] and [textwrap::core::Fragment] for more information.
|
||||
#[derive(Debug)]
|
||||
pub struct Word {
|
||||
pub word: usize,
|
||||
pub word_width: f32,
|
||||
pub whitespace: usize,
|
||||
pub whitespace_width: f32,
|
||||
pub penalty: usize,
|
||||
pub penalty_width: f32,
|
||||
}
|
||||
|
||||
impl textwrap::core::Fragment for Word {
|
||||
fn width(&self) -> f64 {
|
||||
self.word_width as f64
|
||||
}
|
||||
|
||||
fn whitespace_width(&self) -> f64 {
|
||||
self.whitespace_width as f64
|
||||
}
|
||||
|
||||
fn penalty_width(&self) -> f64 {
|
||||
self.penalty_width as f64
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct OwnedText {
|
||||
pub font: Font,
|
||||
pub layout: TextLayout,
|
||||
pub width: f32,
|
||||
}
|
||||
|
||||
impl OwnedText {
|
||||
pub fn borrow(&self) -> Text<'_> {
|
||||
Text {
|
||||
font: self.font,
|
||||
layout: &self.layout,
|
||||
width: self.width,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single piece of renderable text.
|
||||
#[derive(Debug)]
|
||||
pub struct Text<'a> {
|
||||
/// The font that this fragment has been laid out with.
|
||||
pub font: Font,
|
||||
|
||||
/// The draw-ready [TextLayout] of this fragment.
|
||||
pub layout: &'a TextLayout,
|
||||
|
||||
/// The width of this text.
|
||||
pub width: f32,
|
||||
}
|
||||
|
||||
/// A finished, wrapped text layout.
|
||||
pub struct Layout {
|
||||
pub lines: Vec<Line>,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub fn draw(
|
||||
&self,
|
||||
cache: &TextCache,
|
||||
ctx: &DrawContext,
|
||||
scale: f32,
|
||||
line_height: f32,
|
||||
color: Color,
|
||||
) {
|
||||
let mut cursor = Vec2::ZERO;
|
||||
for line in self.lines.iter() {
|
||||
let ctx = ctx.with_offset(cursor);
|
||||
line.draw(cache, &ctx, scale, color);
|
||||
cursor.y += line_height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A finished line of a layout.
|
||||
pub struct Line {
|
||||
pub fragments: Vec<Fragment>,
|
||||
}
|
||||
|
||||
impl Line {
|
||||
pub fn from_word_line(cache: &TextCache, words: &[Word]) -> Self {
|
||||
let last_idx = words.len() - 1;
|
||||
let mut fragments = Vec::new();
|
||||
|
||||
let mut add_word = |index: usize, hidden: bool| {
|
||||
let text = cache.get(index);
|
||||
|
||||
fragments.push(Fragment {
|
||||
font: text.font,
|
||||
text: index,
|
||||
offset: Vec2::ZERO,
|
||||
advance: text.width,
|
||||
hidden,
|
||||
});
|
||||
};
|
||||
|
||||
for (idx, word) in words.iter().enumerate() {
|
||||
add_word(word.word, false);
|
||||
|
||||
if idx == last_idx {
|
||||
add_word(word.penalty, false);
|
||||
} else {
|
||||
add_word(word.whitespace, true);
|
||||
}
|
||||
}
|
||||
|
||||
Self { fragments }
|
||||
}
|
||||
|
||||
pub fn draw(&self, cache: &TextCache, ctx: &DrawContext, scale: f32, color: Color) {
|
||||
let mut cursor = Vec2::ZERO;
|
||||
for fragment in self.fragments.iter() {
|
||||
if !fragment.hidden {
|
||||
let text = cache.get(fragment.text);
|
||||
let offset = cursor + fragment.offset;
|
||||
ctx.draw_text_layout(text.layout, offset, scale, color);
|
||||
}
|
||||
|
||||
cursor.x += fragment.advance * scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A finished fragment in a layout.
|
||||
pub struct Fragment {
|
||||
/// The font of this fragment.
|
||||
pub font: Font,
|
||||
|
||||
/// The index into the [TextCache] of the content of this fragment.
|
||||
pub text: usize,
|
||||
|
||||
/// The offset for drawing the text layout.
|
||||
pub offset: Vec2,
|
||||
|
||||
/// The horizontal advance to draw the next fragment.
|
||||
pub advance: f32,
|
||||
|
||||
/// Whether this fragment should be skipped while drawing.
|
||||
pub hidden: bool,
|
||||
}
|
Loading…
Reference in New Issue