textual 1.0.0-dev

A reactive TUI framework inspired by the Python Textual library
Documentation
use rich_rs::Console;
use textual::css::{default_widget_stylesheet, set_style_context};
use textual::event::MouseDownEvent;
use textual::prelude::*;
use textual::reactive::ReactiveCtx;
use textual::render::FrameBuffer;
use textual::runtime::{build_widget_tree_from_root, render_tree_to_frame};
use textual::style::parse_color_like;

fn two_tabs() -> Tabs {
    Tabs::new()
        .with_tab_id("one", "One")
        .with_tab_id("two", "Two")
}

fn render_tabs_frame(tabs: &mut Tabs, width: u16, height: u16) -> FrameBuffer {
    let console = Console::new();
    let mut tree = build_widget_tree_from_root(tabs).expect("tree should exist");
    render_tree_to_frame(&mut tree, tabs, &console, width as usize, height as usize)
}

fn find_label_column(header: &str, label: &str) -> usize {
    header
        .find(label)
        .unwrap_or_else(|| panic!("label {label:?} should exist in header: {header:?}"))
        as usize
}

#[test]
fn tabs_render_header_and_active_content() {
    let mut tabs = two_tabs();
    let frame = render_tabs_frame(&mut tabs, 20, 3);
    let lines = frame.as_plain_lines();
    assert!(
        lines.first().is_some_and(|line| line.contains("One")),
        "first header row should include first tab label: {lines:?}"
    );
    assert!(
        lines.first().is_some_and(|line| line.contains("Two")),
        "first header row should include second tab label: {lines:?}"
    );
    assert!(
        lines.iter().any(|line| line.chars().any(|ch| ch == '')),
        "frame should contain underline glyphs: {lines:?}"
    );
}

#[test]
fn tabs_keyboard_changes_active_tab() {
    let mut tabs = two_tabs();
    tabs.set_focus(true);
    let key = KeyEventData::from_crossterm(crossterm::event::KeyEvent::new(
        crossterm::event::KeyCode::Right,
        crossterm::event::KeyModifiers::NONE,
    ));
    let mut ctx = EventCtx::default();
    tabs.on_event(&Event::Key(key), &mut ctx);
    assert!(ctx.handled());
    assert!(tabs.is_active("two"));
}

#[test]
fn tabs_mouse_click_on_header_changes_active_tab() {
    let mut tabs = two_tabs();
    tabs.on_layout(40, 5);
    let id = NodeId::default();
    let mut ctx = EventCtx::default();
    tabs.on_event(
        &Event::MouseDown(MouseDownEvent {
            target: id,
            screen_x: 5,
            screen_y: 0,
            x: 5,
            y: 0,
        }),
        &mut ctx,
    );
    assert!(ctx.handled());
    assert!(tabs.is_active("two"));
}

#[test]
fn tabs_mouse_hit_testing_handles_wide_grapheme_titles() {
    let mut tabs = Tabs::new()
        .with_tab_id("first", "👩‍🚀")
        .with_tab_id("deux", "Deux");
    tabs.on_layout(40, 5);
    let id = NodeId::default();
    let first_label_cells = rich_rs::cell_len(" 👩‍🚀 ");
    let mut ctx = EventCtx::default();
    tabs.on_event(
        &Event::MouseDown(MouseDownEvent {
            target: id,
            screen_x: first_label_cells as u16 + 1,
            screen_y: 0,
            x: first_label_cells as u16 + 1,
            y: 0,
        }),
        &mut ctx,
    );
    assert!(ctx.handled());
    assert!(tabs.is_active("deux"));
}

#[test]
fn tabs_switch_binding_hint_is_hidden_for_footer() {
    let tabs = two_tabs();
    assert_eq!(
        tabs.binding_hints(),
        vec![
            BindingHint::new("left", "Previous tab")
                .with_key_display("")
                .hidden(true),
            BindingHint::new("right", "Next tab")
                .with_key_display("")
                .hidden(true),
        ]
    );
}

