From 0af4c88d0895a9b6fdea5019498b0a36d1fbb0c2 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 5 Sep 2026 07:54:28 -0400 Subject: [PATCH] iris: SpanStyle, per-range text styling (RUST.md's I5) A TextBuffer used to have exactly one style for its whole string, applied via parley's push_default. SpanStyle adds a second, optional layer -- a byte range plus whichever of colour/family/font size/ bold/italic/underline it overrides, pushed with parley's own push(property, range) -- so a heading, bold, inline code and a link can each carry their own look inside one wrapped, selectable TextEdit. This is the actual answer to RUST.md's E2 finding against Masonry (TextArea::edit_styles() is one StyleSet for the whole editor). PlacedGlyph gains a color field, read from parley's own per-run Style::brush, and Painter::glyphs draws each glyph in its own colour instead of one colour for the whole RenderedText. Real bug found while wiring this into a live screen (not caught by any test, since markdown's own tests only check string/range logic): spans were threaded through TextOutput::run but not the sibling TextEditOutput::run, so every editable field silently dropped them. Fixed in build.rs; see IRIS.md's entry for why both call sites are a pair to keep in sync. cargo fmt/build/clippy/test --workspace and cargo ndk (iris, iris-android excluded per its own workspace exclusion) all clean; 28 existing iris tests unaffected. Co-Authored-By: Claude Sonnet --- iris/core/src/primitive/text.rs | 119 ++++++++++++++++++++++++++++++-- iris/core/src/render/atlas.rs | 10 ++- iris/core/src/ui/painter.rs | 2 +- iris/src/widget/text/build.rs | 18 ++++- 4 files changed, 139 insertions(+), 10 deletions(-) diff --git a/iris/core/src/primitive/text.rs b/iris/core/src/primitive/text.rs index cb14e5b..ae65f02 100644 --- a/iris/core/src/primitive/text.rs +++ b/iris/core/src/primitive/text.rs @@ -1,8 +1,9 @@ use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiColor, util::Vec2}; use parley::{ - Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, GenericFamily, Layout, - LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty, + Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight, + GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty, }; +use std::ops::Range; use swash::{ FontRef, scale::{Render, ScaleContext, Source, StrikeWith}, @@ -51,6 +52,72 @@ impl Family { } } +/// One styled run inside a `TextBuffer`, overriding `TextAttrs`' base style +/// over `range` (a byte range into the buffer's text). Every field is +/// optional so a span only says what it changes -- e.g. a link span sets +/// `color` and `underline` and leaves weight/family at the paragraph's own +/// default. This is I5's answer to RUST.md's inline-rich-text ceiling +/// (`masonry/src/widgets/text_area.rs`'s `StyleSet` is one style for the +/// whole editor, with `// TODO: RichTextInput` beside it): parley's own +/// `RangedBuilder::push` already takes a style and a range, so per-span +/// bold/italic/monospace/colour/underline only needed plumbing this struct +/// through to it and giving each glyph its own colour at draw time (see +/// `PlacedGlyph::color` and `TextData::place` below) instead of the one +/// `RenderedText::color` every glyph used to share. +#[derive(Clone, PartialEq)] +pub struct SpanStyle { + pub range: Range, + pub color: Option, + pub family: Option, + /// Overrides `TextAttrs::font_size` for just this range -- what lets a + /// heading inside a transcript row's single `TextEdit` be bigger than + /// the paragraph text around it, so a whole markdown-folded row (block + /// and inline styling both) can stay one selectable text buffer instead + /// of one widget per block. + pub font_size: Option, + pub bold: bool, + pub italic: bool, + pub underline: bool, +} + +impl SpanStyle { + pub fn new(range: Range) -> Self { + Self { + range, + color: None, + family: None, + font_size: None, + bold: false, + italic: false, + underline: false, + } + } + pub fn color(mut self, color: UiColor) -> Self { + self.color = Some(color); + self + } + pub fn family(mut self, family: Family) -> Self { + self.family = Some(family); + self + } + pub fn font_size(mut self, size: f32) -> Self { + self.font_size = Some(size); + self + } + pub fn bold(mut self) -> Self { + self.bold = true; + self + } + pub fn italic(mut self) -> Self { + self.italic = true; + self + } + pub fn underline(mut self) -> Self { + self.underline = true; + self + } +} + #[derive(Clone, PartialEq)] pub struct TextAttrs { pub color: UiColor, @@ -86,8 +153,12 @@ impl Default for TextAttrs { pub struct TextBuffer { text: String, layout: Layout, + spans: Vec, /// What the current layout was built for, so `shape` can decline to redo - /// work that would come out the same. + /// work that would come out the same. Spans are not part of this key -- + /// `set_spans` forces `shaped` to `None` directly, the same way `edit` + /// does, since spans change far less often than a naive equality check + /// on the whole `Vec` would cost to compute every frame. shaped: Option<(TextAttrs, Option)>, } @@ -96,10 +167,19 @@ impl TextBuffer { Self { text: text.into(), layout: Layout::new(), + spans: Vec::new(), shaped: None, } } + /// Replace this buffer's per-range style overrides (I5's rich text -- + /// see `SpanStyle`). Invalidates the layout unconditionally, mirroring + /// `set_text`. + pub fn set_spans(&mut self, spans: Vec) { + self.spans = spans; + self.shaped = None; + } + pub fn new_empty() -> Self { Self::new("") } @@ -150,6 +230,27 @@ impl TextBuffer { attrs.line_height, ))); builder.push_default(StyleProperty::Brush(attrs.color)); + for span in &self.spans { + let range = span.range.clone(); + if let Some(color) = span.color { + builder.push(StyleProperty::Brush(color), range.clone()); + } + if let Some(family) = &span.family { + builder.push(StyleProperty::FontFamily(family.family()), range.clone()); + } + if let Some(size) = span.font_size { + builder.push(StyleProperty::FontSize(size), range.clone()); + } + if span.bold { + builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone()); + } + if span.italic { + builder.push(StyleProperty::FontStyle(FontStyle::Italic), range.clone()); + } + if span.underline { + builder.push(StyleProperty::Underline(true), range.clone()); + } + } builder.build_into(&mut self.layout, &self.text); self.layout.break_all_lines(width); self.layout @@ -175,6 +276,7 @@ impl TextData { let font = run.run().font(); let font_size = run.run().font_size(); let coords = run.run().normalized_coords(); + let run_color = run.style().brush; let Some(font_ref) = FontRef::from_index(font.data.as_ref(), font.index as usize) else { continue; @@ -227,6 +329,7 @@ impl TextData { glyph.x.floor() + entry.left as f32, glyph.y.floor() - entry.top as f32, ), + color: run_color, }); } } @@ -245,11 +348,15 @@ fn hash_coords(coords: &[i16]) -> u64 { h } -/// A laid-out string, ready to draw: where each glyph goes, how big the whole -/// thing is, and what colour to tint the atlas with. +/// A laid-out string, ready to draw: where each glyph goes and how big the +/// whole thing is. /// /// Cheap to clone and to keep, which is the point -- a widget holds one across -/// frames and re-emits its quads without going near the rasteriser. +/// frames and re-emits its quads without going near the rasteriser. `color` +/// is the buffer's *base* colour (`TextAttrs::color`) for a caller that wants +/// it as a whole (e.g. tinting a cursor to match); the colour each glyph is +/// actually drawn in is `PlacedGlyph::color`, which a `SpanStyle` can +/// override per range. #[derive(Clone)] pub struct RenderedText { pub glyphs: std::sync::Arc>, diff --git a/iris/core/src/render/atlas.rs b/iris/core/src/render/atlas.rs index ec29a19..027b407 100644 --- a/iris/core/src/render/atlas.rs +++ b/iris/core/src/render/atlas.rs @@ -10,7 +10,7 @@ //! it, and a resize re-emits quads without touching the GPU's copy at all. use crate::{ - PatchRect, TextureHandle, Textures, + PatchRect, TextureHandle, Textures, UiColor, util::{HashMap, Vec2}, }; use image::RgbaImage; @@ -228,8 +228,16 @@ fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) { } /// Where a glyph goes on screen, in pixels relative to the text's origin. +/// +/// `color` is per-glyph (read from the parley run's own `Brush`, since +/// `UiColor` is parley's brush type here) rather than a single colour for +/// the whole `RenderedText`, so that a span pushed with its own +/// `StyleProperty::Brush` (I5's inline rich text: a link, a diff of colour +/// inside one wrapped paragraph) actually renders in that colour instead of +/// the buffer's base one. #[derive(Clone, Copy)] pub struct PlacedGlyph { pub entry: GlyphEntry, pub offset: Vec2, + pub color: UiColor, } diff --git a/iris/core/src/ui/painter.rs b/iris/core/src/ui/painter.rs index 8985f01..801f279 100644 --- a/iris/core/src/ui/painter.rs +++ b/iris/core/src/ui/painter.rs @@ -194,7 +194,7 @@ impl<'a> Painter<'a> { glyph.entry.uv_min, glyph.entry.uv_max, glyph.entry.layer, - text.color, + glyph.color, flags_for(glyph.entry.is_color), ), region, diff --git a/iris/src/widget/text/build.rs b/iris/src/widget/text/build.rs index 1619bfe..7b4c696 100644 --- a/iris/src/widget/text/build.rs +++ b/iris/src/widget/text/build.rs @@ -4,6 +4,7 @@ use std::marker::{PhantomData, Sized}; pub struct TextBuilder = ()> { pub content: String, pub attrs: TextAttrs, + pub spans: Vec, pub hint: H, pub output: O, state: PhantomData, @@ -39,10 +40,19 @@ impl> TextBuilder { self.attrs.wrap = wrap; self } + /// Per-range style overrides -- I5's inline rich text (bold, italic, + /// inline-code monospace, link colour/underline) within one wrapped + /// paragraph. See `SpanStyle`'s doc for why this exists and what it + /// replaces. + pub fn spans(mut self, spans: Vec) -> Self { + self.spans = spans; + self + } pub fn editable(self, mode: EditMode) -> TextBuilder { TextBuilder { content: self.content, attrs: self.attrs, + spans: self.spans, hint: self.hint, output: TextEditOutput { mode }, state: PhantomData, @@ -58,6 +68,7 @@ impl TextBuilder { TextBuilder { content: self.content, attrs: self.attrs, + spans: self.spans, hint: move |rsc: &mut Rsc| Some(hint.add_strong(rsc).any()), output: self.output, state: PhantomData, @@ -81,7 +92,8 @@ impl TextBuilderOutput for TextOutput { state: &mut Rsc, builder: TextBuilder, ) -> Self::Output { - let buf = TextBuffer::new(&builder.content); + let mut buf = TextBuffer::new(&builder.content); + buf.set_spans(builder.spans); let hint = builder.hint.get(state); let mut text = Text { content: builder.content.into(), @@ -103,7 +115,8 @@ impl TextBuilderOutput for TextEditOutput { state: &mut State, builder: TextBuilder, ) -> Self::Output { - let buf = TextBuffer::new(&builder.content); + let mut buf = TextBuffer::new(&builder.content); + buf.set_spans(builder.spans); TextEdit::new( TextView::new(buf, builder.attrs, builder.hint.get(state)), builder.output.mode, @@ -125,6 +138,7 @@ pub fn wtext(content: impl Into) -> TextBuilder { TextBuilder { content: content.into(), attrs: TextAttrs::default(), + spans: Vec::new(), hint: (), output: TextOutput, state: PhantomData,