1use std::ops::Range;
4
5use crate::geometry::{Rect, Size, clamp_u16};
6use crate::style::CellStyle;
7use crate::text;
8use crate::widget::{Align, MeasureCx, PaintCx, Widget};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Span {
13 text: String,
14 role: Option<String>,
15 color: Option<String>,
16 background: Option<String>,
17 bold: bool,
18}
19
20impl Span {
21 #[must_use]
23 pub fn new(text: impl Into<String>) -> Self {
24 Self { text: text.into(), role: None, color: None, background: None, bold: false }
25 }
26
27 #[must_use]
29 pub fn role(mut self, role: impl Into<String>) -> Self {
30 self.role = Some(role.into());
31 self
32 }
33
34 #[must_use]
36 pub fn color(mut self, token: impl Into<String>) -> Self {
37 self.color = Some(token.into());
38 self
39 }
40
41 #[must_use]
44 pub fn on(mut self, token: impl Into<String>) -> Self {
45 self.background = Some(token.into());
46 self
47 }
48
49 #[must_use]
51 pub fn bold(mut self) -> Self {
52 self.bold = true;
53 self
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Text {
67 spans: Vec<Span>,
68 role: String,
69 wrap: bool,
70 align: Align,
71}
72
73impl Text {
74 #[must_use]
76 pub fn new(text: impl Into<String>) -> Self {
77 Self::rich([Span::new(text)])
78 }
79
80 #[must_use]
82 pub fn rich(spans: impl IntoIterator<Item = Span>) -> Self {
83 Self { spans: spans.into_iter().collect(), role: "body".to_owned(), wrap: true, align: Align::Start }
84 }
85
86 #[must_use]
90 pub fn role(mut self, role: impl Into<String>) -> Self {
91 self.role = role.into();
92 self
93 }
94
95 #[must_use]
97 pub fn color(mut self, token: impl Into<String>) -> Self {
98 let token = token.into();
99 for span in &mut self.spans {
100 span.color.get_or_insert_with(|| token.clone());
101 }
102 self
103 }
104
105 #[must_use]
107 pub fn bold(mut self) -> Self {
108 for span in &mut self.spans {
109 span.bold = true;
110 }
111 self
112 }
113
114 #[must_use]
116 pub fn no_wrap(mut self) -> Self {
117 self.wrap = false;
118 self
119 }
120
121 #[must_use]
123 pub fn align(mut self, align: Align) -> Self {
124 self.align = align;
125 self
126 }
127
128 fn joined(&self) -> (String, Vec<Range<usize>>) {
129 let mut joined = String::new();
130 let mut ranges = Vec::new();
131 for span in &self.spans {
132 let start = joined.len();
133 joined.push_str(&span.text);
134 ranges.push(start..joined.len());
135 }
136 (joined, ranges)
137 }
138
139 fn lines(&self, joined: &str, max: u16) -> Vec<Range<usize>> {
140 if self.wrap {
141 return text::wrap_ranges(joined, max);
142 }
143 let mut lines = Vec::new();
144 let mut start = 0;
145 for line in joined.split('\n') {
146 lines.push(start..start + line.len());
147 start += line.len() + 1;
148 }
149 lines
150 }
151}
152
153impl<Msg: 'static> Widget<Msg> for Text {
154 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
155 let (joined, _) = self.joined();
156 let lines = self.lines(&joined, available.width);
157 let width = lines.iter().map(|line| text::width(&joined[line.clone()])).max().unwrap_or(0);
158 Size::new(
159 width.min(available.width),
160 clamp_u16(i32::try_from(lines.len()).unwrap_or(i32::MAX)).min(available.height),
161 )
162 }
163
164 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
165 if area.width == 0 || area.height == 0 {
166 return;
167 }
168 let (joined, ranges) = self.joined();
169 let base = typography(cx, &self.role);
170 let styles: Vec<CellStyle> = self
171 .spans
172 .iter()
173 .map(|span| {
174 let mut style = span.role.as_deref().map_or(base, |role| typography(cx, role));
175 if let Some(token) = &span.color {
176 style.fg = Some(cx.color(token));
177 }
178 if let Some(token) = &span.background {
179 style.bg = Some(cx.color(token));
180 }
181 style.bold |= span.bold;
182 style
183 })
184 .collect();
185
186 for (row, line) in self.lines(&joined, area.width).into_iter().enumerate() {
187 let Ok(row) = u16::try_from(row) else { break };
188 if row >= area.height {
189 break;
190 }
191 let line_text = &joined[line.clone()];
192 let full_width = text::width(line_text);
193 let (visible, cut) = if full_width > area.width {
194 (text::truncate(line_text, area.width).into_owned(), true)
195 } else {
196 (line_text.to_owned(), false)
197 };
198 let visible_width = text::width(&visible);
199 let offset = match self.align {
200 Align::Start => 0,
201 Align::Center => area.width.saturating_sub(visible_width) / 2,
202 Align::End => area.width.saturating_sub(visible_width),
203 };
204 let y = area.y + i32::from(row);
205 let mut x = area.x + i32::from(offset);
206 let mut remaining = if cut { area.width.saturating_sub(1) } else { visible_width };
207 for (range, style) in ranges.iter().zip(&styles) {
208 let start = range.start.max(line.start);
209 let end = range.end.min(line.end);
210 if start >= end || remaining == 0 {
211 continue;
212 }
213 let piece = &joined[start..end];
214 let drawn = cx.text(x, y, piece, *style, remaining).min(remaining);
215 x += i32::from(drawn);
216 remaining -= drawn;
217 }
218 if cut {
219 let last_style = styles.last().copied().unwrap_or(base);
220 cx.text(x, y, text::ELLIPSIS, last_style, 1);
221 }
222 }
223 }
224}
225
226pub(crate) fn typography(cx: &PaintCx<'_>, role: &str) -> CellStyle {
228 let Some(props) = cx.env().theme().typography(role) else {
229 return CellStyle::fg(cx.color("text"));
230 };
231 let style = crate::style::WidgetStyle::new(props.clone(), cx.pulse_phase());
232 let mut text_style = style.text();
233 text_style.bg = None;
234 text_style
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::runtime::{App, Command, Harness};
241 use crate::widget::View;
242
243 struct Demo(Text);
244
245 impl App for Demo {
246 type Msg = ();
247 fn update(&mut self, _: ()) -> Command<()> {
248 Command::none()
249 }
250 fn view(&self, ui: &mut View<'_, ()>) {
251 ui.add(self.0.clone()).fill_width();
252 }
253 }
254
255 #[test]
256 fn wraps_and_truncates() {
257 let wrapped = Harness::new(Demo(Text::new("terminal interfaces with taste")), 12, 4);
258 assert_eq!(wrapped.screen(), "terminal\ninterfaces\nwith taste\n\n");
259 let cut = Harness::new(Demo(Text::new("terminal interfaces").no_wrap()), 12, 2);
260 assert_eq!(cut.screen(), "terminal in…\n\n");
261 }
262
263 #[test]
264 fn spans_keep_their_styles_and_alignment() {
265 let text =
266 Text::rich([Span::new("ok "), Span::new("done").color("success").on("raised").bold()]).align(Align::End);
267 let h = Harness::new(Demo(text), 10, 1);
268 assert_eq!(h.screen(), " ok done\n");
269 let theme = h.env().theme();
270 assert_eq!(h.fg(3, 0), theme.color("text"));
271 assert_eq!(h.fg(6, 0), theme.color("success"));
272 assert!(h.is_bold(6, 0) && !h.is_bold(3, 0));
273 assert_eq!(h.bg(6, 0), theme.color("raised"));
274 }
275}