Skip to main content

rosace_widgets/tree/
text.rs

1use rosace_core::types::{Point, Rect, Size};
2use rosace_render::Color;
3use rosace_text::{RichText, TextLayout};
4use super::{Widget, LayoutCtx, PaintCtx};
5
6/// `rosace-text`'s `TextSpan`/`RichText` use `rosace_theme::Color` (0..1
7/// float channels — the theme-token color type); painting APIs here want
8/// `rosace_render::Color` (0-255 u8 channels) — same two-type split every
9/// other widget already converts via `PaintCtx::tc`, just without a
10/// `ThemeData` available at this conversion site (a plain color value, not
11/// a token lookup), so a direct field-by-field convert instead.
12fn theme_to_render_color(c: rosace_theme::Color) -> Color {
13    Color::rgba(
14        (c.r * 255.0).round() as u8,
15        (c.g * 255.0).round() as u8,
16        (c.b * 255.0).round() as u8,
17        (c.a * 255.0).round() as u8,
18    )
19}
20
21#[derive(Debug, Clone, Copy, Default)]
22pub enum TextAlign {
23    #[default]
24    Left,
25    Center,
26    Right,
27}
28
29pub use rosace_render::FontWeight;
30
31/// A plain text leaf widget.
32///
33/// Color defaults to the theme's `on_surface` — no explicit color needed.
34pub struct Text {
35    pub text: String,
36    /// `None` = use `theme.colors.on_surface`. `Some(c)` = explicit override.
37    pub color: Option<Color>,
38    pub size: f32,
39    pub align: TextAlign,
40    pub weight: FontWeight,
41    pub max_lines: Option<usize>,
42    /// Mixed-style spans (Phase 32 Step 3, D115) — when set, this widget
43    /// renders THESE styled runs instead of `text`/`color`/`size`/`weight`
44    /// (which stay at their defaults and are ignored). Real integration
45    /// with `rosace-text`'s existing `RichText`/`TextSpan`/`TextLayout` —
46    /// this widget does the wrapping via `TextLayout::layout_with_measure`
47    /// (real font metrics, not the crate's own heuristic fallback) and
48    /// paints each span with `PaintCtx`'s ordinary text primitives; no
49    /// rewrite of those types.
50    spans: Option<RichText>,
51}
52
53impl Text {
54    pub fn new(text: impl Into<String>) -> Self {
55        Self {
56            text: text.into(),
57            color: None,
58            size: 18.0,
59            align: TextAlign::Left,
60            weight: FontWeight::Regular,
61            max_lines: None,
62            spans: None,
63        }
64    }
65
66    /// A paragraph of mixed-style spans (bold/italic/color/underline changes
67    /// mid-paragraph) — see `rosace_text::RichText`'s builder
68    /// (`.text(...)`/`.bold(...)`/`.push(...)`). Wrapping/alignment/color
69    /// still apply; `.size()`/`.weight()`/`.color()` on this widget do NOT
70    /// (each span carries its own).
71    pub fn rich(spans: RichText) -> Self {
72        Self { spans: Some(spans), ..Self::new(String::new()) }
73    }
74
75    pub fn color(mut self, c: Color) -> Self { self.color = Some(c); self }
76    pub fn size(mut self, s: f32) -> Self { self.size = s; self }
77    pub fn align(mut self, a: TextAlign) -> Self { self.align = a; self }
78    pub fn weight(mut self, w: FontWeight) -> Self { self.weight = w; self }
79    pub fn max_lines(mut self, n: usize) -> Self { self.max_lines = Some(n); self }
80}
81
82impl Text {
83    /// Break the text into lines that fit `max_w` pixels.
84    ///
85    /// Explicit `\n` breaks are honored first, then each paragraph is
86    /// greedily word-wrapped via [`rosace_text::word_wrap`] with real font
87    /// metrics. `max_lines` truncates the result. A text that fits on one
88    /// line (the common case) skips the per-word measuring entirely.
89    fn wrap_lines(&self, font: &rosace_render::FontCache, max_w: f32) -> Vec<String> {
90        let single_paragraph = !self.text.contains('\n');
91        if single_paragraph
92            && (!max_w.is_finite() || font.measure_text_weighted(&self.text, self.size, self.weight) <= max_w)
93        {
94            return vec![self.text.clone()];
95        }
96
97        let mut lines = Vec::new();
98        for paragraph in self.text.split('\n') {
99            if paragraph.is_empty() {
100                lines.push(String::new());
101            } else {
102                lines.extend(rosace_text::word_wrap(paragraph, max_w, |s| {
103                    font.measure_text_weighted(s, self.size, self.weight)
104                }));
105            }
106        }
107        if let Some(n) = self.max_lines {
108            lines.truncate(n);
109        }
110        lines
111    }
112}
113
114impl Widget for Text {
115    fn layout(&self, ctx: &LayoutCtx) -> Size {
116        if let Some(rt) = &self.spans {
117            let max_w = ctx.constraints.max_width_f32();
118            let layout = rich_layout(rt, ctx.font, max_w);
119            let text_w = layout.lines.iter().map(|l| l.width).fold(0.0_f32, f32::max);
120            return ctx.constraints.constrain(Size { width: text_w, height: layout.total_height() });
121        }
122        let line_h = ctx.font.line_height(self.size);
123        let lines = self.wrap_lines(ctx.font, ctx.constraints.max_width_f32());
124        let text_w = lines
125            .iter()
126            .map(|l| ctx.font.measure_text_weighted(l, self.size, self.weight))
127            .fold(0.0_f32, f32::max);
128        let text_h = line_h * lines.len().max(1) as f32;
129        ctx.constraints.constrain(Size { width: text_w, height: text_h })
130    }
131
132    fn paint(&self, ctx: &mut PaintCtx) {
133        if let Some(rt) = &self.spans {
134            paint_rich(rt, ctx, self.align);
135            return;
136        }
137        if self.text.is_empty() { return; }
138        ctx.semantics(super::Semantics::new(rosace_core::Role::Text).label(&self.text));
139
140        // Fall back to theme on_surface when no explicit color is set.
141        let color = self.color.unwrap_or_else(|| ctx.tc(ctx.theme.colors.on_surface));
142
143        let line_h = ctx.font.line_height(self.size);
144        let mut lines = self.wrap_lines(ctx.font, ctx.rect.size.width);
145
146        // Honor the allocated rect: paint only the lines that fit. layout()
147        // reports the wrapped height, but a parent may allot less (constrained
148        // container) — painting past the rect would bleed into siblings.
149        let fit = ((ctx.rect.size.height / line_h).floor() as usize).max(1);
150        lines.truncate(fit);
151
152        let total_h = line_h * lines.len() as f32;
153        let y_base = ((ctx.rect.size.height - total_h) / 2.0).max(0.0);
154
155        for (i, line) in lines.iter().enumerate() {
156            if line.is_empty() { continue; }
157            let line_w = ctx.font.measure_text_weighted(line, self.size, self.weight);
158            let x_off = match self.align {
159                TextAlign::Left   => 0.0,
160                TextAlign::Center => ((ctx.rect.size.width - line_w) / 2.0).max(0.0),
161                TextAlign::Right  => (ctx.rect.size.width - line_w).max(0.0),
162            };
163            ctx.text_styled(line, x_off, y_base + i as f32 * line_h, color, self.size, self.weight);
164        }
165    }
166}
167
168/// `TextLayout::layout_with_measure`'s measure closure is `Fn(&str, f32)`
169/// (text + size only) — it has no slot for per-span weight, so wrapping
170/// decisions measure every span as Regular. A long run of Bold text can
171/// therefore wrap very slightly early (Bold is wider). Documented
172/// approximation, not silently accepted: extending the closure's signature
173/// would ripple into every other `layout_with_measure` caller/test in
174/// `rosace-text`, which is exactly the "rewrite those types" this
175/// integration is deliberately avoiding. Actual PAINTED weight (below) is
176/// always correct — only the wrap point is approximated.
177fn rich_layout(rt: &RichText, font: &rosace_render::FontCache, max_w: f32) -> TextLayout {
178    TextLayout::layout_with_measure(&rt.spans, max_w, |s, size| {
179        font.measure_text_weighted(s, size, FontWeight::Regular)
180    })
181}
182
183fn paint_rich(rt: &RichText, ctx: &mut PaintCtx, align: TextAlign) {
184    if rt.is_empty() { return; }
185    ctx.semantics(super::Semantics::new(rosace_core::Role::Text).label(rt.plain_text()));
186
187    let layout = rich_layout(rt, ctx.font, ctx.rect.size.width);
188    let mut cy = 0.0_f32;
189    for line in &layout.lines {
190        let mut cx = match align {
191            TextAlign::Left   => 0.0,
192            TextAlign::Center => ((ctx.rect.size.width - line.width) / 2.0).max(0.0),
193            TextAlign::Right  => (ctx.rect.size.width - line.width).max(0.0),
194        };
195        for span in &line.spans {
196            // Italic isn't rendered yet — a real italic face/synthetic-oblique
197            // path doesn't exist in FontCache today (named, separate deferral
198            // in PHASE_32.md: "italic axis not started"). Bold and color and
199            // underline all apply for real.
200            let weight = if span.style.bold { FontWeight::Bold } else { FontWeight::Regular };
201            let color = theme_to_render_color(span.style.color);
202            let w = ctx.font.measure_text_weighted(&span.text, span.style.font_size, weight);
203            ctx.text_styled(&span.text, cx, cy, color, span.style.font_size, weight);
204            if span.style.underline {
205                let underline_y = cy + ctx.font.line_height(span.style.font_size) * 0.92;
206                ctx.fill_rect(Rect {
207                    origin: Point { x: ctx.rect.origin.x + cx, y: ctx.rect.origin.y + underline_y },
208                    size: Size { width: w, height: 1.0 },
209                }, color);
210            }
211            cx += w;
212        }
213        cy += line.height * layout.line_spacing;
214    }
215}
216
217// ── Named text styles (all use theme colors unless overridden) ────────────────
218
219impl Text {
220    pub fn label(text: impl Into<String>) -> Self {
221        Self::new(text).size(16.0)
222    }
223
224    pub fn caption(text: impl Into<String>) -> Self {
225        Self::new(text).size(14.0)
226    }
227
228    pub fn heading(text: impl Into<String>) -> Self {
229        Self::new(text).size(22.0).weight(FontWeight::SemiBold)
230    }
231
232    pub fn title(text: impl Into<String>) -> Self {
233        Self::new(text).size(20.0).weight(FontWeight::Medium)
234    }
235
236    pub fn display(text: impl Into<String>) -> Self {
237        Self::new(text).size(40.0).weight(FontWeight::Bold)
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use rosace_layout::Constraints;
245    use rosace_theme::Color as ThemeColor;
246
247    fn ctx_font() -> rosace_render::FontCache { rosace_render::FontCache::embedded() }
248    fn ctx_theme() -> rosace_theme::ThemeData { rosace_theme::built_in::dark_theme() }
249
250    #[test]
251    fn rich_text_renders_a_real_paragraph_with_mixed_styles() {
252        // The concrete exit bar from PHASE_32.md Step 3: a real running app
253        // renders a paragraph with at least two different inline styles in
254        // a single Text widget.
255        let font = ctx_font();
256        let theme = ctx_theme();
257        let rt = RichText::new()
258            .text("Plain ", 16.0, ThemeColor::WHITE)
259            .bold("bold", 16.0, ThemeColor::WHITE)
260            .text(" and ", 16.0, ThemeColor::WHITE)
261            .push("colored", rosace_text::TextStyle::new(16.0, ThemeColor { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }));
262        let text = Text::rich(rt);
263
264        let lctx = LayoutCtx::new(Constraints::loose(400.0, 200.0), &font, &theme);
265        let size = text.layout(&lctx);
266        assert!(size.width > 0.0 && size.height > 0.0);
267
268        // paint() must not panic and must not fall into the plain-text
269        // early-return (spans mode ignores the empty `self.text`).
270        let mut recorder = rosace_render::PictureRecorder::new();
271        let tree = std::rc::Rc::new(std::cell::RefCell::new(super::super::render_tree::RenderTree::new()));
272        let mut pctx = PaintCtx::root(&mut recorder, Rect { origin: Point { x: 0.0, y: 0.0 }, size }, &font, theme, tree);
273        text.paint(&mut pctx);
274        let picture = recorder.finish();
275        assert!(!picture.commands.is_empty(), "rich text must actually record draw commands");
276    }
277
278    #[test]
279    fn rich_text_wraps_across_multiple_lines_when_narrow() {
280        let font = ctx_font();
281        let theme = ctx_theme();
282        let rt = RichText::new().text("one two three four five six seven", 16.0, ThemeColor::WHITE);
283        let text = Text::rich(rt);
284        let lctx = LayoutCtx::new(Constraints::loose(80.0, 400.0), &font, &theme);
285        let size = text.layout(&lctx);
286        // At 80px wide, a 7-word sentence at 16px must wrap to more than one line.
287        let line_h = font.line_height(16.0);
288        assert!(size.height > line_h, "expected wrapping, got single-line height {}", size.height);
289    }
290
291    #[test]
292    fn plain_text_path_is_unaffected_by_spans_field_existing() {
293        let font = ctx_font();
294        let theme = ctx_theme();
295        let text = Text::new("hello world");
296        let lctx = LayoutCtx::new(Constraints::loose(400.0, 200.0), &font, &theme);
297        let size = text.layout(&lctx);
298        assert!(size.width > 0.0);
299    }
300}