1use rosace_core::types::{Point, Rect, Size};
2use rosace_render::Color;
3use rosace_text::{RichText, TextLayout};
4use super::{Widget, LayoutCtx, PaintCtx};
5
6fn 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
31pub struct Text {
35 pub text: String,
36 pub color: Option<Color>,
38 pub size: f32,
39 pub align: TextAlign,
40 pub weight: FontWeight,
41 pub max_lines: Option<usize>,
42 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 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 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 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 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
168fn 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 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
217impl 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 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 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 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}