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 pointer_kind: None,
493 wheel_delta: None,
494 }
495 }
496
497 fn middle_click(key: &str) -> UiEvent {
498 let mut event = click(key);
499 event.kind = UiEventKind::MiddleClick;
500 event
501 }
502
503 #[test]
504 fn key_helpers_match_widget_format() {
505 assert_eq!(editor_tab_select_key("docs", &"readme"), "docs:tab:readme");
506 assert_eq!(editor_tab_close_key("docs", &"readme"), "docs:close:readme");
507 assert_eq!(editor_tab_add_key("docs"), "docs:add");
508 }
509
510 #[test]
511 fn classify_event_recognises_all_three_actions() {
512 assert_eq!(
513 classify_event(&click("docs:tab:readme"), "docs"),
514 Some(EditorTabsAction::Select("readme")),
515 );
516 assert_eq!(
517 classify_event(&click("docs:close:readme"), "docs"),
518 Some(EditorTabsAction::Close("readme")),
519 );
520 assert_eq!(
521 classify_event(&click("docs:add"), "docs"),
522 Some(EditorTabsAction::Add),
523 );
524 assert_eq!(classify_event(&click("other:tab:x"), "docs"), None);
526 assert_eq!(classify_event(&click("docs"), "docs"), None);
527 }
528
529 #[test]
530 fn classify_event_middle_click_on_tab_closes_it() {
531 assert_eq!(
532 classify_event(&middle_click("docs:tab:readme"), "docs"),
533 Some(EditorTabsAction::Close("readme")),
534 );
535 assert_eq!(
536 classify_event(&middle_click("docs:close:readme"), "docs"),
537 Some(EditorTabsAction::Close("readme")),
538 );
539 assert_eq!(
540 classify_event(&middle_click("docs:add"), "docs"),
541 None,
542 "middle-clicking the add button should not create a tab",
543 );
544 }
545
546 #[test]
547 fn classify_event_ignores_non_activating_kinds() {
548 let mut ev = click("docs:close:readme");
549 ev.kind = UiEventKind::PointerDown;
550 assert_eq!(classify_event(&ev, "docs"), None);
551 ev.kind = UiEventKind::Activate;
552 assert_eq!(
553 classify_event(&ev, "docs"),
554 Some(EditorTabsAction::Close("readme")),
555 "keyboard activation should fire close like a click",
556 );
557 }
558
559 #[test]
560 fn editor_tab_routes_via_select_key() {
561 let tab = editor_tab(
562 "docs",
563 "readme",
564 None,
565 "README.md",
566 false,
567 EditorTabsConfig::default(),
568 );
569 assert_eq!(tab.key.as_deref(), Some("docs:tab:readme"));
570 assert!(tab.focusable);
571 }
572
573 #[test]
574 fn editor_tab_active_lifted_fills_with_card() {
575 let active = editor_tab(
576 "docs",
577 "readme",
578 None,
579 "README.md",
580 true,
581 EditorTabsConfig::default(),
582 );
583 let inactive = editor_tab(
584 "docs",
585 "readme",
586 None,
587 "README.md",
588 false,
589 EditorTabsConfig::default(),
590 );
591 assert_eq!(active.fill, Some(tokens::CARD));
592 assert_eq!(
593 inactive.fill, None,
594 "inactive lifted tabs leave fill unset so the strip's MUTED background shows through",
595 );
596 }
597
598 #[test]
599 fn editor_tab_top_accent_renders_a_rule_row_above_the_body() {
600 let cfg = EditorTabsConfig {
601 active_style: ActiveTabStyle::TopAccent,
602 ..Default::default()
603 };
604 let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
605 assert!(active.children.len() >= 2);
608 assert_eq!(active.children[0].fill, Some(tokens::PRIMARY));
609 }
610
611 #[test]
612 fn editor_tab_bottom_rule_renders_a_rule_row_below_the_body() {
613 let cfg = EditorTabsConfig {
614 active_style: ActiveTabStyle::BottomRule,
615 ..Default::default()
616 };
617 let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
618 let last = active.children.last().expect("at least one child");
619 assert_eq!(last.fill, Some(tokens::PRIMARY));
620 }
621
622 #[test]
623 fn editor_tab_inactive_under_top_accent_omits_the_rule_fill() {
624 let cfg = EditorTabsConfig {
625 active_style: ActiveTabStyle::TopAccent,
626 ..Default::default()
627 };
628 let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
629 assert_eq!(inactive.children[0].fill, None);
632 }
633
634 #[test]
635 fn close_visibility_active_or_hover_hides_close_at_rest_on_inactive() {
636 let cfg = EditorTabsConfig {
637 close_visibility: CloseVisibility::ActiveOrHover,
638 ..Default::default()
639 };
640 let active = editor_tab("docs", "readme", None, "README.md", true, cfg);
645 let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
646 let active_body = &active.children[0];
647 let inactive_body = &inactive.children[0];
648 assert_eq!(active_body.children.len(), 2);
649 assert_eq!(inactive_body.children.len(), 2);
650 let active_close = &active_body.children[1];
652 assert_eq!(active_close.hover_alpha, None);
653 let inactive_close = &inactive_body.children[1];
656 let cfg = inactive_close.hover_alpha.expect("hover_alpha attached");
657 assert_eq!(cfg.rest, 0.0);
658 assert_eq!(cfg.peak, 1.0);
659 }
660
661 #[test]
662 fn close_visibility_dimmed_uses_partial_rest_opacity() {
663 let cfg = EditorTabsConfig {
664 close_visibility: CloseVisibility::Dimmed,
665 ..Default::default()
666 };
667 let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
668 let body = &inactive.children[0];
669 let close = &body.children[1];
670 match close.hover_alpha {
673 Some(cfg) => {
674 assert!(
675 cfg.rest > 0.0 && cfg.rest < 1.0,
676 "Dimmed rest should be partial; got {}",
677 cfg.rest,
678 );
679 assert_eq!(cfg.peak, 1.0);
680 }
681 None => panic!("Dimmed should attach hover_alpha so interaction composes the alpha"),
682 }
683 }
684
685 #[test]
686 fn close_visibility_always_skips_hover_alpha() {
687 let cfg = EditorTabsConfig {
688 close_visibility: CloseVisibility::Always,
689 ..Default::default()
690 };
691 let inactive = editor_tab("docs", "readme", None, "README.md", false, cfg);
692 let body = &inactive.children[0];
693 let close = &body.children[1];
694 assert_eq!(close.hover_alpha, None);
698 }
699
700 #[test]
701 fn editor_tab_leading_prepends_inside_the_body_row() {
702 let dot = crate::tree::column([crate::widgets::text::text("●")])
703 .width(Size::Fixed(8.0))
704 .height(Size::Fixed(8.0));
705 let tab = editor_tab(
706 "docs",
707 "readme",
708 Some(dot),
709 "README.md",
710 false,
711 EditorTabsConfig::default(),
712 );
713 let body = &tab.children[0];
716 assert_eq!(body.children.len(), 3);
717 }
718
719 #[test]
720 fn editor_tabs_appends_an_add_button_with_the_strip_add_key() {
721 let strip = editor_tabs(
722 "docs",
723 &"readme",
724 [("readme", "README.md"), ("main", "main.rs")],
725 );
726 assert_eq!(strip.children.len(), 3);
728 let add = strip.children.last().unwrap();
729 assert_eq!(add.key.as_deref(), Some("docs:add"));
730 }
731
732 #[test]
733 fn editor_tabs_marks_only_the_current_value_active() {
734 let strip = editor_tabs(
735 "docs",
736 &"main",
737 [
738 ("readme", "README.md"),
739 ("main", "main.rs"),
740 ("cargo", "Cargo.toml"),
741 ],
742 );
743 assert_eq!(strip.children[0].fill, None);
744 assert_eq!(strip.children[1].fill, Some(tokens::CARD));
745 assert_eq!(strip.children[2].fill, None);
746 }
747
748 #[test]
749 fn apply_event_select_swaps_active_without_touching_tabs() {
750 let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
751 let mut active = "a".to_string();
752 let next_id = || "fresh".to_string();
753 assert!(apply_event(
754 &mut tabs,
755 &mut active,
756 &click("docs:tab:b"),
757 "docs",
758 |s| Some(s.to_string()),
759 next_id,
760 ));
761 assert_eq!(active, "b");
762 assert_eq!(tabs, vec!["a", "b", "c"]);
763 }
764
765 #[test]
766 fn apply_event_close_removes_tab_and_picks_neighbour_when_active() {
767 let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
768 let mut active = "b".to_string();
769 let next_id = || "fresh".to_string();
770 assert!(apply_event(
771 &mut tabs,
772 &mut active,
773 &click("docs:close:b"),
774 "docs",
775 |s| Some(s.to_string()),
776 next_id,
777 ));
778 assert_eq!(tabs, vec!["a", "c"]);
779 assert_eq!(active, "c");
782 }
783
784 #[test]
785 fn apply_event_close_last_tab_picks_previous_neighbour() {
786 let mut tabs = vec!["a".to_string(), "b".to_string()];
787 let mut active = "b".to_string();
788 let next_id = || "fresh".to_string();
789 assert!(apply_event(
790 &mut tabs,
791 &mut active,
792 &click("docs:close:b"),
793 "docs",
794 |s| Some(s.to_string()),
795 next_id,
796 ));
797 assert_eq!(tabs, vec!["a"]);
798 assert_eq!(active, "a");
799 }
800
801 #[test]
802 fn apply_event_close_inactive_tab_leaves_active_alone() {
803 let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
804 let mut active = "a".to_string();
805 let next_id = || "fresh".to_string();
806 assert!(apply_event(
807 &mut tabs,
808 &mut active,
809 &click("docs:close:c"),
810 "docs",
811 |s| Some(s.to_string()),
812 next_id,
813 ));
814 assert_eq!(tabs, vec!["a", "b"]);
815 assert_eq!(active, "a");
816 }
817
818 #[test]
819 fn apply_event_middle_click_on_tab_closes_it() {
820 let mut tabs = vec!["a".to_string(), "b".to_string(), "c".to_string()];
821 let mut active = "a".to_string();
822 let next_id = || "fresh".to_string();
823 assert!(apply_event(
824 &mut tabs,
825 &mut active,
826 &middle_click("docs:tab:b"),
827 "docs",
828 |s| Some(s.to_string()),
829 next_id,
830 ));
831 assert_eq!(tabs, vec!["a", "c"]);
832 assert_eq!(active, "a");
833 }
834
835 #[test]
836 fn apply_event_refuses_to_close_the_last_tab() {
837 let mut tabs = vec!["a".to_string()];
838 let mut active = "a".to_string();
839 let next_id = || "fresh".to_string();
840 assert!(apply_event(
841 &mut tabs,
842 &mut active,
843 &click("docs:close:a"),
844 "docs",
845 |s| Some(s.to_string()),
846 next_id,
847 ));
848 assert_eq!(
849 tabs,
850 vec!["a"],
851 "the last tab can't be closed via the helper"
852 );
853 assert_eq!(active, "a");
854 }
855
856 #[test]
857 fn apply_event_add_appends_and_activates_a_minted_tab() {
858 let mut tabs = vec!["a".to_string()];
859 let mut active = "a".to_string();
860 let mut counter = 0;
861 let next_id = || {
862 counter += 1;
863 format!("new-{counter}")
864 };
865 assert!(apply_event(
866 &mut tabs,
867 &mut active,
868 &click("docs:add"),
869 "docs",
870 |s| Some(s.to_string()),
871 next_id,
872 ));
873 assert_eq!(tabs, vec!["a", "new-1"]);
874 assert_eq!(active, "new-1");
875 }
876
877 #[test]
878 fn apply_event_returns_false_for_foreign_events() {
879 let mut tabs = vec!["a".to_string()];
880 let mut active = "a".to_string();
881 let next_id = || "fresh".to_string();
882 assert!(!apply_event(
883 &mut tabs,
884 &mut active,
885 &click("save"),
886 "docs",
887 |s| Some(s.to_string()),
888 next_id,
889 ));
890 assert_eq!(tabs, vec!["a"]);
891 assert_eq!(active, "a");
892 }
893}