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
25pub struct Tabs {
27 props: Props,
28 slot: Slot,
29 state: TabsState,
30}
31
32impl Tabs {
33 pub fn new() -> Self {
35 Self { props: Props::new(), slot: next_slot(), state: TabsState::default() }
36 }
37
38 pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
40 self.props.set(prop, value);
41 self
42 }
43
44 pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
46 self.props.set(prop, value);
47 self
48 }
49
50 pub fn child(self, children: impl IntoChildren) -> Self {
52 self.pane("tab", children)
53 }
54
55 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}