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