diff --git a/Cargo.toml b/Cargo.toml index f26a129a6..8ddcabeee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -172,4 +172,4 @@ resolver = "2" [patch.crates-io.kas-text] git = "https://github.com/kas-gui/kas-text.git" -rev = "f8d88435996ccacebc4893793dc47580be2251b7" +rev = "dc34ef6212dbe8ba539d50a0a3bff1bf2cbad102" diff --git a/crates/kas-core/Cargo.toml b/crates/kas-core/Cargo.toml index 3ebfcc27f..32a2a58b2 100644 --- a/crates/kas-core/Cargo.toml +++ b/crates/kas-core/Cargo.toml @@ -49,7 +49,7 @@ ab_glyph = ["kas-text/ab_glyph", "dep:ab_glyph"] shaping = ["kas-text/shaping"] # Enable Markdown parsing -markdown = ["kas-text/markdown"] +markdown = ["pulldown-cmark"] # Enable support for YAML (de)serialisation yaml = ["serde", "dep:serde_yaml2"] @@ -116,6 +116,7 @@ swash = { version = "0.2.4", features = ["scale"] } linearize = { version = "0.1.5", features = ["derive"] } kas-text = "0.9.0" easy-cast = "0.5.4" # used in doc links +pulldown-cmark = { version = "0.13.0", optional = true } [dependencies.kas-macros] version = "=0.17.0" # pinned because kas-macros makes assumptions about kas-core's internals @@ -142,6 +143,7 @@ neg_cmp_op_on_partial_ord = "allow" collapsible_if = "allow" collapsible_else_if = "allow" bool_comparison = "allow" +option_map_unit_fn = "allow" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(internal_doc)'] } diff --git a/crates/kas-core/src/draw/draw.rs b/crates/kas-core/src/draw/draw.rs index 2421d81c2..bec5c57ef 100644 --- a/crates/kas-core/src/draw/draw.rs +++ b/crates/kas-core/src/draw/draw.rs @@ -9,7 +9,8 @@ use super::{AnimationState, color::Rgba}; #[allow(unused)] use super::{DrawRounded, DrawRoundedImpl}; use super::{DrawShared, DrawSharedImpl, ImageId, PassId, PassType, SharedState, WindowCommon}; use crate::geom::{Offset, Quad, Rect, Vec2}; -use crate::text::{Effect, TextDisplay}; +use crate::text::{TextDisplay, format}; +use crate::theme::ColorsLinear; use std::any::Any; use std::time::Instant; @@ -127,8 +128,19 @@ impl<'a, DS: DrawSharedImpl> DrawIface<'a, DS> { /// /// The `text` display must be prepared prior to calling this method. /// Typically this is done using a [`crate::theme::Text`] object. - pub fn text(&mut self, pos: Vec2, bounding_box: Quad, text: &TextDisplay, col: Rgba) { - self.text_effects(pos, bounding_box, text, &[col], &[]); + pub fn text_with_color( + &mut self, + pos: Vec2, + bounding_box: Quad, + text: &TextDisplay, + theme: &ColorsLinear, + col: Rgba, + ) { + let tokens = [(0, format::Colors { + color: format::Color::from_index(0).unwrap(), + ..Default::default() + })]; + self.text(pos, bounding_box, text, theme, &[col], &tokens); } } @@ -223,25 +235,34 @@ pub trait Draw { /// Draw the image in the given `rect` fn image(&mut self, id: ImageId, rect: Quad); - /// Draw text with effects + /// Draw text with a list of color effects /// /// Text is drawn from `pos` and clipped to `bounding_box`. /// - /// The `effects` list provides underlining/strikethrough information via - /// [`Effect::flags`] and an index [`Effect::color`]. - /// - /// Text colour lookup uses index `color` and is essentially: - /// `colors.get(color.unwrap_or(Rgba::BLACK)`. + /// The `text` display must be prepared prior to calling this method. + /// Typically this is done using a [`crate::theme::Text`] object. + fn text( + &mut self, + pos: Vec2, + bounding_box: Quad, + text: &TextDisplay, + theme: &ColorsLinear, + palette: &[Rgba], + tokens: &[(u32, format::Colors)], + ); + + /// Draw text decorations (e.g. underlines) /// /// The `text` display must be prepared prior to calling this method. /// Typically this is done using a [`crate::theme::Text`] object. - fn text_effects( + fn decorate_text( &mut self, pos: Vec2, bounding_box: Quad, text: &TextDisplay, - colors: &[Rgba], - effects: &[Effect], + theme: &ColorsLinear, + palette: &[Rgba], + decorations: &[(u32, format::Decoration)], ); } @@ -293,17 +314,39 @@ impl<'a, DS: DrawSharedImpl> Draw for DrawIface<'a, DS> { self.shared.draw.draw_image(self.draw, self.pass, id, rect); } - fn text_effects( + fn text( &mut self, pos: Vec2, bb: Quad, text: &TextDisplay, - colors: &[Rgba], - effects: &[Effect], + theme: &ColorsLinear, + palette: &[Rgba], + tokens: &[(u32, format::Colors)], ) { self.shared .draw - .draw_text_effects(self.draw, self.pass, pos, bb, text, colors, effects); + .draw_text(self.draw, self.pass, pos, bb, text, theme, palette, tokens); + } + + fn decorate_text( + &mut self, + pos: Vec2, + bb: Quad, + text: &TextDisplay, + theme: &ColorsLinear, + palette: &[Rgba], + decorations: &[(u32, format::Decoration)], + ) { + self.shared.draw.decorate_text( + self.draw, + self.pass, + pos, + bb, + text, + theme, + palette, + decorations, + ); } } diff --git a/crates/kas-core/src/draw/draw_shared.rs b/crates/kas-core/src/draw/draw_shared.rs index aba1e1ae2..375c6a97c 100644 --- a/crates/kas-core/src/draw/draw_shared.rs +++ b/crates/kas-core/src/draw/draw_shared.rs @@ -10,7 +10,8 @@ use super::{DrawImpl, PassId}; use crate::ActionRedraw; use crate::config::RasterConfig; use crate::geom::{Quad, Size, Vec2}; -use crate::text::{Effect, TextDisplay}; +use crate::text::{TextDisplay, format}; +use crate::theme::ColorsLinear; use std::any::Any; use std::num::NonZeroU32; use std::sync::Arc; @@ -201,21 +202,37 @@ pub trait DrawSharedImpl: Any { /// Draw the image in the given `rect` fn draw_image(&self, draw: &mut Self::Draw, pass: PassId, id: ImageId, rect: Quad); - /// Draw text with effects + /// Draw text with a list of color effects /// - /// The `effects` list provides underlining/strikethrough information via - /// [`Effect::flags`] and an index [`Effect::color`]. + /// Text is drawn from `pos` and clipped to `bounding_box`. /// - /// Text colour lookup uses index `color` and is essentially: - /// `colors.get(color.unwrap_or(Rgba::BLACK)`. - fn draw_text_effects( + /// The `text` display must be prepared prior to calling this method. + /// Typically this is done using a [`crate::theme::Text`] object. + fn draw_text( &mut self, draw: &mut Self::Draw, pass: PassId, pos: Vec2, - bb: Quad, + bounding_box: Quad, text: &TextDisplay, - colors: &[Rgba], - effects: &[Effect], + theme: &ColorsLinear, + palette: &[Rgba], + tokens: &[(u32, format::Colors)], + ); + + /// Draw text decorations (e.g. underlines) + /// + /// The `text` display must be prepared prior to calling this method. + /// Typically this is done using a [`crate::theme::Text`] object. + fn decorate_text( + &mut self, + draw: &mut Self::Draw, + pass: PassId, + pos: Vec2, + bounding_box: Quad, + text: &TextDisplay, + theme: &ColorsLinear, + palette: &[Rgba], + decorations: &[(u32, format::Decoration)], ); } diff --git a/crates/kas-core/src/text/format.rs b/crates/kas-core/src/text/format.rs new file mode 100644 index 000000000..994fe7c7f --- /dev/null +++ b/crates/kas-core/src/text/format.rs @@ -0,0 +1,257 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License in the LICENSE-APACHE file or at: +// https://www.apache.org/licenses/LICENSE-2.0 + +//! Formatted text traits and types + +use std::num::NonZeroU16; + +use super::fonts::FontSelector; +use crate::{draw::color::Rgba, theme::ColorsLinear}; +pub use kas_text::format::FontToken; + +#[cfg(feature = "markdown")] mod markdown; +#[cfg(feature = "markdown")] pub use markdown::Markdown; + +/// Palette or theme-provided color value +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Color(NonZeroU16); + +impl Default for Color { + /// Use a theme-defined color (automatic) + /// + /// For backgrounds, this uses the text-selection color. + fn default() -> Self { + Color(NonZeroU16::new(1).unwrap()) + } +} + +impl Color { + /// Use a color palette index + /// + /// Returns `None` if `index >= 0x8000`. + #[inline] + pub fn from_index(index: usize) -> Option { + (index < 0x8000) + .then_some(NonZeroU16::new(0x8000 | (index as u16)).map(Color)) + .flatten() + } + + /// Get the palette index, if any + #[inline] + pub fn as_index(self) -> Option { + let color = self.0.get(); + (color & 0x8000 != 0).then_some((color & 0x7FFF) as usize) + } + + /// Validate index values against a given palette + #[cfg(debug_assertions)] + pub fn validate(self, palette: &[Rgba]) { + if let Some(index) = self.as_index() { + assert!( + index < palette.len(), + "text::format::Color: invalid palette index" + ); + } + } + + /// Resolve color from the palette + /// + /// This returns `None` if either this does not represent a palette index or + /// the represented index is not present in the palette. + #[inline] + pub fn resolve_palette_color(self, palette: &[Rgba]) -> Option { + self.as_index() + .and_then(|index| palette.get(index).cloned()) + } + + /// Resolve color + /// + /// If this represents a valid palette index, the palette's color will be + /// used, otherwise the color will be inferred from the theme, using `bg` + /// if provided. + #[inline] + pub fn resolve_color(self, theme: &ColorsLinear, palette: &[Rgba], bg: Option) -> Rgba { + if let Some(col) = self.resolve_palette_color(palette) { + col + } else if let Some(bg) = bg { + theme.text_over(bg) + } else { + theme.text + } + } +} + +/// Effect formatting marker: text and background color +/// +/// By default, this uses the theme's text color without a background. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Colors { + pub color: Color, + pub background: Option, +} + +impl Colors { + /// Resolve the background color, if any + pub fn resolve_background_color(self, theme: &ColorsLinear, palette: &[Rgba]) -> Option { + self.background.map(|bg| { + bg.resolve_palette_color(palette) + .unwrap_or(theme.text_sel_bg) + }) + } +} + +/// Decoration types +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum DecorationType { + /// No decoration + #[default] + None, + /// Glyph is underlined + Underline, + /// Glyph is crossed through by a center-line + Strikethrough, +} + +/// Decoration styles +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum LineStyle { + /// A single solid line + #[default] + Solid, +} + +/// Effect formatting marker: strikethrough and underline decorations +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Decoration { + /// Type of decoration + pub dec: DecorationType, + /// Line style + pub style: LineStyle, + /// Line color + pub color: Color, +} + +/// Text, optionally with formatting data +pub trait FormattableText: std::cmp::PartialEq { + /// Access whole text as contiguous `str` + fn as_str(&self) -> &str; + + /// Return an iterator of font tokens + /// + /// These tokens are used to select the font and font size. + /// Each text object has a configured + /// [font size][crate::theme::Text::set_font_size] and [`FontSelector`]; these + /// values are passed as a reference (`dpem` and `font`). + /// + /// The iterator is expected to yield a stream of tokens such that + /// [`FontToken::start`] values are strictly increasing, less than + /// `self.as_str().len()` and at `char` boundaries (i.e. an index value + /// returned by [`str::char_indices`]. In case the returned iterator is + /// empty or the first [`FontToken::start`] value is greater than zero the + /// reference `dpem` and `font` values are used. + /// + /// Any changes to the result of this method require full re-preparation of + /// text since this affects run breaking and font resolution. + fn font_tokens(&self, dpem: f32, font: FontSelector) -> impl Iterator; + + /// Return the sequence of color effect tokens + /// + /// These tokens may be used for rendering effects: glyph color and + /// background color. + /// + /// Use `&[]` to use the default `Colors` everywhere, or use a sequence such + /// that `tokens[i].0` values are strictly increasing. A glyph for index `j` + /// in the source text will use the colors `tokens[i].1` where `i` is the + /// largest value such that `tokens[i].0 <= j`, or the default `Colors` if + /// no such `i` exists. + /// + /// Changes to the result of this method do not require any re-preparation + /// of text. + /// + /// The default implementation returns `&[]`. + #[inline] + fn color_tokens(&self) -> &[(u32, Colors)] { + &[] + } + + /// Return optional sequences of decoration tokens + /// + /// These tokens may be used for rendering effects: strike-through and + /// underline decorations. + /// + /// Use `&[]` for no decorations, or use a sequence such that `tokens[i].0` + /// values are strictly increasing. A glyph for index `j` in the source text + /// will use the decoration `tokens[i].1` where `i` is the largest value + /// such that `tokens[i].0 <= j`, or no decoration if no such `i` exists. + /// + /// Changes to the result of this method do not require any re-preparation + /// of text. + /// + /// The default implementation returns `&[]`. + #[inline] + fn decorations(&self) -> &[(u32, Decoration)] { + &[] + } +} + +impl FormattableText for str { + #[inline] + fn as_str(&self) -> &str { + self + } + + #[inline] + fn font_tokens(&self, dpem: f32, font: FontSelector) -> impl Iterator { + let start = 0; + std::iter::once(FontToken { start, dpem, font }) + } +} + +impl FormattableText for String { + #[inline] + fn as_str(&self) -> &str { + self + } + + #[inline] + fn font_tokens(&self, dpem: f32, font: FontSelector) -> impl Iterator { + let start = 0; + std::iter::once(FontToken { start, dpem, font }) + } +} + +impl FormattableText for &F { + fn as_str(&self) -> &str { + F::as_str(self) + } + + fn font_tokens(&self, dpem: f32, font: FontSelector) -> impl Iterator { + F::font_tokens(self, dpem, font) + } + + fn color_tokens(&self) -> &[(u32, Colors)] { + F::color_tokens(self) + } + + fn decorations(&self) -> &[(u32, Decoration)] { + F::decorations(self) + } +} + +#[cfg(test)] +#[test] +fn sizes() { + use std::mem::size_of; + + assert_eq!(size_of::(), 4); + assert_eq!(size_of::(), 1); + assert_eq!(size_of::(), 0); + assert_eq!(size_of::(), 4); +} diff --git a/crates/kas-core/src/text/format/markdown.rs b/crates/kas-core/src/text/format/markdown.rs new file mode 100644 index 000000000..efd8e9d94 --- /dev/null +++ b/crates/kas-core/src/text/format/markdown.rs @@ -0,0 +1,395 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License in the LICENSE-APACHE file or at: +// https://www.apache.org/licenses/LICENSE-2.0 + +//! Markdown parsing + +use super::{Decoration, DecorationType, FontToken, FormattableText}; +use crate::cast::Cast; +use crate::text::fonts::{FamilySelector, FontSelector, FontStyle, FontWeight}; +use pulldown_cmark::{Event, HeadingLevel, Tag, TagEnd}; +use std::fmt::Write; +use std::iter::FusedIterator; +use thiserror::Error; + +/// Markdown parsing errors +#[derive(Error, Debug)] +pub enum Error { + #[error("Not supported by Markdown parser: {0}")] + NotSupported(&'static str), +} + +/// Basic Markdown formatter +/// +/// Currently this misses several important Markdown features, but may still +/// prove a convenient way of constructing formatted texts. +/// +/// Supported: +/// +/// - Text paragraphs +/// - Code (embedded and blocks); caveat: extra line after code blocks +/// - Explicit line breaks +/// - Headings +/// - Lists (numerated and bulleted); caveat: indentation after first line +/// - Bold, italic (emphasis), strike-through +/// +/// Not supported: +/// +/// - Block quotes +/// - Footnotes +/// - HTML +/// - Horizontal rules +/// - Images +/// - Links +/// - Tables +/// - Task lists +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Markdown { + text: String, + fmt: Vec, + decorations: Vec<(u32, Decoration)>, +} + +impl Markdown { + /// Parse the input as Markdown + /// + /// Parsing happens immediately. Fonts must be initialized before calling + /// this method. + #[inline] + pub fn new(input: &str) -> Result { + parse(input) + } +} + +pub struct FontTokenIter<'a> { + index: usize, + fmt: &'a [Fmt], + base_dpem: f32, + base_font: FontSelector, +} + +impl<'a> FontTokenIter<'a> { + fn new(fmt: &'a [Fmt], base_dpem: f32, base_font: FontSelector) -> Self { + FontTokenIter { + index: 0, + fmt, + base_dpem, + base_font, + } + } +} + +impl<'a> Iterator for FontTokenIter<'a> { + type Item = FontToken; + + fn next(&mut self) -> Option { + if self.index < self.fmt.len() { + let fmt = &self.fmt[self.index]; + self.index += 1; + let start = fmt.start; + let dpem = self.base_dpem * fmt.rel_size; + + let mut font = self.base_font; + if fmt.bold { + font.weight = FontWeight::BOLD; + } + if fmt.italic { + font.style = FontStyle::Italic; + } + if fmt.monospace { + font.family = FamilySelector::MONOSPACE; + } + Some(FontToken { start, font, dpem }) + } else { + None + } + } + + fn size_hint(&self) -> (usize, Option) { + let len = self.fmt.len(); + (len, Some(len)) + } +} + +impl<'a> ExactSizeIterator for FontTokenIter<'a> {} +impl<'a> FusedIterator for FontTokenIter<'a> {} + +impl FormattableText for Markdown { + #[inline] + fn as_str(&self) -> &str { + &self.text + } + + #[inline] + fn font_tokens(&self, dpem: f32, font: FontSelector) -> impl Iterator { + FontTokenIter::new(&self.fmt, dpem, font) + } + + #[inline] + fn decorations(&self) -> &[(u32, Decoration)] { + &self.decorations + } +} + +fn parse(input: &str) -> Result { + let mut text = String::with_capacity(input.len()); + let mut fmt: Vec = vec![Fmt::default()]; + let mut set_last = |item: &StackItem| { + let f = item.fmt.clone(); + if let Some(last) = fmt.last_mut() + && last.start >= item.fmt.start + { + *last = f; + return; + } + fmt.push(f); + }; + + let mut state = State::None; + let mut stack = Vec::with_capacity(16); + let mut item = StackItem::default(); + + let options = pulldown_cmark::Options::ENABLE_STRIKETHROUGH; + for ev in pulldown_cmark::Parser::new_ext(input, options) { + match ev { + Event::Start(tag) => { + item.fmt.start = text.len().cast(); + if let Some(clone) = item.start_tag(&mut text, &mut state, tag)? { + stack.push(item); + item = clone; + set_last(&item); + } + } + Event::End(tag) => { + if item.end_tag(&mut state, tag) { + item = stack.pop().unwrap(); + item.fmt.start = text.len().cast(); + set_last(&item); + } + } + Event::Text(part) => { + state.part(&mut text); + text.push_str(&part); + } + Event::Code(part) => { + state.part(&mut text); + item.fmt.start = text.len().cast(); + + let mut item2 = item.clone(); + item2.fmt.monospace = true; + set_last(&item2); + + text.push_str(&part); + + item.fmt.start = text.len().cast(); + set_last(&item); + } + Event::InlineMath(_) | Event::DisplayMath(_) => { + return Err(Error::NotSupported("math expressions")); + } + Event::Html(_) | Event::InlineHtml(_) => { + return Err(Error::NotSupported("embedded HTML")); + } + Event::FootnoteReference(_) => return Err(Error::NotSupported("footnote")), + Event::SoftBreak => state.soft_break(&mut text), + Event::HardBreak => state.hard_break(&mut text), + Event::Rule => return Err(Error::NotSupported("horizontal rule")), + Event::TaskListMarker(_) => return Err(Error::NotSupported("task list")), + } + } + + // TODO(opt): don't need to store flags in fmt? + let mut decorations = Vec::new(); + let mut strikethrough = false; + for token in &fmt { + if token.strikethrough != strikethrough { + let mut dec = Decoration::default(); + if token.strikethrough { + dec.dec = DecorationType::Strikethrough; + } + decorations.push((token.start, dec)); + strikethrough = token.strikethrough; + } + } + + Ok(Markdown { + text, + fmt, + decorations, + }) +} + +#[derive(Copy, Clone, Debug, PartialEq)] +enum State { + None, + BlockStart, + BlockEnd, + ListItem, + Part, +} + +impl State { + fn start_block(&mut self, text: &mut String) { + match *self { + State::None | State::BlockStart => (), + State::BlockEnd | State::ListItem | State::Part => text.push_str("\n\n"), + } + *self = State::BlockStart; + } + fn end_block(&mut self) { + *self = State::BlockEnd; + } + fn part(&mut self, text: &mut String) { + match *self { + State::None | State::BlockStart | State::Part | State::ListItem => (), + State::BlockEnd => text.push_str("\n\n"), + } + *self = State::Part; + } + fn list_item(&mut self, text: &mut String) { + match *self { + State::None | State::BlockStart | State::BlockEnd => { + debug_assert_eq!(*self, State::BlockStart); + } + State::ListItem | State::Part => text.push('\n'), + } + *self = State::ListItem; + } + fn soft_break(&mut self, text: &mut String) { + text.push(' '); + } + fn hard_break(&mut self, text: &mut String) { + text.push('\n'); + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Fmt { + start: u32, + rel_size: f32, + bold: bool, + italic: bool, + monospace: bool, + strikethrough: bool, +} + +impl Default for Fmt { + fn default() -> Self { + Fmt { + start: 0, + rel_size: 1.0, + bold: false, + italic: false, + monospace: false, + strikethrough: false, + } + } +} + +#[derive(Clone, Debug, Default)] +struct StackItem { + list: Option, + fmt: Fmt, +} + +impl StackItem { + // process a tag; may modify current item and may return new item + fn start_tag( + &mut self, + text: &mut String, + state: &mut State, + tag: Tag, + ) -> Result, Error> { + fn with_clone(s: &mut StackItem, c: F) -> Option { + let mut item = s.clone(); + c(&mut item); + Some(item) + } + + Ok(match tag { + Tag::Paragraph => { + state.start_block(text); + None + } + Tag::Heading { level, .. } => { + state.start_block(text); + self.fmt.start = text.len().cast(); + with_clone(self, |item| { + // CSS sizes: https://www.w3.org/TR/2018/REC-css-fonts-3-20180920/#font-size-prop + item.fmt.rel_size = match level { + HeadingLevel::H1 => 2.0 / 1.0, + HeadingLevel::H2 => 3.0 / 2.0, + HeadingLevel::H3 => 6.0 / 5.0, + HeadingLevel::H4 => 1.0, + HeadingLevel::H5 => 8.0 / 9.0, + HeadingLevel::H6 => 3.0 / 5.0, + } + }) + } + Tag::CodeBlock(_) => { + state.start_block(text); + self.fmt.start = text.len().cast(); + with_clone(self, |item| { + item.fmt.monospace = true; + }) + // TODO: within a code block, the last \n should be suppressed? + } + Tag::HtmlBlock => return Err(Error::NotSupported("embedded HTML")), + Tag::List(start) => { + state.start_block(text); + self.list = start; + None + } + Tag::Item => { + state.list_item(text); + // NOTE: we use \t for indent, which indents only the first + // line. Without better flow control we cannot fix this. + match &mut self.list { + Some(x) => { + write!(text, "{x}\t").unwrap(); + *x += 1; + } + None => text.push_str("•\t"), + } + None + } + Tag::Emphasis => with_clone(self, |item| item.fmt.italic = true), + Tag::Strong => with_clone(self, |item| item.fmt.bold = true), + Tag::Strikethrough => with_clone(self, |item| { + item.fmt.strikethrough = true; + }), + Tag::BlockQuote(_) => return Err(Error::NotSupported("block quote")), + Tag::FootnoteDefinition(_) => return Err(Error::NotSupported("footnote")), + Tag::DefinitionList | Tag::DefinitionListTitle | Tag::DefinitionListDefinition => { + return Err(Error::NotSupported("definition")); + } + Tag::Table(_) | Tag::TableHead | Tag::TableRow | Tag::TableCell => { + return Err(Error::NotSupported("table")); + } + Tag::Superscript | Tag::Subscript => { + // kas-text doesn't support adjusting the baseline + return Err(Error::NotSupported("super/subscript")); + } + Tag::Link { .. } => return Err(Error::NotSupported("link")), + Tag::Image { .. } => return Err(Error::NotSupported("image")), + Tag::MetadataBlock(_) => return Err(Error::NotSupported("metadata block")), + }) + } + // returns true if stack must be popped + fn end_tag(&self, state: &mut State, tag: TagEnd) -> bool { + match tag { + TagEnd::Paragraph | TagEnd::List(_) => { + state.end_block(); + false + } + TagEnd::Heading(_) | TagEnd::CodeBlock => { + state.end_block(); + true + } + TagEnd::Item => false, + TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => true, + tag => unimplemented!("{:?}", tag), + } + } +} diff --git a/crates/kas-core/src/text/mod.rs b/crates/kas-core/src/text/mod.rs index 9e86c9a17..e8600687f 100644 --- a/crates/kas-core/src/text/mod.rs +++ b/crates/kas-core/src/text/mod.rs @@ -14,10 +14,12 @@ //! [KAS Text]: https://github.com/kas-gui/kas-text/ pub use kas_text::{ - Align, DPU, Direction, Effect, EffectFlags, Line, LineIterator, MarkerPos, MarkerPosIter, - NotReady, Status, TextDisplay, Vec2, fonts, format, + Align, DPU, Direction, Line, LineIterator, MarkerPos, MarkerPosIter, NotReady, Status, + TextDisplay, Vec2, fonts, }; +pub mod format; + /// Glyph rastering #[cfg_attr(not(feature = "internal_doc"), doc(hidden))] #[cfg_attr(docsrs, doc(cfg(internal_doc)))] diff --git a/crates/kas-core/src/text/raster.rs b/crates/kas-core/src/text/raster.rs index d22ba7709..61635db5d 100644 --- a/crates/kas-core/src/text/raster.rs +++ b/crates/kas-core/src/text/raster.rs @@ -10,17 +10,18 @@ //! Text drawing pipeline +use crate::config::SubpixelMode; +use crate::text::format::{Color, Colors, Decoration, DecorationType, LineStyle}; +use crate::theme::ColorsLinear; use kas::cast::traits::*; use kas::config::RasterConfig; use kas::draw::{AllocError, Allocation, PassId, color::Rgba}; use kas::geom::{Quad, Vec2}; use kas_text::fonts::{self, FaceId}; -use kas_text::{Effect, Glyph, GlyphId, TextDisplay}; +use kas_text::{Glyph, GlyphId, TextDisplay}; use rustc_hash::FxHashMap as HashMap; use swash::zeno::Format; -use crate::config::SubpixelMode; - /// Number of sub-pixel text sizes /// /// Text `dpem * SCALE_STEPS` is rounded to the nearest integer, so for example @@ -501,8 +502,8 @@ impl State { } } - /// Draw text as a sequence of sprites - pub fn text( + /// Draw text with a single color + pub fn text_with_color( &mut self, allocator: &mut dyn SpriteAllocator, queue: &mut dyn RenderQueue, @@ -512,7 +513,7 @@ impl State { text: &TextDisplay, col: Rgba, ) { - for run in text.runs(pos.into(), &[]) { + for run in text.runs::<()>(pos.into(), &[]) { let face = run.face_id(); let dpem = run.dpem(); for glyph in run.glyphs() { @@ -532,9 +533,9 @@ impl State { } } - /// Draw text with effects as a sequence of sprites + /// Draw text with color effects #[allow(clippy::too_many_arguments)] - pub fn text_effects( + pub fn text( &mut self, allocator: &mut dyn SpriteAllocator, queue: &mut dyn RenderQueue, @@ -542,26 +543,27 @@ impl State { pos: Vec2, bb: Quad, text: &TextDisplay, - colors: &[Rgba], - effects: &[Effect], + theme: &ColorsLinear, + palette: &[Rgba], + tokens: &[(u32, Colors)], mut draw_quad: impl FnMut(Quad, Rgba), ) { // Optimisation: use cheaper TextDisplay::runs method - if effects.len() <= 1 - && effects + if tokens.len() <= 1 + && tokens .first() - .map(|e| e.flags == Default::default()) + .map(|e| e.1 == Default::default()) .unwrap_or(true) { - let col = colors.first().cloned().unwrap_or(Rgba::BLACK); - self.text(allocator, queue, pass, pos, bb, text, col); + let col = Color::default().resolve_color(theme, palette, None); + self.text_with_color(allocator, queue, pass, pos, bb, text, col); return; } - for run in text.runs(pos.into(), effects) { + for run in text.runs(pos.into(), tokens) { let face = run.face_id(); let dpem = run.dpem(); - let for_glyph = |glyph: Glyph, e: u16| { + let for_glyph = |glyph: Glyph, token: Colors| { let desc = SpriteDescriptor::new(&self.config, face, glyph, dpem); let sprite = match self.glyphs.get(&desc) { Some(sprite) => sprite, @@ -573,20 +575,74 @@ impl State { } } }; - let col = colors.get(usize::conv(e)).cloned().unwrap_or(Rgba::BLACK); + + let col = token.color.resolve_color(theme, palette, None); queue.push_sprite(pass, glyph.position.into(), bb, col, sprite); }; - let for_rect = |x1, x2, y: f32, h: f32, e: u16| { - let y = y.ceil(); - let y2 = y + h.ceil(); - if let Some(quad) = Quad::from_coords(Vec2(x1, y), Vec2(x2, y2)).intersection(&bb) { - let col = colors.get(usize::conv(e)).cloned().unwrap_or(Rgba::BLACK); + let for_range = |p: kas_text::Vec2, x2, colors: Colors| { + let Some(col) = colors.resolve_background_color(theme, palette) else { + return; + }; + + let a = Vec2(p.0, run.line_top()); + let b = Vec2(x2, run.line_bottom()); + if let Some(quad) = Quad::from_coords(a, b).intersection(&bb) { draw_quad(quad, col); } }; - run.glyphs_with_effects(for_glyph, for_rect); + run.glyphs_with_effects(for_glyph, for_range); + } + } + + /// Draw text decorations (e.g. underlines) + #[allow(clippy::too_many_arguments)] + pub fn decorate_text( + &mut self, + pos: Vec2, + bb: Quad, + text: &TextDisplay, + theme: &ColorsLinear, + palette: &[Rgba], + tokens: &[(u32, Decoration)], + mut draw_quad: impl FnMut(Quad, Rgba), + ) { + // Optimisation: do nothing + if tokens.len() <= 1 + && tokens + .first() + .map(|e| e.1 == Default::default()) + .unwrap_or(true) + { + return; + } + + for run in text.runs(pos.into(), tokens) { + let sf = run.scaled_face(); + let for_range = |p: kas_text::Vec2, x2, token: Decoration| { + let metrics = match token.dec { + DecorationType::None => return, + DecorationType::Underline => sf.underline_metrics(), + DecorationType::Strikethrough => sf.strikethrough_metrics(), + }; + let Some(metrics) = metrics else { return }; + + let a = Vec2(p.0, p.1 - metrics.top.ceil()); + let b = Vec2(x2, a.1 + metrics.thickness.ceil()); + let Some(quad) = Quad::from_coords(a, b).intersection(&bb) else { + return; + }; + + // Known limitation: this cannot depend on the background color. + let col = token.color.resolve_color(theme, palette, None); + + match token.style { + LineStyle::Solid => draw_quad(quad, col), + } + }; + + run.glyphs_with_effects(|_, _| (), for_range); } } } diff --git a/crates/kas-core/src/text/selection.rs b/crates/kas-core/src/text/selection.rs index 5d03c2b26..7570e687c 100644 --- a/crates/kas-core/src/text/selection.rs +++ b/crates/kas-core/src/text/selection.rs @@ -5,9 +5,9 @@ //! Tools for text selection +use crate::text::format::FormattableText; use crate::theme::Text; use kas_macros::autoimpl; -use kas_text::format::FormattableText; use std::ops::Range; use unicode_segmentation::UnicodeSegmentation; diff --git a/crates/kas-core/src/text/string.rs b/crates/kas-core/src/text/string.rs index 9cd50d378..a7bc85645 100644 --- a/crates/kas-core/src/text/string.rs +++ b/crates/kas-core/src/text/string.rs @@ -11,8 +11,8 @@ use crate::cast::Conv; use crate::event::Key; -use crate::text::format::{FontToken, FormattableText}; -use crate::text::{Effect, EffectFlags, fonts::FontSelector}; +use crate::text::fonts::FontSelector; +use crate::text::format::{Decoration, DecorationType, FontToken, FormattableText}; /// An access key string /// @@ -29,7 +29,7 @@ use crate::text::{Effect, EffectFlags, fonts::FontSelector}; #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct AccessString { text: String, - key: Option<(Key, [Effect; 2])>, + key: Option<(Key, [(u32, Decoration); 2])>, } impl AccessString { @@ -60,20 +60,15 @@ impl AccessString { let k = c.to_ascii_lowercase().encode_utf8(&mut kbuf); let k = Key::Character(k.into()); - let e0 = Effect { - start, - color: 0, - flags: EffectFlags::UNDERLINE, - }; + let e0 = (start, Decoration { + dec: DecorationType::Underline, + ..Decoration::default() + }); let i = c.len_utf8(); s = &s[i..]; - let e1 = Effect { - start: start + u32::conv(i), - color: 0, - flags: EffectFlags::empty(), - }; + let e1 = (start + u32::conv(i), Decoration::default()); key = Some((k, [e0, e1])); } @@ -90,7 +85,7 @@ impl AccessString { } /// Get the key bindings and associated effects, if any - pub fn key(&self) -> Option<&(Key, [Effect; 2])> { + pub fn key(&self) -> Option<&(Key, [(u32, Decoration); 2])> { self.key.as_ref() } @@ -107,13 +102,15 @@ impl FormattableText for AccessString { } #[inline] - fn font_tokens(&self, _: f32, _: FontSelector) -> impl Iterator { - std::iter::empty() + fn font_tokens(&self, dpem: f32, font: FontSelector) -> impl Iterator { + std::iter::once(FontToken { + start: 0, + dpem, + font, + }) } - fn effect_tokens(&self) -> &[Effect] { - &[] - } + // Note that we do not display underline decorations by default } impl From for AccessString { diff --git a/crates/kas-core/src/theme/colors.rs b/crates/kas-core/src/theme/colors.rs index 0c2f49211..2c73a2845 100644 --- a/crates/kas-core/src/theme/colors.rs +++ b/crates/kas-core/src/theme/colors.rs @@ -156,6 +156,8 @@ pub struct Colors { /// Opposing text colour (e.g. white if `text` is black) pub text_invert: C, /// Disabled text colour + /// + /// NOTE: this is not currently used. pub text_disabled: C, /// Selected text background colour /// diff --git a/crates/kas-core/src/theme/draw.rs b/crates/kas-core/src/theme/draw.rs index a4658d947..990495518 100644 --- a/crates/kas-core/src/theme/draw.rs +++ b/crates/kas-core/src/theme/draw.rs @@ -14,11 +14,10 @@ use crate::draw::{Draw, DrawIface, DrawRounded, DrawShared, DrawSharedImpl, Imag use crate::event::EventState; #[allow(unused)] use crate::event::{Command, ConfigCx}; use crate::geom::{Coord, Offset, Rect}; -use crate::text::{Effect, TextDisplay, format::FormattableText}; +use crate::text::{TextDisplay, format, format::FormattableText}; use crate::theme::ColorsLinear; use crate::{Id, Tile, autoimpl}; #[allow(unused)] use crate::{Layout, theme::TextClass}; -use std::ops::Range; use std::time::Instant; /// Optional background colour @@ -276,9 +275,11 @@ impl<'a> DrawCx<'a> { /// Draw text /// - /// Text is clipped to `rect`. + /// Text colors are inferred from [`Text::color_tokens`] and decorations + /// from [`Text::decorations`]. /// - /// This is a convenience method over [`Self::text_with_position`]. + /// Text is clipped to `rect` and drawn without offset (see + /// [`Self::text_with_position`]). /// /// The `text` should be prepared before calling this method. pub fn text(&mut self, rect: Rect, text: &Text) { @@ -287,11 +288,12 @@ impl<'a> DrawCx<'a> { /// Draw text with effects and an offset /// + /// Text colors are inferred from [`Text::color_tokens`] and decorations + /// from [`Text::decorations`]. + /// /// Text is clipped to `rect`, drawing from `pos`; use `pos = rect.pos` if /// the text is not scrolled. /// - /// This is a convenience method over [`Self::text_with_effects`]. - /// /// The `text` should be prepared before calling this method. pub fn text_with_position( &mut self, @@ -300,87 +302,96 @@ impl<'a> DrawCx<'a> { text: &Text, ) { if let Ok(display) = text.display() { - self.text_with_effects(pos, rect, display, &[], text.effect_tokens()); + let tokens = text.color_tokens(); + self.text_with_colors(pos, rect, display, &[], tokens); + self.decorate_text(pos, rect, display, &[], text.decorations()); } } /// Draw text with specified color /// - /// Text is clipped to `rect` and drawn using `color`. + /// The given `color` is used, ignoring [`Text::color_tokens`] + /// Decorations are inferred from [`Text::decorations`]. /// - /// This is a convenience method over [`Self::text_with_effects`]. + /// Text is clipped to `rect` and drawn without offset (see + /// [`Self::text_with_position`]). /// /// The `text` should be prepared before calling this method. pub fn text_with_color(&mut self, rect: Rect, text: &Text, color: Rgba) { if let Ok(display) = text.display() { - self.text_with_effects(rect.pos, rect, display, &[color], text.effect_tokens()); + let colors = &[color]; + let tokens = [(0, format::Colors { + color: format::Color::from_index(0).unwrap(), + ..Default::default() + })]; + self.text_with_colors(rect.pos, rect, display, colors, &tokens); + self.decorate_text(rect.pos, rect, display, colors, text.decorations()); } } - /// Draw text with a given effect list + /// Draw text with a list of color tokens /// - /// Text is clipped to `rect`, drawing from `pos`; use `pos = rect.pos` if - /// the text is not scrolled. + /// Color `tokens` specify both foreground (text) and background colors. + /// Use `&[]` for no effects (uses the theme-default colors), or use a + /// sequence such that `effects[i].0` values are strictly increasing. + /// A glyph for index `j` in the source text will use effect `tokens[i].1` + /// where `i` is the largest value such that `tokens[i].0 <= j`, or the + /// default value of `format::Colors` if no such `i` exists. /// - /// If `colors` is empty, it is replaced with a single theme-defined color. - /// Text is then drawn using `colors[0]` except as specified by effects. + /// This method does not draw decorations; see also [`Self::decorate_text`]. /// - /// The list of `effects` (if not empty) controls render effects: - /// [`Effect::color`] is an index into `colors` while [`Effect::flags`] controls - /// underline and strikethrough. [`Effect::start`] is the text index at - /// which this effect first takes effect, and must effects must be ordered - /// such that the sequence of [`Effect::start`] values is strictly - /// increasing. [`Effect::default()`] is used if `effects` is empty or while - /// `index < effects.first().unwrap().start`. - /// - /// Text objects may embed their own list of effects, accessible using - /// [`Text::effect_tokens`]. It is always valid to disregard these - /// and use a custom `effects` list or empty list. + /// Text is clipped to `rect`, drawing from `pos`; use `pos = rect.pos` if + /// the text is not scrolled. #[inline] - pub fn text_with_effects( + pub fn text_with_colors( &mut self, pos: Coord, rect: Rect, display: &TextDisplay, - colors: &[Rgba], - effects: &[Effect], + palette: &[Rgba], + tokens: &[(u32, format::Colors)], ) { if cfg!(debug_assertions) { - let num_colors = if colors.is_empty() { 1 } else { colors.len() }; let mut i = 0; - for effect in effects { - assert!(effect.start >= i); - i = effect.start; - - assert!(usize::from(effect.color) < num_colors); + for (start, token) in tokens { + assert!(*start >= i); + i = *start; + token.color.validate(palette); + token.background.map(|bg| bg.validate(palette)); } } - self.h - .text_effects(&self.id, pos, rect, display, colors, effects); + self.h.text(&self.id, pos, rect, display, palette, tokens); } - /// Draw some text with a selection + /// Draw text decorations (e.g. underlines) + /// + /// This does not draw the text itself, but requires most of the same inputs + /// as [`Self::text_with_colors`]. /// - /// Text is drawn like [`Self::text_with_position`] except that the subset - /// identified by `range` is highlighted using theme-defined colors. - pub fn text_with_selection( + /// The list of `decorations` may come from [`Text::decorations`] or be any + /// other compatible sequence. See also [`FormattableText::decorations`]. + pub fn decorate_text( &mut self, pos: Coord, rect: Rect, - text: &Text, - range: Range, + display: &TextDisplay, + palette: &[Rgba], + decorations: &[(u32, format::Decoration)], ) { - if range.is_empty() { - return self.text_with_position(pos, rect, text); + if cfg!(debug_assertions) { + let mut i = 0; + for (start, token) in decorations { + assert!(*start >= i); + i = *start; + token.color.validate(palette); + } } - let Ok(display) = text.display() else { - return; - }; - - self.h - .text_selected_range(&self.id, pos, rect, display, range); + if !decorations.is_empty() { + self.h + .decorate_text(&self.id, pos, rect, display, palette, decorations); + } } /// Draw an edit marker at the given `byte` index on this `text` @@ -545,31 +556,42 @@ pub trait ThemeDraw { /// Draw a selection highlight / frame fn selection(&mut self, rect: Rect, style: SelectionStyle); - /// Draw text with effects + /// Draw text with a list of color effects /// - /// *Font* effects (e.g. bold, italics, text size) must be baked into the - /// [`TextDisplay`] during preparation. In contrast, "display" `effects` - /// (e.g. color, underline) are applied only when drawing. + /// Text is clipped to `rect`, drawing from `pos`; use `pos = rect.pos` if + /// the text is not scrolled. /// - /// The `text` should be prepared before calling this method. - fn text_effects( + /// *Font* effects (e.g. bold, italics, text size) must be baked into the + /// [`TextDisplay`] during preparation. In contrast, display effects + /// (e.g. color, underline) are applied only when drawing from the provided + /// list of `tokens`. This list may be the result of [`Text::color_tokens`] + /// or any compatible sequence (including `&[]`). See also + /// [`FormattableText::color_tokens`]. + fn text( &mut self, id: &Id, pos: Coord, rect: Rect, text: &TextDisplay, - colors: &[Rgba], - effects: &[Effect], + palette: &[Rgba], + tokens: &[(u32, format::Colors)], ); - /// Method used to implement [`DrawCx::text_with_selection`] - fn text_selected_range( + /// Draw text decorations (e.g. underlines) + /// + /// This does not draw the text itself, but requires most of the same inputs + /// as [`Self::text`]. + /// + /// The list of `decorations` may come from [`Text::decorations`] or be any + /// other compatible sequence. See also [`FormattableText::decorations`]. + fn decorate_text( &mut self, id: &Id, pos: Coord, rect: Rect, text: &TextDisplay, - range: Range, + palette: &[Rgba], + decorations: &[(u32, format::Decoration)], ); /// Draw an edit marker at the given `byte` index on this `text` @@ -641,6 +663,6 @@ mod test { let _scale = draw.size_cx().scale_factor(); let text = crate::theme::Text::new("sample", TextClass::Label, false); - draw.text_with_selection(Coord::ZERO, Rect::ZERO, &text, 0..6) + draw.text(Rect::ZERO, &text) } } diff --git a/crates/kas-core/src/theme/flat_theme.rs b/crates/kas-core/src/theme/flat_theme.rs index a332b7c1e..b9c934ce5 100644 --- a/crates/kas-core/src/theme/flat_theme.rs +++ b/crates/kas-core/src/theme/flat_theme.rs @@ -7,7 +7,6 @@ use std::cell::RefCell; use std::f32; -use std::ops::Range; use std::time::Instant; use super::SimpleTheme; diff --git a/crates/kas-core/src/theme/simple_theme.rs b/crates/kas-core/src/theme/simple_theme.rs index 55ff1a7ed..c4aced3bb 100644 --- a/crates/kas-core/src/theme/simple_theme.rs +++ b/crates/kas-core/src/theme/simple_theme.rs @@ -7,9 +7,9 @@ use std::cell::RefCell; use std::f32; -use std::ops::Range; use std::time::Instant; +use super::ColorsSrgb; use crate::Id; use crate::cast::traits::*; use crate::config::{Config, WindowConfig}; @@ -17,14 +17,12 @@ use crate::dir::{Direction, Directional}; use crate::draw::{color::Rgba, *}; use crate::event::EventState; use crate::geom::*; -use crate::text::{Effect, TextDisplay}; +use crate::text::{TextDisplay, format}; use crate::theme::dimensions as dim; use crate::theme::{Background, FrameStyle, MarkStyle}; use crate::theme::{ColorsLinear, InputState, Theme}; use crate::theme::{SelectionStyle, ThemeDraw, ThemeSize}; -use super::ColorsSrgb; - /// A simple theme /// /// This theme is functional, but not pretty. It is intended as a template for @@ -348,75 +346,39 @@ impl<'a, DS: DrawSharedImpl> ThemeDraw for DrawHandle<'a, DS> { } } - fn text_effects( + fn text( &mut self, id: &Id, pos: Coord, rect: Rect, text: &TextDisplay, - colors: &[Rgba], - effects: &[Effect], + palette: &[Rgba], + tokens: &[(u32, format::Colors)], ) { + // NOTE: id is passed to allow usage of self.cols.text_disabled if self.ev.is_disabled(id). + // We could do this by passing the pair (text, text_invert) instead of self.cols. + let _ = id; + let bb = Quad::conv(rect); - let col; - let mut colors = colors; - if colors.is_empty() { - col = [if self.ev.is_disabled(id) { - self.cols.text_disabled - } else { - self.cols.text - }]; - colors = &col; - } self.draw - .text_effects(pos.cast(), bb, text, colors, effects); + .text(pos.cast(), bb, text, self.cols, palette, tokens); } - fn text_selected_range( + fn decorate_text( &mut self, id: &Id, pos: Coord, rect: Rect, text: &TextDisplay, - range: Range, + palette: &[Rgba], + decorations: &[(u32, format::Decoration)], ) { - let pos = pos.cast(); + // NOTE: see above note on usage of self.cols.text_disabled. + let _ = id; + let bb = Quad::conv(rect); - let col = if self.ev.is_disabled(id) { - self.cols.text_disabled - } else { - self.cols.text - }; - let sel_col = self.cols.text_over(self.cols.text_sel_bg); - - // Draw background: - text.highlight_range(range.clone(), &mut |p1, p2| { - let p1 = Vec2::from(p1); - let p2 = Vec2::from(p2); - if let Some(quad) = Quad::from_coords(pos + p1, pos + p2).intersection(&bb) { - self.draw.rect(quad, self.cols.text_sel_bg); - } - }); - - let effects = [ - Effect { - start: 0, - color: 0, - flags: Default::default(), - }, - Effect { - start: range.start.cast(), - color: 1, - flags: Default::default(), - }, - Effect { - start: range.end.cast(), - color: 0, - flags: Default::default(), - }, - ]; - let colors = [col, sel_col]; - self.draw.text_effects(pos, bb, text, &colors, &effects); + self.draw + .decorate_text(pos.cast(), bb, text, self.cols, palette, decorations); } fn text_cursor(&mut self, id: &Id, pos: Coord, rect: Rect, text: &TextDisplay, byte: usize) { diff --git a/crates/kas-core/src/theme/text.rs b/crates/kas-core/src/theme/text.rs index 59c470b8b..3bd569533 100644 --- a/crates/kas-core/src/theme/text.rs +++ b/crates/kas-core/src/theme/text.rs @@ -15,7 +15,7 @@ use crate::cast::Cast; use crate::geom::{Rect, Vec2}; use crate::layout::{AlignHints, AxisInfo, SizeRules, Stretch}; use crate::text::fonts::FontSelector; -use crate::text::format::FormattableText; +use crate::text::format::{Colors, Decoration, FormattableText}; use crate::text::*; use std::num::NonZeroUsize; @@ -386,17 +386,20 @@ impl Text { is_rtl } - /// Get the sequence of effect tokens + /// Return the sequence of color effect tokens /// - /// This method has some limitations: (1) it may only return a reference to - /// an existing sequence, (2) effect tokens cannot be generated dependent - /// on input state, and (3) it does not incorporate color information. For - /// most uses it should still be sufficient, but for other cases it may be - /// preferable not to use this method (use a dummy implementation returning - /// `&[]` and use inherent methods on the text object via [`Text::text`]). + /// This forwards to [`FormattableText::color_tokens`]. #[inline] - pub fn effect_tokens(&self) -> &[Effect] { - self.text.effect_tokens() + pub fn color_tokens(&self) -> &[(u32, Colors)] { + self.text.color_tokens() + } + + /// Return optional sequences of decoration tokens + /// + /// This forwards to [`FormattableText::decorations`]. + #[inline] + pub fn decorations(&self) -> &[(u32, Decoration)] { + self.text.decorations() } } @@ -448,9 +451,16 @@ impl Text { match self.status { Status::New => self .display - .prepare_runs(&self.text, self.direction, self.font, self.dpem) + .prepare_runs( + self.text.as_str(), + self.direction, + self.text.font_tokens(self.dpem, self.font), + ) .expect("no suitable font found"), - Status::ResizeLevelRuns => self.display.resize_runs(&self.text, self.font, self.dpem), + Status::ResizeLevelRuns => self.display.resize_runs( + self.text.as_str(), + self.text.font_tokens(self.dpem, self.font), + ), _ => return, } diff --git a/crates/kas-macros/src/extends.rs b/crates/kas-macros/src/extends.rs index 425b906a0..5f0918b3e 100644 --- a/crates/kas-macros/src/extends.rs +++ b/crates/kas-macros/src/extends.rs @@ -80,27 +80,28 @@ impl Methods { (#base).selection(rect, style); } - fn text_effects( + fn text( &mut self, id: &Id, pos: Coord, rect: Rect, text: &TextDisplay, - colors: &[Rgba], - effects: &[::kas::text::Effect], + palette: &[Rgba], + tokens: &[(u32, ::kas::text::format::Colors)], ) { - (#base).text_effects(id, pos, rect, text, colors, effects); + (#base).text(id, pos, rect, text, palette, tokens); } - fn text_selected_range( + fn decorate_text( &mut self, id: &Id, pos: Coord, rect: Rect, text: &TextDisplay, - range: Range, + palette: &[Rgba], + decorations: &[(u32, ::kas::text::format::Decoration)], ) { - (#base).text_selected_range(id, pos, rect, text, range); + (#base).decorate_text(id, pos, rect, text, palette, decorations); } fn text_cursor( diff --git a/crates/kas-soft/src/draw.rs b/crates/kas-soft/src/draw.rs index 416edd9af..dae34960a 100644 --- a/crates/kas-soft/src/draw.rs +++ b/crates/kas-soft/src/draw.rs @@ -14,6 +14,7 @@ use kas::draw::{ImageFormat, ImageId, color}; use kas::geom::{Quad, Size, Vec2}; use kas::prelude::{Offset, Rect}; use kas::text::{self}; +use kas::theme::ColorsLinear; #[derive(Debug)] struct ClipRegion { @@ -178,30 +179,51 @@ impl DrawSharedImpl for Shared { } } - fn draw_text_effects( + fn draw_text( &mut self, - draw: &mut Draw, + draw: &mut Self::Draw, pass: PassId, pos: Vec2, bb: Quad, text: &text::TextDisplay, - colors: &[color::Rgba], - effects: &[text::Effect], + theme: &ColorsLinear, + palette: &[color::Rgba], + tokens: &[(u32, text::format::Colors)], ) { let time = std::time::Instant::now(); - self.text.text_effects( + self.text.text( &mut self.images, &mut draw.images, pass, pos, bb, text, - colors, - effects, + theme, + palette, + tokens, |quad, col| { draw.basic.rect(pass, quad, col); }, ); draw.common.report_dur_text(time.elapsed()); } + + fn decorate_text( + &mut self, + draw: &mut Self::Draw, + pass: PassId, + pos: Vec2, + bb: Quad, + text: &text::TextDisplay, + theme: &ColorsLinear, + palette: &[color::Rgba], + decorations: &[(u32, text::format::Decoration)], + ) { + let time = std::time::Instant::now(); + self.text + .decorate_text(pos, bb, text, theme, palette, decorations, |quad, col| { + draw.basic.rect(pass, quad, col); + }); + draw.common.report_dur_text(time.elapsed()); + } } diff --git a/crates/kas-wgpu/src/draw/draw_pipe.rs b/crates/kas-wgpu/src/draw/draw_pipe.rs index 0b7b81228..3cb63eaee 100644 --- a/crates/kas-wgpu/src/draw/draw_pipe.rs +++ b/crates/kas-wgpu/src/draw/draw_pipe.rs @@ -6,6 +6,7 @@ //! Drawing API for `kas_wgpu` use futures_lite::future::block_on; +use kas::theme::ColorsLinear; use std::f32::consts::FRAC_PI_2; use wgpu::util::DeviceExt; @@ -18,7 +19,7 @@ use kas::draw::color::Rgba; use kas::draw::*; use kas::geom::{Quad, Size, Vec2}; use kas::runner::{GraphicsFeatures, RunError}; -use kas::text::{Effect, TextDisplay}; +use kas::text; impl DrawPipe { /// Construct @@ -356,32 +357,53 @@ impl DrawSharedImpl for DrawPipe { }; } - fn draw_text_effects( + fn draw_text( &mut self, draw: &mut Self::Draw, pass: PassId, pos: Vec2, bb: Quad, - text: &TextDisplay, - colors: &[Rgba], - effects: &[Effect], + text: &text::TextDisplay, + theme: &ColorsLinear, + palette: &[color::Rgba], + tokens: &[(u32, text::format::Colors)], ) { let time = std::time::Instant::now(); - self.text.text_effects( + self.text.text( &mut self.images, &mut draw.images, pass, pos, bb, text, - colors, - effects, + theme, + palette, + tokens, |quad, col| { draw.shaded_square.rect(pass, quad, col); }, ); draw.common.report_dur_text(time.elapsed()); } + + fn decorate_text( + &mut self, + draw: &mut Self::Draw, + pass: PassId, + pos: Vec2, + bb: Quad, + text: &text::TextDisplay, + theme: &ColorsLinear, + palette: &[color::Rgba], + decorations: &[(u32, text::format::Decoration)], + ) { + let time = std::time::Instant::now(); + self.text + .decorate_text(pos, bb, text, theme, palette, decorations, |quad, col| { + draw.shaded_square.rect(pass, quad, col); + }); + draw.common.report_dur_text(time.elapsed()); + } } impl DrawImpl for DrawWindow { diff --git a/crates/kas-wgpu/src/shaded_theme.rs b/crates/kas-wgpu/src/shaded_theme.rs index b93618ad6..6a6e6fb08 100644 --- a/crates/kas-wgpu/src/shaded_theme.rs +++ b/crates/kas-wgpu/src/shaded_theme.rs @@ -7,7 +7,6 @@ use std::cell::RefCell; use std::f32; -use std::ops::Range; use std::time::Instant; use crate::{DrawShaded, DrawShadedImpl}; diff --git a/crates/kas-widgets/src/access_label.rs b/crates/kas-widgets/src/access_label.rs index 18f216b64..f880609f4 100644 --- a/crates/kas-widgets/src/access_label.rs +++ b/crates/kas-widgets/src/access_label.rs @@ -130,15 +130,14 @@ mod AccessLabel { fn draw(&self, mut draw: DrawCx) { let rect = self.text.rect(); - if let Some((key, effects)) = self.text.text().key() + draw.text(rect, &self.text); + if let Some((key, decoration)) = self.text.text().key() && draw.access_key(&self.target, key) { // Stop on first successful binding and draw if let Ok(display) = self.text.display() { - draw.text_with_effects(rect.pos, rect, display, &[], effects); + draw.decorate_text(rect.pos, rect, display, &[], decoration); } - } else { - draw.text(rect, &self.text); } } } diff --git a/crates/kas-widgets/src/edit/editor.rs b/crates/kas-widgets/src/edit/editor.rs index 1d5089bf8..3b6cfd5dc 100644 --- a/crates/kas-widgets/src/edit/editor.rs +++ b/crates/kas-widgets/src/edit/editor.rs @@ -10,7 +10,7 @@ use kas::event::components::{TextInput, TextInputAction}; use kas::event::{ElementState, FocusSource, Ime, ImePurpose, ImeSurroundingText, Scroll}; use kas::geom::Vec2; use kas::prelude::*; -use kas::text::{CursorRange, Effect, EffectFlags, NotReady, SelectionHelper}; +use kas::text::{CursorRange, NotReady, SelectionHelper, format}; use kas::theme::{Text, TextClass}; use kas::util::UndoStack; use std::borrow::Cow; @@ -161,32 +161,36 @@ impl Component { /// Implementation of [`Viewport::draw_with_offset`] pub fn draw_with_offset(&self, mut draw: DrawCx, rect: Rect, offset: Offset) { + let Ok(display) = self.text.display() else { + return; + }; + let pos = self.rect().pos - offset; + let range: Range = self.selection.range().cast(); + + let mut tokens = [(0, format::Colors::default()); 3]; + let tokens = if range.is_empty() { + &[] + } else { + tokens[1].0 = range.start; + tokens[1].1.background = Some(format::Color::default()); + tokens[2].0 = range.end; + let r0 = if range.start > 0 { 0 } else { 1 }; + &tokens[r0..] + }; + draw.text_with_colors(pos, rect, display, &[], tokens); if let CurrentAction::ImePreedit { edit_range } = self.current.clone() { - // TODO: combine underline with selection highlight - let effects = [ - Effect { - start: 0, - color: 0, - flags: Default::default(), - }, - Effect { - start: edit_range.start, - color: 0, - flags: EffectFlags::UNDERLINE, - }, - Effect { - start: edit_range.end, - color: 0, - flags: Default::default(), - }, + let tokens = [ + Default::default(), + (edit_range.start, format::Decoration { + dec: format::DecorationType::Underline, + ..Default::default() + }), + (edit_range.end, Default::default()), ]; - if let Ok(display) = self.text.display() { - draw.text_with_effects(pos, rect, display, &[], &effects); - } - } else { - draw.text_with_selection(pos, rect, &self.text, self.selection.range()); + let r0 = if edit_range.start > 0 { 0 } else { 1 }; + draw.decorate_text(pos, rect, display, &[], &tokens[r0..]); } if self.editable && draw.ev_state().has_input_focus(self.id_ref()) == Some(true) { diff --git a/crates/kas-widgets/src/scroll_label.rs b/crates/kas-widgets/src/scroll_label.rs index 27fb014ea..ed52501fc 100644 --- a/crates/kas-widgets/src/scroll_label.rs +++ b/crates/kas-widgets/src/scroll_label.rs @@ -10,7 +10,7 @@ use kas::event::components::{ScrollComponent, TextInput, TextInputAction}; use kas::event::{CursorIcon, FocusSource, Scroll}; use kas::prelude::*; use kas::text::SelectionHelper; -use kas::text::format::FormattableText; +use kas::text::format::{self, FormattableText}; use kas::theme::{Text, TextClass}; #[impl_self] @@ -66,13 +66,26 @@ mod SelectableText { } fn draw_with_offset(&self, mut draw: DrawCx, rect: Rect, offset: Offset) { + let Ok(display) = self.text.display() else { + return; + }; + let pos = self.rect().pos - offset; + let range: std::ops::Range = self.selection.range().cast(); - if self.selection.is_empty() { - draw.text_with_position(pos, rect, &self.text); + let mut tokens = [(0, format::Colors::default()); 3]; + let tokens = if range.is_empty() { + &[] } else { - draw.text_with_selection(pos, rect, &self.text, self.selection.range()); - } + tokens[1].0 = range.start; + tokens[1].1.background = Some(format::Color::default()); + tokens[2].0 = range.end; + let r0 = if range.start > 0 { 0 } else { 1 }; + &tokens[r0..] + }; + draw.text_with_colors(pos, rect, display, &[], tokens); + + draw.decorate_text(pos, rect, display, &[], self.text.decorations()); } } diff --git a/examples/gallery.rs b/examples/gallery.rs index bb154ef59..e5f77aefe 100644 --- a/examples/gallery.rs +++ b/examples/gallery.rs @@ -305,7 +305,7 @@ fn editor() -> Page { Demonstration of *as-you-type* formatting from **Markdown**. 1. Edit below -2. View the result +2. View the ~~result~~ _result_ 3. In case of error, be informed ### Not all Markdown supported