1use omp_core::Str;
2use smallvec::SmallVec;
3
4use crate::{
5 component::{Component, PaintCtx, Slot, next_slot},
6 context::{Charset, UiContext},
7 frame::{Color, Rect},
8 markup::Align,
9 props::{Prop, PropValue, Props},
10 rich::cell_width,
11};
12
13pub struct Segment {
15 props: Props,
16 label: Str,
17}
18
19impl Segment {
20 pub fn new() -> Self {
22 Self { props: Props::new(), label: Str::default() }
23 }
24
25 pub fn label(mut self, label: impl Into<Str>) -> Self {
27 let label = label.into();
28 if self.label.is_empty() {
29 self.label = label;
30 } else {
31 self.label = Str::from(format!("{}{}", self.label, label));
32 }
33 self
34 }
35
36 pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
38 self.props.set(prop, value);
39 self
40 }
41
42 pub fn with_custom(mut self, name: impl Into<Str>, value: impl Into<PropValue>) -> Self {
44 self.props.set_custom(name, value);
45 self
46 }
47}
48
49impl Default for Segment {
50 fn default() -> Self {
51 Self::new()
52 }
53}
54
55pub struct Status {
61 props: Props,
62 slot: Slot,
63 segments: SmallVec<Segment, 8>,
64 text_widths: SmallVec<u16, 8>,
65}
66
67impl Status {
68 pub fn new() -> Self {
70 Self {
71 props: Props::new(),
72 slot: next_slot(),
73 segments: SmallVec::new(),
74 text_widths: SmallVec::new(),
75 }
76 }
77
78 pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
80 self.props.set(prop, value);
81 self
82 }
83
84 pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
86 self.props.set(prop, value);
87 self
88 }
89
90 pub fn segment(mut self, segment: Segment) -> Self {
92 let width = self
93 .text_widths
94 .last()
95 .copied()
96 .unwrap_or(0)
97 .saturating_add(cell_width(&segment.label));
98 self.segments.push(segment);
99 self.text_widths.push(width);
100 self
101 }
102
103 fn chrome(&self, charset: Charset) -> (&'static str, &'static str, &'static str) {
105 match self.props.align() {
106 Align::End => charset.status_band_end(),
107 Align::Start | Align::Center => charset.status_band(),
108 }
109 }
110
111 fn group_width(&self, count: usize, charset: Charset) -> u16 {
112 let (left_cap, separator, cap) = self.chrome(charset);
113 let text = count
114 .checked_sub(1)
115 .and_then(|index| self.text_widths.get(index))
116 .copied()
117 .unwrap_or(0);
118 let separators = u16::try_from(count.saturating_sub(1))
119 .unwrap_or(u16::MAX)
120 .saturating_mul(cell_width(separator).saturating_add(2));
121 text
122 .saturating_add(separators)
123 .saturating_add(cell_width(left_cap))
124 .saturating_add(2)
125 .saturating_add(cell_width(cap))
126 }
127}
128
129impl Default for Status {
130 fn default() -> Self {
131 Self::new()
132 }
133}
134
135impl Component for Status {
136 fn props(&self) -> &Props {
137 &self.props
138 }
139
140 fn props_mut(&mut self) -> &mut Props {
141 &mut self.props
142 }
143
144 fn slot(&self) -> Slot {
145 self.slot
146 }
147
148 fn measure(&mut self, ctx: &UiContext) -> (u16, u16) {
149 let min = self.group_width(self.segments.len().min(1), ctx.charset);
150 let natural = self.group_width(self.segments.len(), ctx.charset);
151 (min, natural)
152 }
153
154 fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
155 1
156 }
157
158 fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
159 if rect.y >= pc.clip || rect.width == 0 {
160 return;
161 }
162 let mut visible = self.segments.len();
163 while visible > 1 && self.group_width(visible, pc.ctx.charset) > rect.width {
164 visible -= 1;
165 }
166 let style = self.props.style(&pc.ctx.theme);
167 let (left_cap, separator, cap) = self.chrome(pc.ctx.charset);
168 let edge_style = crate::Style::new().fg(style.background_color());
169 let mut column = pc.frame.put(rect.x, rect.y, left_cap, edge_style);
170 column = pc.frame.put(column, rect.y, " ", style);
171 for (index, segment) in self.segments[..visible].iter().enumerate() {
172 if index > 0 {
173 column = pc.frame.put(column, rect.y, " ", style.dim());
174 column = pc.frame.put(column, rect.y, separator, style.dim());
175 column = pc.frame.put(column, rect.y, " ", style.dim());
176 }
177 let mut segment_style = segment.props.style(&pc.ctx.theme).inherit(style);
178 if segment_style.background_color() == Color::Default {
179 segment_style = segment_style.bg(style.background_color());
180 }
181 column = pc.frame.put(column, rect.y, &segment.label, segment_style);
182 }
183 column = pc.frame.put(column, rect.y, " ", style);
184 pc.frame.put(column, rect.y, cap, edge_style);
185 }
186
187 fn paints_background(&self) -> bool {
188 false
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::{Segment, Status};
195 use crate::{
196 Charset, Color, Prop, Ui, UiContext,
197 component::{Cached, Hit, PaintCtx},
198 dom,
199 frame::{Frame, Rect, Size},
200 test_support::frame_row_text,
201 };
202
203 fn paint(status: Status, width: u16) -> (Frame, Vec<Hit>) {
204 paint_with_charset(status, width, Charset::default())
205 }
206
207 fn paint_with_charset(status: Status, width: u16, charset: Charset) -> (Frame, Vec<Hit>) {
208 let ctx = UiContext { charset, ..UiContext::default() };
209 let mut status = Cached::new(Box::new(status));
210 status.place(&ctx, Rect::new(0, 0, width, 1));
211 let mut frame = Frame::new(Size::new(width, 1));
212 let mut hits = Vec::new();
213 status.paint(&mut PaintCtx::new(&mut frame, &ctx, &mut hits, &mut Vec::new()));
214 (frame, hits)
215 }
216
217 #[test]
218 fn status_paints_segments_and_styles() {
219 let status = Status::new()
220 .with(Prop::Bg, "yellow")
221 .segment(Segment::new().label("alpha").with(Prop::Fg, "red"))
222 .segment(
223 Segment::new()
224 .label("beta")
225 .with(Prop::Fg, "green")
226 .with(Prop::Bg, "blue"),
227 )
228 .segment(Segment::new().label("gamma").with(Prop::Fg, "blue"));
229 let (frame, hits) = paint(status, 40);
230 assert_eq!(frame_row_text(&frame, 0), " alpha › beta › gamma ›");
231 assert_eq!(frame.cell(1, 0).style.foreground_color(), Color::Rgb(255, 0, 0));
232 assert_eq!(frame.cell(9, 0).style.foreground_color(), Color::Rgb(0, 128, 0));
233 assert_eq!(frame.cell(16, 0).style.foreground_color(), Color::Rgb(0, 0, 255));
234 assert_eq!(frame.cell(1, 0).style.background_color(), Color::Rgb(255, 255, 0),);
235 assert_eq!(frame.cell(9, 0).style.background_color(), Color::Rgb(0, 0, 255));
236 assert_eq!(
237 frame.cell(22, 0).style.foreground_color(),
238 Color::Rgb(255, 255, 0),
239 "the cap uses the band's background as its foreground",
240 );
241 assert_eq!(
242 frame.cell(22, 0).style.background_color(),
243 Color::Default,
244 "the cap transitions onto the surrounding background",
245 );
246 assert_eq!(
247 frame.cell(23, 0).style.background_color(),
248 Color::Default,
249 "the band stops after the rendered group",
250 );
251 assert!(hits.is_empty());
252 }
253
254 #[test]
255 fn nerd_font_edges_use_band_background_as_foreground() {
256 let status = Status::new()
257 .with(Prop::Bg, "yellow")
258 .segment(Segment::new().label("chip"));
259 let (frame, _) = paint_with_charset(status, 20, Charset::NerdFont);
260
261 assert_eq!(frame_row_text(&frame, 0), "\u{e0b6} chip \u{e0b0}");
262 for column in [0, 7] {
263 assert_eq!(frame.cell(column, 0).style.foreground_color(), Color::Rgb(255, 255, 0),);
264 assert_eq!(frame.cell(column, 0).style.background_color(), Color::Default);
265 }
266 assert_eq!(frame.cell(8, 0).style.background_color(), Color::Default);
267 }
268
269 #[test]
270 fn align_end_mirrors_the_caps_for_a_right_docked_band() {
271 let status = Status::new()
272 .with_str(Prop::Align, "right")
273 .with(Prop::Bg, "yellow")
274 .segment(Segment::new().label("chip"));
275 let (frame, _) = paint_with_charset(status, 20, Charset::NerdFont);
276 assert_eq!(frame_row_text(&frame, 0), "\u{e0b2} chip");
277 assert_eq!(
278 frame.cell(6, 0).style.background_color(),
279 Color::Rgb(255, 255, 0),
280 "the flat closing edge keeps the band background through its pad cell",
281 );
282 let (frame, _) = paint(
283 Status::new()
284 .with_str(Prop::Align, "right")
285 .segment(Segment::new().label("alpha"))
286 .segment(Segment::new().label("beta")),
287 20,
288 );
289 assert_eq!(frame_row_text(&frame, 0), "‹ alpha › beta");
290 }
291
292 #[test]
293 fn status_narrow_width_drops_whole_trailing_segments() {
294 let status = Status::new()
295 .segment(Segment::new().label("alpha"))
296 .segment(Segment::new().label("beta"))
297 .segment(Segment::new().label("gamma"));
298 let (frame, _) = paint(status, 10);
299 let painted = frame_row_text(&frame, 0);
300 assert_eq!(painted, " alpha ›");
301 assert!(!painted.contains("beta"));
302 }
303
304 #[test]
305 fn status_markup_paints_segment_labels() {
306 let ui = Ui::from_markup(
307 "<status><segment fg=green>alpha</segment><segment>beta</segment></status>",
308 40,
309 UiContext::default(),
310 )
311 .expect("status markup should parse");
312 let painted = frame_row_text(ui.frame(), 0);
313 assert!(painted.contains("alpha › beta"));
314 }
315
316 #[test]
317 fn status_markup_rejects_orphan_segment() {
318 let error = Ui::from_markup("<segment>alpha</segment>", 40, UiContext::default())
319 .err()
320 .expect("orphan segment must fail");
321 assert!(
322 error
323 .message
324 .contains("<segment> is not allowed directly inside")
325 );
326 }
327
328 #[test]
329 fn status_macro_paints_segment_label() {
330 let ui = Ui::from_root(
331 dom! { <status><segment fg=green>{"alpha"}</segment></status> },
332 40,
333 UiContext::default(),
334 );
335 assert!(frame_row_text(ui.frame(), 0).contains("alpha"));
336 }
337}