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> {
209 Keymap::new()
210 .bind(Chord::named(Named::Tab).ctrl(), Action::Next)
211 .bind(Chord::named(Named::ArrowRight), Action::Next)
212 .bind(Chord::named(Named::Tab).ctrl().shift(), Action::Previous)
213 .bind(Chord::named(Named::ArrowLeft), Action::Previous)
214 .bind(Chord::named(Named::Home), Action::First)
215 .bind(Chord::named(Named::End), Action::Last)
216}
217
218pub struct Tabs<'a, Id, Message> {
220 state: &'a State<Id>,
221 variant: Variant,
222 width: Option<Length>,
223 on_event: Option<Box<dyn Fn(Event<Id>) -> Message + 'a>>,
224}
225
226pub fn tabs<Id, Message>(state: &State<Id>) -> Tabs<'_, Id, Message> {
229 Tabs {
230 state,
231 variant: Variant::default(),
232 width: None,
233 on_event: None,
234 }
235}
236
237impl<Id: std::fmt::Debug, Message> std::fmt::Debug for Tabs<'_, Id, Message> {
238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 f.debug_struct("Tabs")
240 .field("state", self.state)
241 .field("variant", &self.variant)
242 .finish_non_exhaustive()
243 }
244}
245
246impl<'a, Id, Message> Tabs<'a, Id, Message> {
247 pub fn variant(mut self, variant: Variant) -> Self {
248 self.variant = variant;
249 self
250 }
251
252 pub fn width(mut self, width: impl Into<Length>) -> Self {
254 self.width = Some(width.into());
255 self
256 }
257
258 pub fn on_event(mut self, on_event: impl Fn(Event<Id>) -> Message + 'a) -> Self {
259 self.on_event = Some(Box::new(on_event));
260 self
261 }
262}
263
264impl<'a, Id, Message> From<Tabs<'a, Id, Message>> for Element<'a, Message>
265where
266 Id: Copy + PartialEq + 'a,
267 Message: Clone + 'a,
268{
269 fn from(tabs: Tabs<'a, Id, Message>) -> Self {
270 let Tabs {
271 state,
272 variant,
273 width,
274 on_event,
275 } = tabs;
276
277 let items = state.tabs.iter().map(|tab| {
278 let selected = state.is_selected(tab.id);
279 let message = on_event
280 .as_ref()
281 .filter(|_| !tab.disabled)
282 .map(|on_event| on_event(Event::Select(tab.id)));
283 item(tab, variant, selected, message)
284 });
285
286 match variant {
287 Variant::Underline => column![
288 sideways(row(items).spacing(space::XS)),
289 rule::horizontal(1).style(|theme: &Theme| rule::Style {
290 color: Tokens::of(theme).border,
291 radius: 0.0.into(),
292 fill_mode: rule::FillMode::Full,
293 snap: true,
294 }),
295 ]
296 .width(width.unwrap_or(Length::Fill))
297 .into(),
298 Variant::Pills => container(sideways(row(items).spacing(space::XS)))
299 .padding(space::XS)
300 .width(width.unwrap_or(Length::Shrink))
301 .style(move |theme| list_style(&Tokens::of(theme), variant))
302 .into(),
303 }
304 }
305}
306
307fn sideways<'a, Message: 'a>(list: widget::Row<'a, Message>) -> widget::Scrollable<'a, Message> {
310 widget::scrollable(list).direction(scrollable::Direction::Horizontal(
311 scrollable::Scrollbar::hidden(),
312 ))
313}
314
315fn item<'a, Id, Message: Clone + 'a>(
316 tab: &'a Tab<Id>,
317 variant: Variant,
318 selected: bool,
319 message: Option<Message>,
320) -> Element<'a, Message> {
321 let enabled = message.is_some();
322 let mut label = row![].spacing(space::SM).align_y(Alignment::Center);
323 if let Some(glyph) = tab.icon {
324 let status = if enabled {
325 Status::Active
326 } else {
327 Status::Disabled
328 };
329 label = label.push(themed(glyph, 16.0, opacity(enabled), move |theme| {
330 foreground(&Tokens::of(theme), selected, status)
331 }));
332 }
333 label = label.push(
334 text(tab.label.as_str())
335 .size(text_size::SM)
336 .wrapping(text::Wrapping::None),
337 );
338
339 let button = widget::button(label)
340 .padding([6.0, space::MD])
341 .on_press_maybe(message)
342 .style(move |theme, status| style(&Tokens::of(theme), variant, selected, status));
343
344 if variant == Variant::Pills {
345 return natural(button);
346 }
347
348 natural(
349 column![
350 button,
351 container(iced::widget::space())
352 .width(Length::Fill)
353 .height(2)
354 .style(move |theme| indicator_style(&Tokens::of(theme), selected)),
355 ]
356 .width(Length::Shrink),
357 )
358}
359
360fn foreground(tokens: &Tokens, selected: bool, status: Status) -> Color {
361 let colour = match status {
362 _ if selected => tokens.foreground,
363 Status::Hovered | Status::Pressed => tokens.foreground,
364 _ => tokens.muted_foreground,
365 };
366 if status == Status::Disabled {
367 fade(colour, 0.5)
368 } else {
369 colour
370 }
371}
372
373pub fn style(
375 tokens: &Tokens,
376 variant: Variant,
377 selected: bool,
378 status: Status,
379) -> widget::button::Style {
380 let raised = variant == Variant::Pills && selected;
381 let background = raised.then(|| {
382 if tokens.is_dark {
383 mix(tokens.muted, tokens.foreground, 0.12)
384 } else {
385 tokens.background
386 }
387 });
388
389 widget::button::Style {
390 background: background.map(Background::Color),
391 text_color: foreground(tokens, selected, status),
392 border: Border {
393 radius: radius::MD.into(),
394 ..Border::default()
395 },
396 shadow: if raised {
397 Shadow {
398 color: fade(Color::BLACK, 0.1),
399 offset: Vector::new(0.0, 1.0),
400 blur_radius: 3.0,
401 }
402 } else {
403 Shadow::default()
404 },
405 snap: true,
406 }
407}
408
409pub fn indicator_style(tokens: &Tokens, selected: bool) -> container::Style {
411 container::Style {
412 background: selected.then_some(Background::Color(tokens.foreground)),
413 ..container::Style::default()
414 }
415}
416
417pub fn list_style(tokens: &Tokens, variant: Variant) -> container::Style {
419 match variant {
420 Variant::Underline => container::Style::default(),
421 Variant::Pills => container::Style {
422 background: Some(Background::Color(tokens.muted)),
423 border: Border {
424 radius: radius::LG.into(),
425 ..Border::default()
426 },
427 ..container::Style::default()
428 },
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use crate::theme::{dark, light};
436
437 fn state() -> State<u8> {
438 State::new([
439 tab(0, "A"),
440 tab(1, "B").disabled(true),
441 tab(2, "C"),
442 tab(3, "D"),
443 ])
444 }
445
446 #[test]
447 fn new_selects_first_enabled_tab() {
448 assert_eq!(state().selected(), Some(0));
449 let skipped = State::new([tab(0, "A").disabled(true), tab(1, "B")]);
450 assert_eq!(skipped.selected(), Some(1));
451 }
452
453 #[test]
454 fn empty_and_all_disabled_have_no_selection() {
455 let mut empty: State<u8> = State::new([]);
456 assert_eq!(empty.selected(), None);
457 assert_eq!(empty.update(Event::Next), None);
458 assert_eq!(empty.update(Event::Previous), None);
459
460 let mut disabled = State::new([tab(0, "A").disabled(true)]);
461 assert_eq!(disabled.selected(), None);
462 assert_eq!(disabled.update(Event::Next), None);
463 }
464
465 #[test]
466 fn select_ignores_disabled_unknown_and_current() {
467 let mut state = state();
468 assert_eq!(state.update(Event::Select(1)), None);
469 assert_eq!(state.update(Event::Select(9)), None);
470 assert_eq!(state.update(Event::Select(0)), None);
471 assert_eq!(state.update(Event::Select(3)), Some(3));
472 assert_eq!(state.selected(), Some(3));
473 }
474
475 #[test]
476 fn next_skips_disabled_and_wraps() {
477 let mut state = state();
478 assert_eq!(state.update(Event::Next), Some(2));
479 assert_eq!(state.update(Event::Next), Some(3));
480 assert_eq!(state.update(Event::Next), Some(0));
481 }
482
483 #[test]
484 fn previous_skips_disabled_and_wraps() {
485 let mut state = state();
486 assert_eq!(state.update(Event::Previous), Some(3));
487 assert_eq!(state.update(Event::Previous), Some(2));
488 assert_eq!(state.update(Event::Previous), Some(0));
489 }
490
491 #[test]
492 fn single_enabled_tab_never_moves() {
493 let mut state = State::new([tab(0, "A"), tab(1, "B").disabled(true)]);
494 assert_eq!(state.update(Event::Next), None);
495 assert_eq!(state.update(Event::Previous), None);
496 assert_eq!(state.selected(), Some(0));
497 }
498
499 #[test]
500 fn with_selected_and_set_disabled() {
501 let mut state = state().with_selected(2);
502 assert_eq!(state.selected(), Some(2));
503 state.set_disabled(1, false);
504 assert_eq!(state.update(Event::Previous), Some(1));
505 }
506
507 fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
508 let chord: Chord = chord.parse().unwrap();
509 keymap.resolve(chord.key(), chord.modifiers())
510 }
511
512 #[test]
513 fn default_keymap_covers_every_action() {
514 let keymap = default_keymap();
515 assert_eq!(press(&keymap, "Ctrl+Tab"), Some(Action::Next));
516 assert_eq!(press(&keymap, "ArrowRight"), Some(Action::Next));
517 assert_eq!(press(&keymap, "Ctrl+Shift+Tab"), Some(Action::Previous));
518 assert_eq!(press(&keymap, "ArrowLeft"), Some(Action::Previous));
519 assert_eq!(press(&keymap, "Home"), Some(Action::First));
520 assert_eq!(press(&keymap, "End"), Some(Action::Last));
521 assert_eq!(press(&keymap, "Tab"), None);
522 for action in <Action as keys::Action>::ALL {
523 assert!(!keymap.chords(action).is_empty(), "{action:?}");
524 }
525 assert_eq!(Keymap::<Action>::defaults(), keymap);
526 }
527
528 #[test]
529 fn keymap_overrides_and_unbinding() {
530 let keymap = default_keymap()
531 .unbind(&"ArrowRight".parse().unwrap())
532 .unbind(&"ArrowLeft".parse().unwrap())
533 .bind("Ctrl+PageDown".parse().unwrap(), Action::Next);
534 assert_eq!(press(&keymap, "ArrowRight"), None);
535 assert_eq!(press(&keymap, "Ctrl+PageDown"), Some(Action::Next));
536 assert_eq!(press(&keymap, "Ctrl+Tab"), Some(Action::Next));
537 }
538
539 #[test]
540 fn actions_map_to_events() {
541 let mut state = state().with_selected(2);
542 assert_eq!(Action::Next.event(&state), Some(Event::Next));
543 assert_eq!(Action::Previous.event(&state), Some(Event::Previous));
544 assert_eq!(Action::First.event(&state), Some(Event::Select(0)));
545 assert_eq!(Action::Last.event(&state), Some(Event::Select(3)));
546
547 let event = Action::First.event(&state).unwrap();
548 assert_eq!(state.update(event), Some(0));
549
550 let disabled = State::new([tab(0, "A").disabled(true)]);
551 assert_eq!(Action::First.event(&disabled), None);
552 assert_eq!(Action::Last.event(&disabled), None);
553 }
554
555 #[test]
556 fn first_and_last_skip_disabled_tabs() {
557 let state = State::new([
558 tab(0, "A").disabled(true),
559 tab(1, "B"),
560 tab(2, "C"),
561 tab(3, "D").disabled(true),
562 ]);
563 assert_eq!(Action::First.event(&state), Some(Event::Select(1)));
564 assert_eq!(Action::Last.event(&state), Some(Event::Select(2)));
565 }
566
567 #[test]
568 fn selected_tab_is_emphasised_in_both_themes() {
569 for theme in [light(), dark()] {
570 let tokens = Tokens::of(&theme);
571 for variant in Variant::ALL {
572 let on = style(&tokens, variant, true, Status::Active);
573 let off = style(&tokens, variant, false, Status::Active);
574 assert_eq!(on.text_color, tokens.foreground);
575 assert_eq!(off.text_color, tokens.muted_foreground);
576 }
577 }
578 }
579
580 #[test]
581 fn only_selected_pill_is_raised() {
582 let tokens = Tokens::of(&light());
583 assert!(
584 style(&tokens, Variant::Pills, true, Status::Active)
585 .background
586 .is_some()
587 );
588 assert!(
589 style(&tokens, Variant::Pills, false, Status::Active)
590 .background
591 .is_none()
592 );
593 assert!(
594 style(&tokens, Variant::Underline, true, Status::Active)
595 .background
596 .is_none()
597 );
598 }
599
600 #[test]
601 fn hover_and_disabled_change_the_label_colour() {
602 let tokens = Tokens::of(&dark());
603 let hovered = style(&tokens, Variant::Underline, false, Status::Hovered);
604 let disabled = style(&tokens, Variant::Underline, false, Status::Disabled);
605 assert_eq!(hovered.text_color, tokens.foreground);
606 assert!(disabled.text_color.a < tokens.muted_foreground.a);
607 }
608
609 #[test]
610 fn indicator_and_list_styles() {
611 let tokens = Tokens::of(&light());
612 assert!(indicator_style(&tokens, true).background.is_some());
613 assert!(indicator_style(&tokens, false).background.is_none());
614 assert!(list_style(&tokens, Variant::Pills).background.is_some());
615 assert!(list_style(&tokens, Variant::Underline).background.is_none());
616 }
617}