#[test]
fn tabs_default_css_focus_styles_active_tab_and_underline() {
    let _guard = set_style_context(default_widget_stylesheet());
    let mut tabs = two_tabs();
    tabs.set_focus(true);
    let frame = render_tabs_frame(&mut tabs, 20, 2);

    let header = frame.as_plain_lines()[0].clone();
    let active_col = find_label_column(&header, "One");
    let inactive_col = find_label_column(&header, "Two");

    let active_tab_style = frame.get(active_col, 0).style.expect("active tab style");
    let inactive_tab_style = frame
        .get(inactive_col, 0)
        .style
        .expect("inactive tab style");
    assert_ne!(active_tab_style.color, inactive_tab_style.color);
    assert_eq!(
        active_tab_style.bgcolor,
        Some(
            parse_color_like("$block-cursor-background")
                .expect("block cursor background")
                .to_simple_opaque()
        )
    );
    assert_eq!(
        active_tab_style.color,
        Some(
            parse_color_like("$block-cursor-foreground")
                .expect("block cursor foreground")
                .flatten_over(
                    parse_color_like("$block-cursor-background").expect("block cursor background"),
                )
                .to_simple_opaque()
        )
    );
    assert_eq!(active_tab_style.bold, Some(true));

    let active_underline_style = frame
        .get(active_col, 1)
        .style
        .expect("active underline style");
    let inactive_underline_style = frame
        .get(inactive_col, 1)
        .style
        .expect("inactive underline style");
    assert!(
        active_underline_style.color.is_some(),
        "active underline should have explicit color style"
    );
    assert!(
        inactive_underline_style.color.is_some(),
        "inactive underline should have explicit color style"
    );
}

#[test]
fn tabs_default_css_styles_inactive_tab_differently_from_active_tab() {
    let _guard = set_style_context(default_widget_stylesheet());
    let mut tabs = two_tabs();
    let frame = render_tabs_frame(&mut tabs, 20, 2);

    let header = frame.as_plain_lines()[0].clone();
    let active_col = find_label_column(&header, "One");
    let inactive_col = find_label_column(&header, "Two");
    let active_style = frame.get(active_col, 0).style.expect("active style");
    let inactive_style = frame.get(inactive_col, 0).style.expect("inactive style");
    assert_ne!(active_style.color, inactive_style.color);
}

#[test]
fn tabs_keyboard_navigation_skips_disabled_and_hidden_tabs() {
    let mut tabs = Tabs::new()
        .with_tab_id("one", "One")
        .with_tab_id("two", "Two")
        .with_tab_id("three", "Three")
        .with_tab_id("four", "Four");
    let mut rctx = ReactiveCtx::new(NodeId::default());
    assert!(tabs.disable_tab("two", &mut rctx));
    assert!(tabs.hide_tab("three", &mut rctx));
    tabs.set_focus(true);

    let right = KeyEventData::from_crossterm(crossterm::event::KeyEvent::new(
        crossterm::event::KeyCode::Right,
        crossterm::event::KeyModifiers::NONE,
    ));
    let mut ctx = EventCtx::default();
    tabs.on_event(&Event::Key(right.clone()), &mut ctx);
    assert!(ctx.handled());
    assert!(tabs.is_active("four"));

    let mut wrap_ctx = EventCtx::default();
    tabs.on_event(&Event::Key(right), &mut wrap_ctx);
    assert!(wrap_ctx.handled());
    assert!(tabs.is_active("one"));
}

#[test]
fn tabs_mouse_click_disabled_tab_does_not_activate() {
    let mut tabs = two_tabs();
    let mut rctx = ReactiveCtx::new(NodeId::default());
    assert!(tabs.disable_tab("two", &mut rctx));
    tabs.on_layout(40, 5);
    let id = NodeId::default();
    let mut ctx = EventCtx::default();
    tabs.on_event(
        &Event::MouseDown(MouseDownEvent {
            target: id,
            screen_x: 6,
            screen_y: 0,
            x: 6,
            y: 0,
        }),
        &mut ctx,
    );
    assert!(!ctx.handled());
    assert!(tabs.is_active("one"));
}

#[test]
fn tabs_hiding_active_tab_promotes_next_available() {
    let mut tabs = Tabs::new()
        .with_tab_id("one", "One")
        .with_tab_id("two", "Two")
        .with_tab_id("three", "Three");
    let mut rctx = ReactiveCtx::new(NodeId::default());
    tabs.set_active("two", &mut rctx);
    assert!(tabs.is_active("two"));

    assert!(tabs.hide_tab("two", &mut rctx));
    assert!(tabs.is_active("three"));
    assert!(tabs.hide_tab("three", &mut rctx));
    assert!(tabs.is_active("one"));
}