Skip to main content

omp_tui/components/
status.rs

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
13/// Declarative segment data backing the `<segment>` markup tag.
14pub struct Segment {
15	props: Props,
16	label: Str,
17}
18
19impl Segment {
20	/// Creates an empty status segment.
21	pub fn new() -> Self {
22		Self { props: Props::new(), label: Str::default() }
23	}
24
25	/// Appends label text.
26	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	/// Sets one segment property.
37	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
38		self.props.set(prop, value);
39		self
40	}
41
42	/// Sets one custom segment property.
43	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
55/// A one-line powerline-style status group backing the `<status>` markup tag.
56///
57/// `align=end` (`right`) mirrors the caps for a band docked against the right
58/// edge: the opening cap points into the background and the closing edge sits
59/// solid on the margin.
60pub struct Status {
61	props:       Props,
62	slot:        Slot,
63	segments:    SmallVec<Segment, 8>,
64	text_widths: SmallVec<u16, 8>,
65}
66
67impl Status {
68	/// Creates an empty status group.
69	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	/// Sets one status property.
79	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
80		self.props.set(prop, value);
81		self
82	}
83
84	/// Sets one status property from a string.
85	pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
86		self.props.set(prop, value);
87		self
88	}
89
90	/// Appends a segment to the group.
91	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	/// Band chrome for this group's dock side.
104	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}