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),
163 Add,
166}
167
168pub fn editor_tab_select_key(key: &str, value: &impl std::fmt::Display) -> String {
173 format!("{key}:tab:{value}")
174}
175
176pub fn editor_tab_close_key(key: &str, value: &impl std::fmt::Display) -> String {
178 format!("{key}:close:{value}")
179}
180
181pub fn editor_tab_add_key(key: &str) -> String {
183 format!("{key}:add")
184}
185
186pub fn classify_event<'a>(event: &'a UiEvent, key: &str) -> Option<EditorTabsAction<'a>> {
197 if !matches!(
198 event.kind,
199 UiEventKind::Click | UiEventKind::Activate | UiEventKind::MiddleClick
200 ) {
201 return None;
202 }
203 let routed = event.route()?;
204 let rest = routed.strip_prefix(key)?.strip_prefix(':')?;
205 if event.kind == UiEventKind::MiddleClick {
206 if let Some(value) = rest
207 .strip_prefix("tab:")
208 .or_else(|| rest.strip_prefix("close:"))
209 {
210 return Some(EditorTabsAction::Close(value));
211 }
212 return None;
213 }
214 if let Some(value) = rest.strip_prefix("tab:") {
215 return Some(EditorTabsAction::Select(value));
216 }
217 if let Some(value) = rest.strip_prefix("close:") {
218 return Some(EditorTabsAction::Close(value));
219 }
220 if rest == "add" {
221 return Some(EditorTabsAction::Add);
222 }
223 None
224}
225
226pub fn apply_event<V>(
249 tabs: &mut Vec<V>,
250 active: &mut V,
251 event: &UiEvent,
252 key: &str,
253 parse: impl Fn(&str) -> Option<V>,
254 mint_new: impl FnOnce() -> V,
255) -> bool
256where
257 V: Clone + PartialEq,
258{
259 match classify_event(event, key) {
260 Some(EditorTabsAction::Select(raw)) => {
261 if let Some(v) = parse(raw) {
262 *active = v;
263 }
264 true
265 }
266 Some(EditorTabsAction::Close(raw)) => {
267 let Some(target) = parse(raw) else {
268 return true;
269 };
270 let Some(index) = tabs.iter().position(|t| *t == target) else {
271 return true;
272 };
273 if tabs.len() <= 1 {
277 return true;
278 }
279 let was_active = *active == target;
280 tabs.remove(index);
281 if was_active {
282 let next = index.min(tabs.len() - 1);
283 *active = tabs[next].clone();
284 }
285 true
286 }
287 Some(EditorTabsAction::Add) => {
288 let new = mint_new();
289 *active = new.clone();
290 tabs.push(new);
291 true
292 }
293 None => false,
294 }
295}
296
297#[track_caller]
313pub fn editor_tab(
314 strip_key: &str,
315 value: impl std::fmt::Display,
316 leading: Option<El>,
317 label: impl Into<String>,
318 selected: bool,
319 config: EditorTabsConfig,
320) -> El {
321 let select_key = editor_tab_select_key(strip_key, &value);
322 let close_key = editor_tab_close_key(strip_key, &value);
323
324 let label_el = text(label).label().ellipsis().text_color(if selected {
325 tokens::FOREGROUND
326 } else {
327 tokens::MUTED_FOREGROUND
328 });
329
330 let mut close = icon_button(IconName::X)
336 .key(close_key)
337 .icon_size(tokens::ICON_XS)
338 .ghost()
339 .width(Size::Fixed(tokens::SPACE_5))
340 .height(Size::Fixed(tokens::SPACE_5));
341 if !selected {
342 let rest = match config.close_visibility {
343 CloseVisibility::ActiveOrHover => 0.0,
344 CloseVisibility::Dimmed => 0.4,
345 CloseVisibility::Always => 1.0,
346 };
347 if rest < 1.0 {
351 close = close.hover_alpha(rest, 1.0);
352 }
353 }
354
355 let mut body_children: Vec<El> = Vec::with_capacity(3);
356 if let Some(leading) = leading {
357 body_children.push(leading);
358 }
359 body_children.push(label_el);
360 body_children.push(close);
361 let body = row(body_children)
362 .gap(tokens::SPACE_2)
363 .align(Align::Center)
364 .padding(Sides::xy(tokens::SPACE_3, 0.0))
365 .height(Size::Fill(1.0));
366
367 let rule = || {
372 let mut el = El::new(Kind::Custom("editor_tab_accent_rule"))
373 .height(Size::Fixed(2.0))
374 .width(Size::Fill(1.0));
375 if selected {
376 el = el.fill(tokens::PRIMARY);
377 }
378 el
379 };
380
381 let stack = match config.active_style {
382 ActiveTabStyle::Lifted => column([body]),
383 ActiveTabStyle::TopAccent => column([rule(), body]),
384 ActiveTabStyle::BottomRule => column([body, rule()]),
385 };
386
387 let mut tab = stack
388 .at_loc(Location::caller())
389 .key(select_key)
390 .style_profile(StyleProfile::Solid)
391 .focusable()
392 .cursor(Cursor::Pointer)
393 .paint_overflow(Sides::all(tokens::RING_WIDTH))
394 .hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
395 .axis(Axis::Column)
396 .align(Align::Stretch)
397 .height(Size::Fixed(tokens::CONTROL_HEIGHT + 2.0))
398 .width(Size::Hug);
399 if matches!(config.active_style, ActiveTabStyle::Lifted) && selected {
400 tab = tab.fill(tokens::CARD).default_radius(tokens::RADIUS_SM);
401 }
402 tab
403}
404
405#[track_caller]
409pub fn editor_tabs<I, V, L>(
410 key: impl Into<String>,
411 current: &impl std::fmt::Display,
412 options: I,
413) -> El
414where
415 I: IntoIterator<Item = (V, L)>,
416 V: std::fmt::Display,
417 L: Into<String>,
418{
419 editor_tabs_with(key, current, options, EditorTabsConfig::default())
420}
421
422#[track_caller]
426pub fn editor_tabs_with<I, V, L>(
427 key: impl Into<String>,
428 current: &impl std::fmt::Display,
429 options: I,
430 config: EditorTabsConfig,
431) -> El
432where
433 I: IntoIterator<Item = (V, L)>,
434 V: std::fmt::Display,
435 L: Into<String>,
436{
437 let caller = Location::caller();
438 let key = key.into();
439 let current_str = current.to_string();
440
441 let mut children: Vec<El> = options
442 .into_iter()
443 .map(|(value, label)| {
444 let selected = value.to_string() == current_str;
445 editor_tab(&key, value, None, label, selected, config).at_loc(caller)
446 })
447 .collect();
448
449 let add_key = editor_tab_add_key(&key);
454 let add_btn = icon_button(IconName::Plus)
455 .at_loc(caller)
456 .key(add_key)
457 .icon_size(tokens::ICON_SM)
458 .ghost()
459 .width(Size::Fixed(tokens::CONTROL_HEIGHT))
460 .height(Size::Fixed(tokens::CONTROL_HEIGHT));
461 children.push(add_btn);
462
463 El::new(Kind::Custom("editor_tabs"))
464 .at_loc(caller)
465 .axis(Axis::Row)
466 .default_gap(tokens::SPACE_1)
467 .align(Align::Center)
468 .children(children)
469 .fill(tokens::MUTED)
470 .default_padding(Sides::xy(tokens::SPACE_2, tokens::SPACE_1))
471 .width(Size::Fill(1.0))
472 .height(Size::Hug)
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use crate::event::KeyModifiers;
479
480 fn click(key: &str) -> UiEvent {
481 UiEvent {
482 path: None,
483 kind: UiEventKind::Click,
484 key: Some(key.to_string()),
485 target: None,
486 pointer: None,
487 key_press: None,
488 text: None,
489 selection: None,
490 modifiers: KeyModifiers::default(),
491 click_count: 1,
492 }
493 }
494
495 fn middle_click(key: &str) -> UiEvent {
496 let mut event = click(key);
497 event.kind = UiEventKind::MiddleClick;
498 event
499 }
500
501 #[test]
502 fn key_helpers_match_widget_format() {
503 assert_eq!(editor_tab_select_key("docs", &"readme"), "docs:tab:readme");
504 assert_eq!(editor_tab_close_key("docs", &"readme"), "docs:close:readme");
505 assert_eq!(editor_tab_add_key("docs"), "docs:add");
506 }
507
508 #[test]
509 fn classify_event_recognises_all_three_actions() {
510 assert_eq!(
511 classify_event(&click("docs:tab:readme"), "docs"),
512 Some(EditorTabsAction::Select("readme")),
513 );
514 assert_eq!(
515 classify_event(&click("docs:close:readme"), "docs"),
516 Some(EditorTabsAction::Close("readme")),
517 );
518 assert_eq!(
519 classify_event(&click("docs:add"), "docs"),
520 Some(EditorTabsAction::Add),
521 );
522 assert_eq!(classify_event(&click("other:tab:x"), "docs"), None);
524 assert_eq!(classify_event(&click("docs"), "docs"), None);
525 }
526
527 #[test]
528 fn classify_event_middle_click_on_tab_closes_it() {
529 assert_eq!(
530 classify_event(&middle_click("docs:tab:readme"), "docs"),
531 Some(EditorTabsAction::Close("readme")),
532 );
533 assert_eq!(
534 classify_event(&middle_click("docs:close:readme"), "docs"),
535 Some(EditorTabsAction::Close("readme")),
536 );
537 assert_eq!(
538 classify_event(&middle_click("docs:add"), "docs"),
539 None,
540 "middle-clicking the add button should not create a tab",
541 );
542 }
543
544 #[test]
545 fn classify_event_ignores_non_activating_kinds() {
546 let mut ev = click("docs:close:readme");
547 ev.kind = UiEventKind::PointerDown;
548 assert_eq!(classify_event(&ev, "docs"), None);
549 ev.kind = UiEventKind::Activate;
550 assert_eq!(
551 classify_event(&ev, "docs"),
552 Some(EditorTabsAction::Close("readme")),
553 "keyboard activation should fire close like a click",
554 );
555 }
556
557 #[test]
558 fn editor_tab_routes_via_select_key() {
559 let tab = editor_tab(
560 "docs",
561 "readme",
562 None,
563 "README.md",
564 false,
565 EditorTabsConfig::default(),
566 );
567 assert_eq!(tab.key.as_deref(), Some("docs:tab:readme"));
568 assert!(tab.focusable);
569 }
570
571 #[test]
572 fn editor_tab_active_lifted_fills_with_card() {
573 let active = editor_tab(
574 "docs",
575 "readme",
576 None,
577 "README.md",
578 true,
579 EditorTabsConfig::default(),
580 );
581 let inactive = editor_tab(
582 "docs",
583 "readme",
584 None,
585 "README.md",
586 false,
587 EditorTabsConfig::default(),
588 );
589 assert_eq!(active.fill, Some(tokens::CARD));
590 assert_eq!(
591 inactive.fill, None,
592 "inactive lifted tabs leave fill unset so the strip's MUTED background shows through",
593 );
594 }
595
596 #[test]
597 fn editor_tab_top_accent_renders_a_rule_row_above_the_body() {
598 let cfg = EditorTabsConfig {
599 active_style: ActiveTabStyle::TopAccent,
600 ..Default::default()
601 };
602 let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
603 assert!(active.children.len() >= 2);
606 assert_eq!(active.children[0].fill, Some(tokens::PRIMARY));
607 }
608
609 #[test]
610 fn editor_tab_bottom_rule_renders_a_rule_row_below_the_body() {
611 let cfg = EditorTabsConfig {
612 active_style: ActiveTabStyle::BottomRule,
613 ..Default::default()
614 };
615 let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
616 let last = active.children.last().expect("at least one child");
617 assert_eq!(last.fill, Some(tokens::PRIMARY));
618 }
619
620 #[test]
621 fn editor_tab_inactive_under_top_accent_omits_the_rule_fill() {
622 let cfg = EditorTabsConfig {
623 active_style: ActiveTabStyle::TopAccent,
624 ..Default::default()
625 };
626 let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
627 assert_eq!(inactive.children[0].fill, None);
630 }
631
632 #[test]
633 fn close_visibility_active_or_hover_hides_close_at_rest_on_inactive() {
634 let cfg = EditorTabsConfig {
635 close_visibility: CloseVisibility::ActiveOrHover,
636 ..Default::default()
637 };
638 let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
643 let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
644 let active_body = &active.children[0];
645 let inactive_body = &inactive.children[0];
646 assert_eq!(active_body.children.len(), 2);
647 assert_eq!(inactive_body.children.len(), 2);
648 let active_close = &active_body.children[1];
650 assert_eq!(active_close.hover_alpha, None);
651 let inactive_close = &inactive_body.children[1];
654 let cfg = inactive_close.hover_alpha.expect("hover_alpha attached");
655 assert_eq!(cfg.rest, 0.0);
656 assert_eq!(cfg.peak, 1.0);
657 }
658
659 #[test]
660 fn close_visibility_dimmed_uses_partial_rest_opacity() {
661 let cfg = EditorTabsConfig {
662 close_visibility: CloseVisibility::Dimmed,
663 ..Default::default()
664 };
665 let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
666 let body = &inactive.children[0];
667 let close = &body.children[1];
668 match close.hover_alpha {
671 Some(cfg) => {
672 assert!(
673 cfg.rest > 0.0 && cfg.rest < 1.0,
674 "Dimmed rest should be partial; got {}",
675 cfg.rest,
676 );
677 assert_eq!(cfg.peak, 1.0);
678 }
679 None => panic!("Dimmed should attach hover_alpha so interaction composes the alpha"),
680 }
681 }
682
683 #[test]
684 fn close_visibility_always_skips_hover_alpha() {
685 let cfg = EditorTabsConfig {
686 close_visibility: CloseVisibility::Always,
687 ..Default::default()
688 };
689 let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
690 let body = &inactive.children[0];
691 let close = &body.children[1];
692 assert_eq!(close.hover_alpha, None);
696 }
697
698 #[test]
699 fn editor_tab_leading_prepends_inside_the_body_row() {
700 let dot = crate::tree::column([crate::widgets::text::text("●")])
701 .width(Size::Fixed(8.0))
702 .height(Size::Fixed(8.0));
703 let tab = editor_tab(
704 "docs",
705 "readme",
706 Some(dot),
707 "README.md",
708 false,
709 EditorTabsConfig::default(),
710 );
711 let body = &tab.children[0];
714 assert_eq!(body.children.len(), 3);
715 }
716
717 #[test]
718 fn editor_tabs_appends_an_add_button_with_the_strip_add_key() {
719 let strip = editor_tabs(
720 "docs",
721 &"readme",
722 [("readme", "README.md"), ("main", "main.rs")],
723 );
724 assert_eq!(strip.children.len(), 3);
726 let add = strip.children.last().unwrap();
727 assert_eq!(add.key.as_deref(), Some("docs:add"));
728 }
729
730 #[test]
731 fn editor_tabs_marks_only_the_current_value_active() {
732 let strip = editor_tabs(
733 "docs",
734 &"main",
735 [
736 ("readme", "README.md"),
737 ("main", "main.rs"),
738 ("cargo", "Cargo.toml"),
739 ],
740 );
741 assert_eq!(strip.children[0].fill, None);
742 assert_eq!(strip.children[1].fill, Some(tokens::CARD));
743 assert_eq!(strip.children[2].fill, None);
744 }
745
746 #[test]
747 fn apply_event_select_swaps_active_without_touching_tabs() {
748 let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
749 let mut active = "a".to_string();
750 let next_id = || "fresh".to_string();
751 assert!(apply_event(
752 &mut tabs,
753 &mut active,
754 &click("docs:tab:b"),
755 "docs",
756 |s| Some(s.to_string()),
757 next_id,
758 ));
759 assert_eq!(active, "b");
760 assert_eq!(tabs, vec!["a", "b", "c"]);
761 }
762
763 #[test]
764 fn apply_event_close_removes_tab_and_picks_neighbour_when_active() {
765 let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
766 let mut active = "b".to_string();
767 let next_id = || "fresh".to_string();
768 assert!(apply_event(
769 &mut tabs,
770 &mut active,
771 &click("docs:close:b"),
772 "docs",
773 |s| Some(s.to_string()),
774 next_id,
775 ));
776 assert_eq!(tabs, vec!["a", "c"]);
777 assert_eq!(active, "c");
780 }
781
782 #[test]
783 fn apply_event_close_last_tab_picks_previous_neighbour() {
784 let mut tabs = vec!["a".to_string(), "b".to_string()];
785 let mut active = "b".to_string();
786 let next_id = || "fresh".to_string();
787 assert!(apply_event(
788 &mut tabs,
789 &mut active,
790 &click("docs:close:b"),
791 "docs",
792 |s| Some(s.to_string()),
793 next_id,
794 ));
795 assert_eq!(tabs, vec!["a"]);
796 assert_eq!(active, "a");
797 }
798
799 #[test]
800 fn apply_event_close_inactive_tab_leaves_active_alone() {
801 let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
802 let mut active = "a".to_string();
803 let next_id = || "fresh".to_string();
804 assert!(apply_event(
805 &mut tabs,
806 &mut active,
807 &click("docs:close:c"),
808 "docs",
809 |s| Some(s.to_string()),
810 next_id,
811 ));
812 assert_eq!(tabs, vec!["a", "b"]);
813 assert_eq!(active, "a");
814 }
815
816 #[test]
817 fn apply_event_middle_click_on_tab_closes_it() {
818 let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
819 let mut active = "a".to_string();
820 let next_id = || "fresh".to_string();
821 assert!(apply_event(
822 &mut tabs,
823 &mut active,
824 &middle_click("docs:tab:b"),
825 "docs",
826 |s| Some(s.to_string()),
827 next_id,
828 ));
829 assert_eq!(tabs, vec!["a", "c"]);
830 assert_eq!(active, "a");
831 }
832
833 #[test]
834 fn apply_event_refuses_to_close_the_last_tab() {
835 let mut tabs = vec!["a".to_string()];
836 let mut active = "a".to_string();
837 let next_id = || "fresh".to_string();
838 assert!(apply_event(
839 &mut tabs,
840 &mut active,
841 &click("docs:close:a"),
842 "docs",
843 |s| Some(s.to_string()),
844 next_id,
845 ));
846 assert_eq!(
847 tabs,
848 vec!["a"],
849 "the last tab can't be closed via the helper"
850 );
851 assert_eq!(active, "a");
852 }
853
854 #[test]
855 fn apply_event_add_appends_and_activates_a_minted_tab() {
856 let mut tabs = vec!["a".to_string()];
857 let mut active = "a".to_string();
858 let mut counter = 0;
859 let next_id = || {
860 counter += 1;
861 format!("new-{counter}")
862 };
863 assert!(apply_event(
864 &mut tabs,
865 &mut active,
866 &click("docs:add"),
867 "docs",
868 |s| Some(s.to_string()),
869 next_id,
870 ));
871 assert_eq!(tabs, vec!["a", "new-1"]);
872 assert_eq!(active, "new-1");
873 }
874
875 #[test]
876 fn apply_event_returns_false_for_foreign_events() {
877 let mut tabs = vec!["a".to_string()];
878 let mut active = "a".to_string();
879 let next_id = || "fresh".to_string();
880 assert!(!apply_event(
881 &mut tabs,
882 &mut active,
883 &click("save"),
884 "docs",
885 |s| Some(s.to_string()),
886 next_id,
887 ));
888 assert_eq!(tabs, vec!["a"]);
889 assert_eq!(active, "a");
890 }
891}