1use std::panic::Location;
79
80use crate::cursor::Cursor;
81use crate::event::{UiEvent, UiEventKind};
82use crate::style::StyleProfile;
83use crate::tokens;
84use crate::tree::*;
85use crate::widgets::button::icon_button;
86use crate::{IconName, text};
87
88#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
96#[non_exhaustive]
97pub enum ActiveTabStyle {
98 #[default]
101 Lifted,
102 TopAccent,
105 BottomRule,
108}
109
110#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
125#[non_exhaustive]
126pub enum CloseVisibility {
127 #[default]
131 ActiveOrHover,
132 Always,
134 Dimmed,
138}
139
140#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
144pub struct EditorTabsConfig {
145 pub active_style: ActiveTabStyle,
146 pub close_visibility: CloseVisibility,
147}
148
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
155#[non_exhaustive]
156pub enum EditorTabsAction<'a> {
157 Select(&'a str),
159 Close(&'a str),
162 Add,
165}
166
167pub fn editor_tab_select_key(key: &str, value: &impl std::fmt::Display) -> String {
172 format!("{key}:tab:{value}")
173}
174
175pub fn editor_tab_close_key(key: &str, value: &impl std::fmt::Display) -> String {
177 format!("{key}:close:{value}")
178}
179
180pub fn editor_tab_add_key(key: &str) -> String {
182 format!("{key}:add")
183}
184
185pub fn classify_event<'a>(event: &'a UiEvent, key: &str) -> Option<EditorTabsAction<'a>> {
194 if !matches!(event.kind, UiEventKind::Click | UiEventKind::Activate) {
195 return None;
196 }
197 let routed = event.route()?;
198 let rest = routed.strip_prefix(key)?.strip_prefix(':')?;
199 if let Some(value) = rest.strip_prefix("tab:") {
200 return Some(EditorTabsAction::Select(value));
201 }
202 if let Some(value) = rest.strip_prefix("close:") {
203 return Some(EditorTabsAction::Close(value));
204 }
205 if rest == "add" {
206 return Some(EditorTabsAction::Add);
207 }
208 None
209}
210
211pub fn apply_event<V>(
234 tabs: &mut Vec<V>,
235 active: &mut V,
236 event: &UiEvent,
237 key: &str,
238 parse: impl Fn(&str) -> Option<V>,
239 mint_new: impl FnOnce() -> V,
240) -> bool
241where
242 V: Clone + PartialEq,
243{
244 match classify_event(event, key) {
245 Some(EditorTabsAction::Select(raw)) => {
246 if let Some(v) = parse(raw) {
247 *active = v;
248 }
249 true
250 }
251 Some(EditorTabsAction::Close(raw)) => {
252 let Some(target) = parse(raw) else {
253 return true;
254 };
255 let Some(index) = tabs.iter().position(|t| *t == target) else {
256 return true;
257 };
258 if tabs.len() <= 1 {
262 return true;
263 }
264 let was_active = *active == target;
265 tabs.remove(index);
266 if was_active {
267 let next = index.min(tabs.len() - 1);
268 *active = tabs[next].clone();
269 }
270 true
271 }
272 Some(EditorTabsAction::Add) => {
273 let new = mint_new();
274 *active = new.clone();
275 tabs.push(new);
276 true
277 }
278 None => false,
279 }
280}
281
282#[track_caller]
298pub fn editor_tab(
299 strip_key: &str,
300 value: impl std::fmt::Display,
301 leading: Option<El>,
302 label: impl Into<String>,
303 selected: bool,
304 config: EditorTabsConfig,
305) -> El {
306 let select_key = editor_tab_select_key(strip_key, &value);
307 let close_key = editor_tab_close_key(strip_key, &value);
308
309 let label_el = text(label).label().ellipsis().text_color(if selected {
310 tokens::FOREGROUND
311 } else {
312 tokens::MUTED_FOREGROUND
313 });
314
315 let mut close = icon_button(IconName::X)
321 .key(close_key)
322 .icon_size(tokens::ICON_XS)
323 .ghost()
324 .width(Size::Fixed(tokens::SPACE_5))
325 .height(Size::Fixed(tokens::SPACE_5));
326 if !selected {
327 let rest = match config.close_visibility {
328 CloseVisibility::ActiveOrHover => 0.0,
329 CloseVisibility::Dimmed => 0.4,
330 CloseVisibility::Always => 1.0,
331 };
332 if rest < 1.0 {
336 close = close.hover_alpha(rest, 1.0);
337 }
338 }
339
340 let mut body_children: Vec<El> = Vec::with_capacity(3);
341 if let Some(leading) = leading {
342 body_children.push(leading);
343 }
344 body_children.push(label_el);
345 body_children.push(close);
346 let body = row(body_children)
347 .gap(tokens::SPACE_2)
348 .align(Align::Center)
349 .padding(Sides::xy(tokens::SPACE_3, 0.0))
350 .height(Size::Fill(1.0));
351
352 let rule = || {
357 let mut el = El::new(Kind::Custom("editor_tab_accent_rule"))
358 .height(Size::Fixed(2.0))
359 .width(Size::Fill(1.0));
360 if selected {
361 el = el.fill(tokens::PRIMARY);
362 }
363 el
364 };
365
366 let stack = match config.active_style {
367 ActiveTabStyle::Lifted => column([body]),
368 ActiveTabStyle::TopAccent => column([rule(), body]),
369 ActiveTabStyle::BottomRule => column([body, rule()]),
370 };
371
372 let mut tab = stack
373 .at_loc(Location::caller())
374 .key(select_key)
375 .style_profile(StyleProfile::Solid)
376 .focusable()
377 .cursor(Cursor::Pointer)
378 .paint_overflow(Sides::all(tokens::RING_WIDTH))
379 .hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
380 .axis(Axis::Column)
381 .align(Align::Stretch)
382 .height(Size::Fixed(tokens::CONTROL_HEIGHT + 2.0))
383 .width(Size::Hug);
384 if matches!(config.active_style, ActiveTabStyle::Lifted) && selected {
385 tab = tab.fill(tokens::CARD).default_radius(tokens::RADIUS_SM);
386 }
387 tab
388}
389
390#[track_caller]
394pub fn editor_tabs<I, V, L>(
395 key: impl Into<String>,
396 current: &impl std::fmt::Display,
397 options: I,
398) -> El
399where
400 I: IntoIterator<Item = (V, L)>,
401 V: std::fmt::Display,
402 L: Into<String>,
403{
404 editor_tabs_with(key, current, options, EditorTabsConfig::default())
405}
406
407#[track_caller]
411pub fn editor_tabs_with<I, V, L>(
412 key: impl Into<String>,
413 current: &impl std::fmt::Display,
414 options: I,
415 config: EditorTabsConfig,
416) -> El
417where
418 I: IntoIterator<Item = (V, L)>,
419 V: std::fmt::Display,
420 L: Into<String>,
421{
422 let caller = Location::caller();
423 let key = key.into();
424 let current_str = current.to_string();
425
426 let mut children: Vec<El> = options
427 .into_iter()
428 .map(|(value, label)| {
429 let selected = value.to_string() == current_str;
430 editor_tab(&key, value, None, label, selected, config).at_loc(caller)
431 })
432 .collect();
433
434 let add_key = editor_tab_add_key(&key);
439 let add_btn = icon_button(IconName::Plus)
440 .at_loc(caller)
441 .key(add_key)
442 .icon_size(tokens::ICON_SM)
443 .ghost()
444 .width(Size::Fixed(tokens::CONTROL_HEIGHT))
445 .height(Size::Fixed(tokens::CONTROL_HEIGHT));
446 children.push(add_btn);
447
448 El::new(Kind::Custom("editor_tabs"))
449 .at_loc(caller)
450 .axis(Axis::Row)
451 .default_gap(tokens::SPACE_1)
452 .align(Align::Center)
453 .children(children)
454 .fill(tokens::MUTED)
455 .default_padding(Sides::xy(tokens::SPACE_2, tokens::SPACE_1))
456 .width(Size::Fill(1.0))
457 .height(Size::Hug)
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463 use crate::event::KeyModifiers;
464
465 fn click(key: &str) -> UiEvent {
466 UiEvent {
467 path: None,
468 kind: UiEventKind::Click,
469 key: Some(key.to_string()),
470 target: None,
471 pointer: None,
472 key_press: None,
473 text: None,
474 selection: None,
475 modifiers: KeyModifiers::default(),
476 click_count: 1,
477 }
478 }
479
480 #[test]
481 fn key_helpers_match_widget_format() {
482 assert_eq!(editor_tab_select_key("docs", &"readme"), "docs:tab:readme");
483 assert_eq!(editor_tab_close_key("docs", &"readme"), "docs:close:readme");
484 assert_eq!(editor_tab_add_key("docs"), "docs:add");
485 }
486
487 #[test]
488 fn classify_event_recognises_all_three_actions() {
489 assert_eq!(
490 classify_event(&click("docs:tab:readme"), "docs"),
491 Some(EditorTabsAction::Select("readme")),
492 );
493 assert_eq!(
494 classify_event(&click("docs:close:readme"), "docs"),
495 Some(EditorTabsAction::Close("readme")),
496 );
497 assert_eq!(
498 classify_event(&click("docs:add"), "docs"),
499 Some(EditorTabsAction::Add),
500 );
501 assert_eq!(classify_event(&click("other:tab:x"), "docs"), None);
503 assert_eq!(classify_event(&click("docs"), "docs"), None);
504 }
505
506 #[test]
507 fn classify_event_ignores_non_activating_kinds() {
508 let mut ev = click("docs:close:readme");
509 ev.kind = UiEventKind::PointerDown;
510 assert_eq!(classify_event(&ev, "docs"), None);
511 ev.kind = UiEventKind::Activate;
512 assert_eq!(
513 classify_event(&ev, "docs"),
514 Some(EditorTabsAction::Close("readme")),
515 "keyboard activation should fire close like a click",
516 );
517 }
518
519 #[test]
520 fn editor_tab_routes_via_select_key() {
521 let tab = editor_tab(
522 "docs",
523 "readme",
524 None,
525 "README.md",
526 false,
527 EditorTabsConfig::default(),
528 );
529 assert_eq!(tab.key.as_deref(), Some("docs:tab:readme"));
530 assert!(tab.focusable);
531 }
532
533 #[test]
534 fn editor_tab_active_lifted_fills_with_card() {
535 let active = editor_tab(
536 "docs",
537 "readme",
538 None,
539 "README.md",
540 true,
541 EditorTabsConfig::default(),
542 );
543 let inactive = editor_tab(
544 "docs",
545 "readme",
546 None,
547 "README.md",
548 false,
549 EditorTabsConfig::default(),
550 );
551 assert_eq!(active.fill, Some(tokens::CARD));
552 assert_eq!(
553 inactive.fill, None,
554 "inactive lifted tabs leave fill unset so the strip's MUTED background shows through",
555 );
556 }
557
558 #[test]
559 fn editor_tab_top_accent_renders_a_rule_row_above_the_body() {
560 let cfg = EditorTabsConfig {
561 active_style: ActiveTabStyle::TopAccent,
562 ..Default::default()
563 };
564 let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
565 assert!(active.children.len() >= 2);
568 assert_eq!(active.children[0].fill, Some(tokens::PRIMARY));
569 }
570
571 #[test]
572 fn editor_tab_bottom_rule_renders_a_rule_row_below_the_body() {
573 let cfg = EditorTabsConfig {
574 active_style: ActiveTabStyle::BottomRule,
575 ..Default::default()
576 };
577 let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
578 let last = active.children.last().expect("at least one child");
579 assert_eq!(last.fill, Some(tokens::PRIMARY));
580 }
581
582 #[test]
583 fn editor_tab_inactive_under_top_accent_omits_the_rule_fill() {
584 let cfg = EditorTabsConfig {
585 active_style: ActiveTabStyle::TopAccent,
586 ..Default::default()
587 };
588 let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
589 assert_eq!(inactive.children[0].fill, None);
592 }
593
594 #[test]
595 fn close_visibility_active_or_hover_hides_close_at_rest_on_inactive() {
596 let cfg = EditorTabsConfig {
597 close_visibility: CloseVisibility::ActiveOrHover,
598 ..Default::default()
599 };
600 let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
605 let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
606 let active_body = &active.children[0];
607 let inactive_body = &inactive.children[0];
608 assert_eq!(active_body.children.len(), 2);
609 assert_eq!(inactive_body.children.len(), 2);
610 let active_close = &active_body.children[1];
612 assert_eq!(active_close.hover_alpha, None);
613 let inactive_close = &inactive_body.children[1];
616 let cfg = inactive_close.hover_alpha.expect("hover_alpha attached");
617 assert_eq!(cfg.rest, 0.0);
618 assert_eq!(cfg.peak, 1.0);
619 }
620
621 #[test]
622 fn close_visibility_dimmed_uses_partial_rest_opacity() {
623 let cfg = EditorTabsConfig {
624 close_visibility: CloseVisibility::Dimmed,
625 ..Default::default()
626 };
627 let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
628 let body = &inactive.children[0];
629 let close = &body.children[1];
630 match close.hover_alpha {
633 Some(cfg) => {
634 assert!(
635 cfg.rest > 0.0 && cfg.rest < 1.0,
636 "Dimmed rest should be partial; got {}",
637 cfg.rest,
638 );
639 assert_eq!(cfg.peak, 1.0);
640 }
641 None => panic!("Dimmed should attach hover_alpha so interaction composes the alpha"),
642 }
643 }
644
645 #[test]
646 fn close_visibility_always_skips_hover_alpha() {
647 let cfg = EditorTabsConfig {
648 close_visibility: CloseVisibility::Always,
649 ..Default::default()
650 };
651 let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
652 let body = &inactive.children[0];
653 let close = &body.children[1];
654 assert_eq!(close.hover_alpha, None);
658 }
659
660 #[test]
661 fn editor_tab_leading_prepends_inside_the_body_row() {
662 let dot = crate::tree::column([crate::widgets::text::text("●")])
663 .width(Size::Fixed(8.0))
664 .height(Size::Fixed(8.0));
665 let tab = editor_tab(
666 "docs",
667 "readme",
668 Some(dot),
669 "README.md",
670 false,
671 EditorTabsConfig::default(),
672 );
673 let body = &tab.children[0];
676 assert_eq!(body.children.len(), 3);
677 }
678
679 #[test]
680 fn editor_tabs_appends_an_add_button_with_the_strip_add_key() {
681 let strip = editor_tabs(
682 "docs",
683 &"readme",
684 [("readme", "README.md"), ("main", "main.rs")],
685 );
686 assert_eq!(strip.children.len(), 3);
688 let add = strip.children.last().unwrap();
689 assert_eq!(add.key.as_deref(), Some("docs:add"));
690 }
691
692 #[test]
693 fn editor_tabs_marks_only_the_current_value_active() {
694 let strip = editor_tabs(
695 "docs",
696 &"main",
697 [
698 ("readme", "README.md"),
699 ("main", "main.rs"),
700 ("cargo", "Cargo.toml"),
701 ],
702 );
703 assert_eq!(strip.children[0].fill, None);
704 assert_eq!(strip.children[1].fill, Some(tokens::CARD));
705 assert_eq!(strip.children[2].fill, None);
706 }
707
708 #[test]
709 fn apply_event_select_swaps_active_without_touching_tabs() {
710 let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
711 let mut active = "a".to_string();
712 let next_id = || "fresh".to_string();
713 assert!(apply_event(
714 &mut tabs,
715 &mut active,
716 &click("docs:tab:b"),
717 "docs",
718 |s| Some(s.to_string()),
719 next_id,
720 ));
721 assert_eq!(active, "b");
722 assert_eq!(tabs, vec!["a", "b", "c"]);
723 }
724
725 #[test]
726 fn apply_event_close_removes_tab_and_picks_neighbour_when_active() {
727 let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
728 let mut active = "b".to_string();
729 let next_id = || "fresh".to_string();
730 assert!(apply_event(
731 &mut tabs,
732 &mut active,
733 &click("docs:close:b"),
734 "docs",
735 |s| Some(s.to_string()),
736 next_id,
737 ));
738 assert_eq!(tabs, vec!["a", "c"]);
739 assert_eq!(active, "c");
742 }
743
744 #[test]
745 fn apply_event_close_last_tab_picks_previous_neighbour() {
746 let mut tabs = vec!["a".to_string(), "b".to_string()];
747 let mut active = "b".to_string();
748 let next_id = || "fresh".to_string();
749 assert!(apply_event(
750 &mut tabs,
751 &mut active,
752 &click("docs:close:b"),
753 "docs",
754 |s| Some(s.to_string()),
755 next_id,
756 ));
757 assert_eq!(tabs, vec!["a"]);
758 assert_eq!(active, "a");
759 }
760
761 #[test]
762 fn apply_event_close_inactive_tab_leaves_active_alone() {
763 let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
764 let mut active = "a".to_string();
765 let next_id = || "fresh".to_string();
766 assert!(apply_event(
767 &mut tabs,
768 &mut active,
769 &click("docs:close:c"),
770 "docs",
771 |s| Some(s.to_string()),
772 next_id,
773 ));
774 assert_eq!(tabs, vec!["a", "b"]);
775 assert_eq!(active, "a");
776 }
777
778 #[test]
779 fn apply_event_refuses_to_close_the_last_tab() {
780 let mut tabs = vec!["a".to_string()];
781 let mut active = "a".to_string();
782 let next_id = || "fresh".to_string();
783 assert!(apply_event(
784 &mut tabs,
785 &mut active,
786 &click("docs:close:a"),
787 "docs",
788 |s| Some(s.to_string()),
789 next_id,
790 ));
791 assert_eq!(
792 tabs,
793 vec!["a"],
794 "the last tab can't be closed via the helper"
795 );
796 assert_eq!(active, "a");
797 }
798
799 #[test]
800 fn apply_event_add_appends_and_activates_a_minted_tab() {
801 let mut tabs = vec!["a".to_string()];
802 let mut active = "a".to_string();
803 let mut counter = 0;
804 let next_id = || {
805 counter += 1;
806 format!("new-{counter}")
807 };
808 assert!(apply_event(
809 &mut tabs,
810 &mut active,
811 &click("docs:add"),
812 "docs",
813 |s| Some(s.to_string()),
814 next_id,
815 ));
816 assert_eq!(tabs, vec!["a", "new-1"]);
817 assert_eq!(active, "new-1");
818 }
819
820 #[test]
821 fn apply_event_returns_false_for_foreign_events() {
822 let mut tabs = vec!["a".to_string()];
823 let mut active = "a".to_string();
824 let next_id = || "fresh".to_string();
825 assert!(!apply_event(
826 &mut tabs,
827 &mut active,
828 &click("save"),
829 "docs",
830 |s| Some(s.to_string()),
831 next_id,
832 ));
833 assert_eq!(tabs, vec!["a"]);
834 assert_eq!(active, "a");
835 }
836}