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 .axis(Axis::Column)
380 .align(Align::Stretch)
381 .height(Size::Fixed(tokens::CONTROL_HEIGHT + 2.0))
382 .width(Size::Hug);
383 if matches!(config.active_style, ActiveTabStyle::Lifted) && selected {
384 tab = tab.fill(tokens::CARD).default_radius(tokens::RADIUS_SM);
385 }
386 tab
387}
388
389#[track_caller]
393pub fn editor_tabs<I, V, L>(
394 key: impl Into<String>,
395 current: &impl std::fmt::Display,
396 options: I,
397) -> El
398where
399 I: IntoIterator<Item = (V, L)>,
400 V: std::fmt::Display,
401 L: Into<String>,
402{
403 editor_tabs_with(key, current, options, EditorTabsConfig::default())
404}
405
406#[track_caller]
410pub fn editor_tabs_with<I, V, L>(
411 key: impl Into<String>,
412 current: &impl std::fmt::Display,
413 options: I,
414 config: EditorTabsConfig,
415) -> El
416where
417 I: IntoIterator<Item = (V, L)>,
418 V: std::fmt::Display,
419 L: Into<String>,
420{
421 let caller = Location::caller();
422 let key = key.into();
423 let current_str = current.to_string();
424
425 let mut children: Vec<El> = options
426 .into_iter()
427 .map(|(value, label)| {
428 let selected = value.to_string() == current_str;
429 editor_tab(&key, value, None, label, selected, config).at_loc(caller)
430 })
431 .collect();
432
433 let add_key = editor_tab_add_key(&key);
438 let add_btn = icon_button(IconName::Plus)
439 .at_loc(caller)
440 .key(add_key)
441 .icon_size(tokens::ICON_SM)
442 .ghost()
443 .width(Size::Fixed(tokens::CONTROL_HEIGHT))
444 .height(Size::Fixed(tokens::CONTROL_HEIGHT));
445 children.push(add_btn);
446
447 El::new(Kind::Custom("editor_tabs"))
448 .at_loc(caller)
449 .axis(Axis::Row)
450 .default_gap(tokens::SPACE_1)
451 .align(Align::Center)
452 .children(children)
453 .fill(tokens::MUTED)
454 .default_padding(Sides::xy(tokens::SPACE_2, tokens::SPACE_1))
455 .width(Size::Fill(1.0))
456 .height(Size::Hug)
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462 use crate::event::KeyModifiers;
463
464 fn click(key: &str) -> UiEvent {
465 UiEvent {
466 path: None,
467 kind: UiEventKind::Click,
468 key: Some(key.to_string()),
469 target: None,
470 pointer: None,
471 key_press: None,
472 text: None,
473 selection: None,
474 modifiers: KeyModifiers::default(),
475 click_count: 1,
476 }
477 }
478
479 #[test]
480 fn key_helpers_match_widget_format() {
481 assert_eq!(editor_tab_select_key("docs", &"readme"), "docs:tab:readme");
482 assert_eq!(editor_tab_close_key("docs", &"readme"), "docs:close:readme");
483 assert_eq!(editor_tab_add_key("docs"), "docs:add");
484 }
485
486 #[test]
487 fn classify_event_recognises_all_three_actions() {
488 assert_eq!(
489 classify_event(&click("docs:tab:readme"), "docs"),
490 Some(EditorTabsAction::Select("readme")),
491 );
492 assert_eq!(
493 classify_event(&click("docs:close:readme"), "docs"),
494 Some(EditorTabsAction::Close("readme")),
495 );
496 assert_eq!(
497 classify_event(&click("docs:add"), "docs"),
498 Some(EditorTabsAction::Add),
499 );
500 assert_eq!(classify_event(&click("other:tab:x"), "docs"), None);
502 assert_eq!(classify_event(&click("docs"), "docs"), None);
503 }
504
505 #[test]
506 fn classify_event_ignores_non_activating_kinds() {
507 let mut ev = click("docs:close:readme");
508 ev.kind = UiEventKind::PointerDown;
509 assert_eq!(classify_event(&ev, "docs"), None);
510 ev.kind = UiEventKind::Activate;
511 assert_eq!(
512 classify_event(&ev, "docs"),
513 Some(EditorTabsAction::Close("readme")),
514 "keyboard activation should fire close like a click",
515 );
516 }
517
518 #[test]
519 fn editor_tab_routes_via_select_key() {
520 let tab = editor_tab(
521 "docs",
522 "readme",
523 None,
524 "README.md",
525 false,
526 EditorTabsConfig::default(),
527 );
528 assert_eq!(tab.key.as_deref(), Some("docs:tab:readme"));
529 assert!(tab.focusable);
530 }
531
532 #[test]
533 fn editor_tab_active_lifted_fills_with_card() {
534 let active = editor_tab(
535 "docs",
536 "readme",
537 None,
538 "README.md",
539 true,
540 EditorTabsConfig::default(),
541 );
542 let inactive = editor_tab(
543 "docs",
544 "readme",
545 None,
546 "README.md",
547 false,
548 EditorTabsConfig::default(),
549 );
550 assert_eq!(active.fill, Some(tokens::CARD));
551 assert_eq!(
552 inactive.fill, None,
553 "inactive lifted tabs leave fill unset so the strip's MUTED background shows through",
554 );
555 }
556
557 #[test]
558 fn editor_tab_top_accent_renders_a_rule_row_above_the_body() {
559 let cfg = EditorTabsConfig {
560 active_style: ActiveTabStyle::TopAccent,
561 ..Default::default()
562 };
563 let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
564 assert!(active.children.len() >= 2);
567 assert_eq!(active.children[0].fill, Some(tokens::PRIMARY));
568 }
569
570 #[test]
571 fn editor_tab_bottom_rule_renders_a_rule_row_below_the_body() {
572 let cfg = EditorTabsConfig {
573 active_style: ActiveTabStyle::BottomRule,
574 ..Default::default()
575 };
576 let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
577 let last = active.children.last().expect("at least one child");
578 assert_eq!(last.fill, Some(tokens::PRIMARY));
579 }
580
581 #[test]
582 fn editor_tab_inactive_under_top_accent_omits_the_rule_fill() {
583 let cfg = EditorTabsConfig {
584 active_style: ActiveTabStyle::TopAccent,
585 ..Default::default()
586 };
587 let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
588 assert_eq!(inactive.children[0].fill, None);
591 }
592
593 #[test]
594 fn close_visibility_active_or_hover_hides_close_at_rest_on_inactive() {
595 let cfg = EditorTabsConfig {
596 close_visibility: CloseVisibility::ActiveOrHover,
597 ..Default::default()
598 };
599 let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
604 let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
605 let active_body = &active.children[0];
606 let inactive_body = &inactive.children[0];
607 assert_eq!(active_body.children.len(), 2);
608 assert_eq!(inactive_body.children.len(), 2);
609 let active_close = &active_body.children[1];
611 assert_eq!(active_close.hover_alpha, None);
612 let inactive_close = &inactive_body.children[1];
615 let cfg = inactive_close.hover_alpha.expect("hover_alpha attached");
616 assert_eq!(cfg.rest, 0.0);
617 assert_eq!(cfg.peak, 1.0);
618 }
619
620 #[test]
621 fn close_visibility_dimmed_uses_partial_rest_opacity() {
622 let cfg = EditorTabsConfig {
623 close_visibility: CloseVisibility::Dimmed,
624 ..Default::default()
625 };
626 let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
627 let body = &inactive.children[0];
628 let close = &body.children[1];
629 match close.hover_alpha {
632 Some(cfg) => {
633 assert!(
634 cfg.rest > 0.0 && cfg.rest < 1.0,
635 "Dimmed rest should be partial; got {}",
636 cfg.rest,
637 );
638 assert_eq!(cfg.peak, 1.0);
639 }
640 None => panic!("Dimmed should attach hover_alpha so interaction composes the alpha"),
641 }
642 }
643
644 #[test]
645 fn close_visibility_always_skips_hover_alpha() {
646 let cfg = EditorTabsConfig {
647 close_visibility: CloseVisibility::Always,
648 ..Default::default()
649 };
650 let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
651 let body = &inactive.children[0];
652 let close = &body.children[1];
653 assert_eq!(close.hover_alpha, None);
657 }
658
659 #[test]
660 fn editor_tab_leading_prepends_inside_the_body_row() {
661 let dot = crate::tree::column([crate::widgets::text::text("●")])
662 .width(Size::Fixed(8.0))
663 .height(Size::Fixed(8.0));
664 let tab = editor_tab(
665 "docs",
666 "readme",
667 Some(dot),
668 "README.md",
669 false,
670 EditorTabsConfig::default(),
671 );
672 let body = &tab.children[0];
675 assert_eq!(body.children.len(), 3);
676 }
677
678 #[test]
679 fn editor_tabs_appends_an_add_button_with_the_strip_add_key() {
680 let strip = editor_tabs(
681 "docs",
682 &"readme",
683 [("readme", "README.md"), ("main", "main.rs")],
684 );
685 assert_eq!(strip.children.len(), 3);
687 let add = strip.children.last().unwrap();
688 assert_eq!(add.key.as_deref(), Some("docs:add"));
689 }
690
691 #[test]
692 fn editor_tabs_marks_only_the_current_value_active() {
693 let strip = editor_tabs(
694 "docs",
695 &"main",
696 [
697 ("readme", "README.md"),
698 ("main", "main.rs"),
699 ("cargo", "Cargo.toml"),
700 ],
701 );
702 assert_eq!(strip.children[0].fill, None);
703 assert_eq!(strip.children[1].fill, Some(tokens::CARD));
704 assert_eq!(strip.children[2].fill, None);
705 }
706
707 #[test]
708 fn apply_event_select_swaps_active_without_touching_tabs() {
709 let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
710 let mut active = "a".to_string();
711 let next_id = || "fresh".to_string();
712 assert!(apply_event(
713 &mut tabs,
714 &mut active,
715 &click("docs:tab:b"),
716 "docs",
717 |s| Some(s.to_string()),
718 next_id,
719 ));
720 assert_eq!(active, "b");
721 assert_eq!(tabs, vec!["a", "b", "c"]);
722 }
723
724 #[test]
725 fn apply_event_close_removes_tab_and_picks_neighbour_when_active() {
726 let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
727 let mut active = "b".to_string();
728 let next_id = || "fresh".to_string();
729 assert!(apply_event(
730 &mut tabs,
731 &mut active,
732 &click("docs:close:b"),
733 "docs",
734 |s| Some(s.to_string()),
735 next_id,
736 ));
737 assert_eq!(tabs, vec!["a", "c"]);
738 assert_eq!(active, "c");
741 }
742
743 #[test]
744 fn apply_event_close_last_tab_picks_previous_neighbour() {
745 let mut tabs = vec!["a".to_string(), "b".to_string()];
746 let mut active = "b".to_string();
747 let next_id = || "fresh".to_string();
748 assert!(apply_event(
749 &mut tabs,
750 &mut active,
751 &click("docs:close:b"),
752 "docs",
753 |s| Some(s.to_string()),
754 next_id,
755 ));
756 assert_eq!(tabs, vec!["a"]);
757 assert_eq!(active, "a");
758 }
759
760 #[test]
761 fn apply_event_close_inactive_tab_leaves_active_alone() {
762 let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
763 let mut active = "a".to_string();
764 let next_id = || "fresh".to_string();
765 assert!(apply_event(
766 &mut tabs,
767 &mut active,
768 &click("docs:close:c"),
769 "docs",
770 |s| Some(s.to_string()),
771 next_id,
772 ));
773 assert_eq!(tabs, vec!["a", "b"]);
774 assert_eq!(active, "a");
775 }
776
777 #[test]
778 fn apply_event_refuses_to_close_the_last_tab() {
779 let mut tabs = vec!["a".to_string()];
780 let mut active = "a".to_string();
781 let next_id = || "fresh".to_string();
782 assert!(apply_event(
783 &mut tabs,
784 &mut active,
785 &click("docs:close:a"),
786 "docs",
787 |s| Some(s.to_string()),
788 next_id,
789 ));
790 assert_eq!(
791 tabs,
792 vec!["a"],
793 "the last tab can't be closed via the helper"
794 );
795 assert_eq!(active, "a");
796 }
797
798 #[test]
799 fn apply_event_add_appends_and_activates_a_minted_tab() {
800 let mut tabs = vec!["a".to_string()];
801 let mut active = "a".to_string();
802 let mut counter = 0;
803 let next_id = || {
804 counter += 1;
805 format!("new-{counter}")
806 };
807 assert!(apply_event(
808 &mut tabs,
809 &mut active,
810 &click("docs:add"),
811 "docs",
812 |s| Some(s.to_string()),
813 next_id,
814 ));
815 assert_eq!(tabs, vec!["a", "new-1"]);
816 assert_eq!(active, "new-1");
817 }
818
819 #[test]
820 fn apply_event_returns_false_for_foreign_events() {
821 let mut tabs = vec!["a".to_string()];
822 let mut active = "a".to_string();
823 let next_id = || "fresh".to_string();
824 assert!(!apply_event(
825 &mut tabs,
826 &mut active,
827 &click("save"),
828 "docs",
829 |s| Some(s.to_string()),
830 next_id,
831 ));
832 assert_eq!(tabs, vec!["a"]);
833 assert_eq!(active, "a");
834 }
835}