1use iced::keyboard::key::Named;
11use iced::widget::{self, button::Status, column, container, row, rule, scrollable, text};
12use iced::{Alignment, Background, Border, Color, Element, Length, Shadow, Theme, Vector};
13
14use crate::icon::{Glyph, opacity, themed};
15use crate::keys::{self, Chord, Keymap};
16use crate::natural::natural;
17use crate::theme::{Tokens, fade, mix, radius, space, text_size};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
21pub enum Variant {
22 #[default]
24 Underline,
25 Pills,
27}
28
29impl Variant {
30 pub const ALL: [Variant; 2] = [Variant::Underline, Variant::Pills];
31}
32
33#[derive(Debug, Clone, PartialEq)]
35pub struct Tab<Id> {
36 pub id: Id,
37 pub label: String,
38 pub icon: Option<Glyph>,
39 pub disabled: bool,
40}
41
42pub fn tab<Id>(id: Id, label: impl Into<String>) -> Tab<Id> {
44 Tab {
45 id,
46 label: label.into(),
47 icon: None,
48 disabled: false,
49 }
50}
51
52impl<Id> Tab<Id> {
53 pub fn icon(mut self, glyph: Glyph) -> Self {
54 self.icon = Some(glyph);
55 self
56 }
57
58 pub fn disabled(mut self, disabled: bool) -> Self {
59 self.disabled = disabled;
60 self
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Event<Id> {
67 Select(Id),
68 Next,
69 Previous,
70}
71
72#[derive(Debug, Clone, PartialEq)]
74pub struct State<Id> {
75 tabs: Vec<Tab<Id>>,
76 selected: Option<usize>,
77}
78
79impl<Id: Copy + PartialEq> State<Id> {
80 pub fn new(tabs: impl IntoIterator<Item = Tab<Id>>) -> Self {
82 let tabs: Vec<_> = tabs.into_iter().collect();
83 let selected = tabs.iter().position(|tab| !tab.disabled);
84 Self { tabs, selected }
85 }
86
87 pub fn with_selected(mut self, id: Id) -> Self {
89 let _ = self.update(Event::Select(id));
90 self
91 }
92
93 pub fn tabs(&self) -> &[Tab<Id>] {
94 &self.tabs
95 }
96
97 pub fn selected(&self) -> Option<Id> {
98 self.selected.map(|index| self.tabs[index].id)
99 }
100
101 pub fn is_selected(&self, id: Id) -> bool {
102 self.selected() == Some(id)
103 }
104
105 pub fn set_disabled(&mut self, id: Id, disabled: bool) {
107 if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
108 tab.disabled = disabled;
109 }
110 }
111
112 pub fn update(&mut self, event: Event<Id>) -> Option<Id> {
114 let target = match event {
115 Event::Select(id) => self
116 .tabs
117 .iter()
118 .position(|tab| tab.id == id && !tab.disabled),
119 Event::Next => self.step(true),
120 Event::Previous => self.step(false),
121 }?;
122
123 if self.selected == Some(target) {
124 return None;
125 }
126 self.selected = Some(target);
127 Some(self.tabs[target].id)
128 }
129
130 fn step(&self, forward: bool) -> Option<usize> {
132 let len = self.tabs.len();
133 if len == 0 {
134 return None;
135 }
136 let start = self.selected.unwrap_or(if forward { len - 1 } else { 0 });
137 let offset = if forward { 1 } else { len - 1 };
138 (1..=len)
139 .map(|n| (start + offset * n) % len)
140 .find(|&index| !self.tabs[index].disabled)
141 }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
146pub enum Action {
147 Next,
148 Previous,
149 First,
150 Last,
151}
152
153impl Action {
154 pub fn event<Id: Copy + PartialEq>(self, state: &State<Id>) -> Option<Event<Id>> {
157 let mut enabled = state.tabs.iter().filter(|tab| !tab.disabled);
158 match self {
159 Action::Next => Some(Event::Next),
160 Action::Previous => Some(Event::Previous),
161 Action::First => enabled.next().map(|tab| Event::Select(tab.id)),
162 Action::Last => enabled.next_back().map(|tab| Event::Select(tab.id)),
163 }
164 }
165}
166
167impl keys::Action for Action {
168 const ALL: &'static [Self] = &[Action::Next, Action::Previous, Action::First, Action::Last];
169
170 fn defaults() -> Keymap<Self> {
171 default_keymap()
172 }
173
174 fn name(self) -> &'static str {
175 match self {
176 Action::Next => "Next",
177 Action::Previous => "Previous",
178 Action::First => "First",
179 Action::Last => "Last",
180 }
181 }
182
183 fn description(self) -> &'static str {
184 match self {
185 Action::Next => "Selects the next enabled tab, wrapping at the end.",
186 Action::Previous => "Selects the previous enabled tab, wrapping at the start.",
187 Action::First => "Selects the first enabled tab.",
188 Action::Last => "Selects the last enabled tab.",
189 }
190 }
191}
192
193pub fn default_keymap() -> Keymap<Action> {
207 Keymap::new()
208 .bind(Chord::named(Named::Tab).ctrl(), Action::Next)
209 .bind(Chord::named(Named::ArrowRight), Action::Next)
210 .bind(Chord::named(Named::Tab).ctrl().shift(), Action::Previous)
211 .bind(Chord::named(Named::ArrowLeft), Action::Previous)
212 .bind(Chord::named(Named::Home), Action::First)
213 .bind(Chord::named(Named::End), Action::Last)
214}
215
216pub struct Tabs<'a, Id, Message> {
218 state: &'a State<Id>,
219 variant: Variant,
220 width: Option<Length>,
221 on_event: Option<Box<dyn Fn(Event<Id>) -> Message + 'a>>,
222}
223
224pub fn tabs<Id, Message>(state: &State<Id>) -> Tabs<'_, Id, Message> {
227 Tabs {
228 state,
229 variant: Variant::default(),
230 width: None,
231 on_event: None,
232 }
233}
234
235impl<Id: std::fmt::Debug, Message> std::fmt::Debug for Tabs<'_, Id, Message> {
236 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237 f.debug_struct("Tabs")
238 .field("state", self.state)
239 .field("variant", &self.variant)
240 .finish_non_exhaustive()
241 }
242}
243
244impl<'a, Id, Message> Tabs<'a, Id, Message> {
245 pub fn variant(mut self, variant: Variant) -> Self {
246 self.variant = variant;
247 self
248 }
249
250 pub fn width(mut self, width: impl Into<Length>) -> Self {
252 self.width = Some(width.into());
253 self
254 }
255
256 pub fn on_event(mut self, on_event: impl Fn(Event<Id>) -> Message + 'a) -> Self {
257 self.on_event = Some(Box::new(on_event));
258 self
259 }
260}
261
262impl<'a, Id, Message> From<Tabs<'a, Id, Message>> for Element<'a, Message>
263where
264 Id: Copy + PartialEq + 'a,
265 Message: Clone + 'a,
266{
267 fn from(tabs: Tabs<'a, Id, Message>) -> Self {
268 let Tabs {
269 state,
270 variant,
271 width,
272 on_event,
273 } = tabs;
274
275 let items = state.tabs.iter().map(|tab| {
276 let selected = state.is_selected(tab.id);
277 let message = on_event
278 .as_ref()
279 .filter(|_| !tab.disabled)
280 .map(|on_event| on_event(Event::Select(tab.id)));
281 item(tab, variant, selected, message)
282 });
283
284 match variant {
285 Variant::Underline => column![
286 sideways(row(items).spacing(space::XS)),
287 rule::horizontal(1).style(|theme: &Theme| rule::Style {
288 color: Tokens::of(theme).border,
289 radius: 0.0.into(),
290 fill_mode: rule::FillMode::Full,
291 snap: true,
292 }),
293 ]
294 .width(width.unwrap_or(Length::Fill))
295 .into(),
296 Variant::Pills => container(sideways(row(items).spacing(space::XS)))
297 .padding(space::XS)
298 .width(width.unwrap_or(Length::Shrink))
299 .style(move |theme| list_style(&Tokens::of(theme), variant))
300 .into(),
301 }
302 }
303}
304
305fn sideways<'a, Message: 'a>(list: widget::Row<'a, Message>) -> widget::Scrollable<'a, Message> {
308 widget::scrollable(list).direction(scrollable::Direction::Horizontal(
309 scrollable::Scrollbar::hidden(),
310 ))
311}
312
313fn item<'a, Id, Message: Clone + 'a>(
314 tab: &'a Tab<Id>,
315 variant: Variant,
316 selected: bool,
317 message: Option<Message>,
318) -> Element<'a, Message> {
319 let enabled = message.is_some();
320 let mut label = row![].spacing(space::SM).align_y(Alignment::Center);
321 if let Some(glyph) = tab.icon {
322 let status = if enabled {
323 Status::Active
324 } else {
325 Status::Disabled
326 };
327 label = label.push(themed(glyph, 16.0, opacity(enabled), move |theme| {
328 foreground(&Tokens::of(theme), selected, status)
329 }));
330 }
331 label = label.push(
332 text(tab.label.as_str())
333 .size(text_size::SM)
334 .wrapping(text::Wrapping::None),
335 );
336
337 let button = widget::button(label)
338 .padding([6.0, space::MD])
339 .on_press_maybe(message)
340 .style(move |theme, status| style(&Tokens::of(theme), variant, selected, status));
341
342 if variant == Variant::Pills {
343 return natural(button);
344 }
345
346 natural(
347 column![
348 button,
349 container(iced::widget::space())
350 .width(Length::Fill)
351 .height(2)
352 .style(move |theme| indicator_style(&Tokens::of(theme), selected)),
353 ]
354 .width(Length::Shrink),
355 )
356}
357
358fn foreground(tokens: &Tokens, selected: bool, status: Status) -> Color {
359 let colour = match status {
360 _ if selected => tokens.foreground,
361 Status::Hovered | Status::Pressed => tokens.foreground,
362 _ => tokens.muted_foreground,
363 };
364 if status == Status::Disabled {
365 fade(colour, 0.5)
366 } else {
367 colour
368 }
369}
370
371pub fn style(
373 tokens: &Tokens,
374 variant: Variant,
375 selected: bool,
376 status: Status,
377) -> widget::button::Style {
378 let raised = variant == Variant::Pills && selected;
379 let background = raised.then(|| {
380 if tokens.is_dark {
381 mix(tokens.muted, tokens.foreground, 0.12)
382 } else {
383 tokens.background
384 }
385 });
386
387 widget::button::Style {
388 background: background.map(Background::Color),
389 text_color: foreground(tokens, selected, status),
390 border: Border {
391 radius: radius::MD.into(),
392 ..Border::default()
393 },
394 shadow: if raised {
395 Shadow {
396 color: fade(Color::BLACK, 0.1),
397 offset: Vector::new(0.0, 1.0),
398 blur_radius: 3.0,
399 }
400 } else {
401 Shadow::default()
402 },
403 snap: true,
404 }
405}
406
407pub fn indicator_style(tokens: &Tokens, selected: bool) -> container::Style {
409 container::Style {
410 background: selected.then_some(Background::Color(tokens.foreground)),
411 ..container::Style::default()
412 }
413}
414
415pub fn list_style(tokens: &Tokens, variant: Variant) -> container::Style {
417 match variant {
418 Variant::Underline => container::Style::default(),
419 Variant::Pills => container::Style {
420 background: Some(Background::Color(tokens.muted)),
421 border: Border {
422 radius: radius::LG.into(),
423 ..Border::default()
424 },
425 ..container::Style::default()
426 },
427 }
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433 use crate::theme::{dark, light};
434
435 fn state() -> State<u8> {
436 State::new([
437 tab(0, "A"),
438 tab(1, "B").disabled(true),
439 tab(2, "C"),
440 tab(3, "D"),
441 ])
442 }
443
444 #[test]
445 fn new_selects_first_enabled_tab() {
446 assert_eq!(state().selected(), Some(0));
447 let skipped = State::new([tab(0, "A").disabled(true), tab(1, "B")]);
448 assert_eq!(skipped.selected(), Some(1));
449 }
450
451 #[test]
452 fn empty_and_all_disabled_have_no_selection() {
453 let mut empty: State<u8> = State::new([]);
454 assert_eq!(empty.selected(), None);
455 assert_eq!(empty.update(Event::Next), None);
456 assert_eq!(empty.update(Event::Previous), None);
457
458 let mut disabled = State::new([tab(0, "A").disabled(true)]);
459 assert_eq!(disabled.selected(), None);
460 assert_eq!(disabled.update(Event::Next), None);
461 }
462
463 #[test]
464 fn select_ignores_disabled_unknown_and_current() {
465 let mut state = state();
466 assert_eq!(state.update(Event::Select(1)), None);
467 assert_eq!(state.update(Event::Select(9)), None);
468 assert_eq!(state.update(Event::Select(0)), None);
469 assert_eq!(state.update(Event::Select(3)), Some(3));
470 assert_eq!(state.selected(), Some(3));
471 }
472
473 #[test]
474 fn next_skips_disabled_and_wraps() {
475 let mut state = state();
476 assert_eq!(state.update(Event::Next), Some(2));
477 assert_eq!(state.update(Event::Next), Some(3));
478 assert_eq!(state.update(Event::Next), Some(0));
479 }
480
481 #[test]
482 fn previous_skips_disabled_and_wraps() {
483 let mut state = state();
484 assert_eq!(state.update(Event::Previous), Some(3));
485 assert_eq!(state.update(Event::Previous), Some(2));
486 assert_eq!(state.update(Event::Previous), Some(0));
487 }
488
489 #[test]
490 fn single_enabled_tab_never_moves() {
491 let mut state = State::new([tab(0, "A"), tab(1, "B").disabled(true)]);
492 assert_eq!(state.update(Event::Next), None);
493 assert_eq!(state.update(Event::Previous), None);
494 assert_eq!(state.selected(), Some(0));
495 }
496
497 #[test]
498 fn with_selected_and_set_disabled() {
499 let mut state = state().with_selected(2);
500 assert_eq!(state.selected(), Some(2));
501 state.set_disabled(1, false);
502 assert_eq!(state.update(Event::Previous), Some(1));
503 }
504
505 fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
506 let chord: Chord = chord.parse().unwrap();
507 keymap.resolve(chord.key(), chord.modifiers())
508 }
509
510 #[test]
511 fn default_keymap_covers_every_action() {
512 let keymap = default_keymap();
513 assert_eq!(press(&keymap, "Ctrl+Tab"), Some(Action::Next));
514 assert_eq!(press(&keymap, "ArrowRight"), Some(Action::Next));
515 assert_eq!(press(&keymap, "Ctrl+Shift+Tab"), Some(Action::Previous));
516 assert_eq!(press(&keymap, "ArrowLeft"), Some(Action::Previous));
517 assert_eq!(press(&keymap, "Home"), Some(Action::First));
518 assert_eq!(press(&keymap, "End"), Some(Action::Last));
519 assert_eq!(press(&keymap, "Tab"), None);
520 for action in <Action as keys::Action>::ALL {
521 assert!(!keymap.chords(action).is_empty(), "{action:?}");
522 }
523 assert_eq!(Keymap::<Action>::defaults(), keymap);
524 }
525
526 #[test]
527 fn keymap_overrides_and_unbinding() {
528 let keymap = default_keymap()
529 .unbind(&"ArrowRight".parse().unwrap())
530 .unbind(&"ArrowLeft".parse().unwrap())
531 .bind("Ctrl+PageDown".parse().unwrap(), Action::Next);
532 assert_eq!(press(&keymap, "ArrowRight"), None);
533 assert_eq!(press(&keymap, "Ctrl+PageDown"), Some(Action::Next));
534 assert_eq!(press(&keymap, "Ctrl+Tab"), Some(Action::Next));
535 }
536
537 #[test]
538 fn actions_map_to_events() {
539 let mut state = state().with_selected(2);
540 assert_eq!(Action::Next.event(&state), Some(Event::Next));
541 assert_eq!(Action::Previous.event(&state), Some(Event::Previous));
542 assert_eq!(Action::First.event(&state), Some(Event::Select(0)));
543 assert_eq!(Action::Last.event(&state), Some(Event::Select(3)));
544
545 let event = Action::First.event(&state).unwrap();
546 assert_eq!(state.update(event), Some(0));
547
548 let disabled = State::new([tab(0, "A").disabled(true)]);
549 assert_eq!(Action::First.event(&disabled), None);
550 assert_eq!(Action::Last.event(&disabled), None);
551 }
552
553 #[test]
554 fn first_and_last_skip_disabled_tabs() {
555 let state = State::new([
556 tab(0, "A").disabled(true),
557 tab(1, "B"),
558 tab(2, "C"),
559 tab(3, "D").disabled(true),
560 ]);
561 assert_eq!(Action::First.event(&state), Some(Event::Select(1)));
562 assert_eq!(Action::Last.event(&state), Some(Event::Select(2)));
563 }
564
565 #[test]
566 fn selected_tab_is_emphasised_in_both_themes() {
567 for theme in [light(), dark()] {
568 let tokens = Tokens::of(&theme);
569 for variant in Variant::ALL {
570 let on = style(&tokens, variant, true, Status::Active);
571 let off = style(&tokens, variant, false, Status::Active);
572 assert_eq!(on.text_color, tokens.foreground);
573 assert_eq!(off.text_color, tokens.muted_foreground);
574 }
575 }
576 }
577
578 #[test]
579 fn only_selected_pill_is_raised() {
580 let tokens = Tokens::of(&light());
581 assert!(
582 style(&tokens, Variant::Pills, true, Status::Active)
583 .background
584 .is_some()
585 );
586 assert!(
587 style(&tokens, Variant::Pills, false, Status::Active)
588 .background
589 .is_none()
590 );
591 assert!(
592 style(&tokens, Variant::Underline, true, Status::Active)
593 .background
594 .is_none()
595 );
596 }
597
598 #[test]
599 fn hover_and_disabled_change_the_label_colour() {
600 let tokens = Tokens::of(&dark());
601 let hovered = style(&tokens, Variant::Underline, false, Status::Hovered);
602 let disabled = style(&tokens, Variant::Underline, false, Status::Disabled);
603 assert_eq!(hovered.text_color, tokens.foreground);
604 assert!(disabled.text_color.a < tokens.muted_foreground.a);
605 }
606
607 #[test]
608 fn indicator_and_list_styles() {
609 let tokens = Tokens::of(&light());
610 assert!(indicator_style(&tokens, true).background.is_some());
611 assert!(indicator_style(&tokens, false).background.is_none());
612 assert!(list_style(&tokens, Variant::Pills).background.is_some());
613 assert!(list_style(&tokens, Variant::Underline).background.is_none());
614 }
615}