Skip to main content

omp_tui/components/
tabs.rs

1use omp_core::Str;
2use smallvec::SmallVec;
3
4use super::Col;
5use crate::{
6	component::{
7		Cached, Component, EventCtx, Flow, Hit, HitTag, IntoChildren, PaintCtx, Slot, next_slot,
8	},
9	context::UiContext,
10	frame::{Color, Rect, Style},
11	input::{Key, Mouse},
12	props::{Prop, PropValue, Props},
13	rich::cell_width,
14};
15
16#[derive(Default)]
17struct TabsState {
18	titles: SmallVec<Str, 6>,
19	panes:  Vec<Cached>,
20	idx:    u16,
21	spans:  SmallVec<(u16, u16), 6>,
22	rule:   String,
23}
24
25/// A switchable pane set backing the `<tabs>` markup tag.
26pub struct Tabs {
27	props: Props,
28	slot:  Slot,
29	state: TabsState,
30}
31
32impl Tabs {
33	/// Creates an empty tab set.
34	pub fn new() -> Self {
35		Self { props: Props::new(), slot: next_slot(), state: TabsState::default() }
36	}
37
38	/// Sets one tab-set property.
39	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
40		self.props.set(prop, value);
41		self
42	}
43
44	/// Sets one tab-set property from a string.
45	pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
46		self.props.set(prop, value);
47		self
48	}
49
50	/// Appends an untitled pane.
51	pub fn child(self, children: impl IntoChildren) -> Self {
52		self.pane("tab", children)
53	}
54
55	/// Appends a pane with the supplied title.
56	pub fn pane(mut self, title: impl Into<Str>, children: impl IntoChildren) -> Self {
57		let mut pane = Vec::new();
58		children.extend_children(&mut pane);
59		let pane = if pane.len() == 1 {
60			pane.pop().expect("one pane child")
61		} else {
62			Cached::new(Box::new(Col::new().child(pane)))
63		};
64		self.state.titles.push(title.into());
65		self.state.panes.push(pane);
66		self
67	}
68
69	fn active(&self) -> Option<usize> {
70		let index = usize::from(self.state.idx);
71		(index < self.state.panes.len()).then_some(index)
72	}
73}
74
75impl Default for Tabs {
76	fn default() -> Self {
77		Self::new()
78	}
79}
80
81impl Component for Tabs {
82	fn props(&self) -> &Props {
83		&self.props
84	}
85
86	fn props_mut(&mut self) -> &mut Props {
87		&mut self.props
88	}
89
90	fn slot(&self) -> Slot {
91		self.slot
92	}
93
94	fn children(&self) -> &[Cached] {
95		&self.state.panes
96	}
97
98	fn children_mut(&mut self) -> &mut [Cached] {
99		&mut self.state.panes
100	}
101
102	fn measure(&mut self, ctx: &UiContext) -> (u16, u16) {
103		let bar = self
104			.state
105			.titles
106			.iter()
107			.fold(2u16, |width, title| width.saturating_add(cell_width(title).saturating_add(4)));
108		let mut nat = bar;
109		for pane in &mut self.state.panes {
110			nat = nat.max(pane.measure(ctx).1);
111		}
112		(bar.min(24), nat)
113	}
114
115	fn height(&mut self, ctx: &UiContext, width: u16) -> u16 {
116		let pane_height = self
117			.active()
118			.and_then(|index| self.state.panes.get_mut(index))
119			.filter(|pane| pane.visible)
120			.map_or(0, |pane| pane.height(ctx, width));
121		pane_height.saturating_add(2)
122	}
123
124	fn place(&mut self, ctx: &UiContext, content: Rect) {
125		let Some(index) = self.active() else {
126			return;
127		};
128		let pane = &mut self.state.panes[index];
129		if !pane.visible {
130			return;
131		}
132		let width = content.width;
133		let height = pane.height(ctx, width);
134		pane.place(ctx, Rect::new(content.x, content.y.saturating_add(2), width, height));
135	}
136
137	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
138		let focused = pc.focus == Some(self.slot);
139		let hover_chip = match pc.hover {
140			Some((slot, HitTag::Chip(index))) if slot == self.slot => Some(index),
141			_ => None,
142		};
143		self.state.spans.clear();
144		if rect.y < pc.clip {
145			let mut x = pc.frame.put(
146				rect.x,
147				rect.y,
148				if focused {
149					pc.ctx.charset.cursor()
150				} else {
151					"  "
152				},
153				Style::new().fg(pc.ctx.theme.accent),
154			);
155			for (index, title) in self.state.titles.iter().enumerate() {
156				let start = x.saturating_sub(rect.x);
157				let index = index as u16;
158				let active = index == self.state.idx;
159				let hovered = hover_chip == Some(index);
160				if active {
161					x = pill(
162						pc.frame,
163						x,
164						rect.y,
165						title,
166						pc.ctx.theme.accent,
167						pc.ctx.theme.contrast,
168						pc.ctx.charset.pill_caps(),
169						focused || hovered,
170					);
171				} else {
172					let mut style = Style::new().fg(if hovered {
173						pc.ctx.theme.fg
174					} else {
175						pc.ctx.theme.muted
176					});
177					if hovered {
178						style = style.underline();
179					}
180					x = pc
181						.frame
182						.put(x, rect.y, " ", Style::new().fg(pc.ctx.theme.fg));
183					x = pc.frame.put(x, rect.y, title, style);
184					x = pc
185						.frame
186						.put(x, rect.y, " ", Style::new().fg(pc.ctx.theme.fg));
187				}
188				let end = x.saturating_sub(rect.x);
189				self.state.spans.push((start, end));
190				pc.hits.push(Hit {
191					rect: Rect::new(rect.x.saturating_add(start), rect.y, end.saturating_sub(start), 1),
192					slot: self.slot,
193					tag:  HitTag::Chip(index),
194				});
195				x = pc
196					.frame
197					.put(x, rect.y, "  ", Style::new().fg(pc.ctx.theme.fg));
198			}
199		}
200		if rect.y.saturating_add(1) < pc.clip {
201			self.state.rule.clear();
202			for _ in 0..rect.width {
203				self.state.rule.push(pc.ctx.charset.rule());
204			}
205			pc.frame.put(
206				rect.x,
207				rect.y.saturating_add(1),
208				&self.state.rule,
209				Style::new().fg(pc.ctx.theme.muted),
210			);
211		}
212		let Some(index) = self.active() else {
213			return;
214		};
215		let pane = &mut self.state.panes[index];
216		if pane.visible {
217			pane.paint(pc);
218		}
219	}
220
221	fn focusable(&self) -> bool {
222		true
223	}
224
225	fn ring(&self, out: &mut Vec<Slot>) {
226		out.push(self.slot);
227		if let Some(index) = self.active()
228			&& let Some(pane) = self.state.panes.get(index)
229			&& pane.visible
230		{
231			pane.comp().ring(out);
232		}
233	}
234
235	fn key(&mut self, _ec: &mut EventCtx<'_>, key: Key) -> Flow {
236		let len = self.state.titles.len() as u16;
237		match key {
238			Key::Left if len > 0 => {
239				self.state.idx = (self.state.idx + len - 1) % len;
240				Flow::Consumed
241			},
242			Key::Right if len > 0 => {
243				self.state.idx = (self.state.idx + 1) % len;
244				Flow::Consumed
245			},
246			_ => Flow::Skip,
247		}
248	}
249
250	fn mouse(
251		&mut self,
252		_ec: &mut EventCtx<'_>,
253		tag: HitTag,
254		_at: (u16, u16),
255		_rect: Rect,
256		mouse: Mouse,
257	) -> Flow {
258		match mouse {
259			Mouse::Click => {
260				let HitTag::Chip(index) = tag else {
261					return Flow::Skip;
262				};
263				if usize::from(index) >= self.state.titles.len() {
264					return Flow::Skip;
265				}
266				self.state.idx = index;
267				Flow::Consumed
268			},
269			Mouse::RightClick
270			| Mouse::MiddleClick
271			| Mouse::Move
272			| Mouse::Drag
273			| Mouse::Release
274			| Mouse::WheelUp
275			| Mouse::WheelDown
276			| Mouse::WheelLeft
277			| Mouse::WheelRight => Flow::Skip,
278		}
279	}
280
281	fn value(&self, out: &mut serde_json::Map<String, serde_json::Value>) {
282		let Some(id) = self.props.id() else {
283			return;
284		};
285		let value = self
286			.state
287			.titles
288			.get(usize::from(self.state.idx))
289			.map_or(serde_json::Value::Null, |title| serde_json::Value::String(title.to_string()));
290		out.insert(id.to_string(), value);
291	}
292}
293
294fn pill(
295	frame: &mut crate::Frame,
296	x: u16,
297	y: u16,
298	label: &str,
299	bg: Color,
300	fg: Color,
301	caps: (&str, &str),
302	highlight: bool,
303) -> u16 {
304	let bg = if highlight { brighten(bg) } else { bg };
305	let cap = Style::new().fg(bg);
306	let body = Style::new().fg(fg).bg(bg).bold();
307	let mut x = frame.put(x, y, caps.0, cap);
308	x = frame.put(x, y, label, body);
309	frame.put(x, y, caps.1, cap)
310}
311
312fn brighten(color: Color) -> Color {
313	match color {
314		Color::Rgb(r, g, b) => Color::Rgb(
315			r.saturating_add((255 - u16::from(r)) as u8 / 5),
316			g.saturating_add((255 - u16::from(g)) as u8 / 5),
317			b.saturating_add((255 - u16::from(b)) as u8 / 5),
318		),
319		other => other,
320	}
321}
322
323#[cfg(test)]
324mod tests {
325	use super::*;
326	use crate::{Frame, Size, component::Component, components::Pre, test_support::frame_row_text};
327
328	struct FocusProbe {
329		props: Props,
330		slot:  Slot,
331		text:  &'static str,
332	}
333
334	impl FocusProbe {
335		fn new(text: &'static str) -> Self {
336			Self { props: Props::new(), slot: next_slot(), text }
337		}
338	}
339
340	impl Component for FocusProbe {
341		fn props(&self) -> &Props {
342			&self.props
343		}
344
345		fn props_mut(&mut self) -> &mut Props {
346			&mut self.props
347		}
348
349		fn slot(&self) -> Slot {
350			self.slot
351		}
352
353		fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
354			(3, 3)
355		}
356
357		fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
358			1
359		}
360
361		fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
362			pc.frame.put(rect.x, rect.y, self.text, Style::default());
363		}
364
365		fn focusable(&self) -> bool {
366			true
367		}
368	}
369
370	#[test]
371	fn switching_panes_changes_paint_value_and_ring() {
372		let ctx = UiContext::default();
373		let first = FocusProbe::new("one");
374		let first_slot = first.slot;
375		let second = FocusProbe::new("two");
376		let second_slot = second.slot;
377		let mut tabs = Tabs::new()
378			.with(Prop::Id, "tab-id")
379			.pane("First", first)
380			.pane("Second", second);
381		let tabs_slot = tabs.slot;
382		tabs.place(&ctx, Rect::new(0, 0, 24, 3));
383		let mut ring = Vec::new();
384		tabs.ring(&mut ring);
385		assert_eq!(ring, vec![tabs_slot, first_slot]);
386
387		let mut frame = Frame::new(Size::new(24, 3));
388		let mut hits = Vec::new();
389		let mut wakes = Vec::new();
390		let mut pc = PaintCtx::new(&mut frame, &ctx, &mut hits, &mut wakes);
391		tabs.paint(&mut pc, Rect::new(0, 0, 24, 3));
392		assert_eq!(frame_row_text(pc.frame, 2), "one");
393
394		let mut ec = EventCtx::new(&ctx, 24, 3);
395		assert_eq!(tabs.key(&mut ec, Key::Right), Flow::Consumed);
396		tabs.place(&ctx, Rect::new(0, 0, 24, 3));
397		pc.frame.clear(Style::default());
398		pc.hits.clear();
399		tabs.paint(&mut pc, Rect::new(0, 0, 24, 3));
400		assert_eq!(frame_row_text(pc.frame, 2), "two");
401		ring.clear();
402		tabs.ring(&mut ring);
403		assert_eq!(ring, vec![tabs_slot, second_slot]);
404		let mut values = serde_json::Map::new();
405		tabs.value(&mut values);
406		assert_eq!(values["tab-id"], serde_json::json!("Second"));
407	}
408
409	#[test]
410	fn pane_accepts_multiple_children() {
411		let tabs = Tabs::new().pane("many", vec![Pre::new().text("a"), Pre::new().text("b")]);
412		assert_eq!(tabs.state.panes.len(), 1);
413		assert_eq!(tabs.state.panes[0].comp().children().len(), 2);
414	}
415}