diff --git a/Cargo.toml b/Cargo.toml index 9479031..4816475 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "apps/notifications", "apps/sandbox", "crates/script", + "crates/textwrap", "scripts/music-player", "scripts/sao-ui", ] diff --git a/crates/script/src/api/mod.rs b/crates/script/src/api/mod.rs index 0df721e..47944cd 100644 --- a/crates/script/src/api/mod.rs +++ b/crates/script/src/api/mod.rs @@ -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 { diff --git a/crates/textwrap/Cargo.toml b/crates/textwrap/Cargo.toml new file mode 100644 index 0000000..1462ef6 --- /dev/null +++ b/crates/textwrap/Cargo.toml @@ -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" diff --git a/crates/textwrap/src/lib.rs b/crates/textwrap/src/lib.rs new file mode 100644 index 0000000..d6feb28 --- /dev/null +++ b/crates/textwrap/src/lib.rs @@ -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, + fonts: Vec>, +} + +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, +} + +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, +} + +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, +} + +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, +}