textual 1.0.0-dev

A reactive TUI framework inspired by the Python Textual library
Documentation
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use rich_rs::Console;
use textual::event::MouseDownEvent;
use textual::prelude::*;
use textual::reactive::ReactiveCtx;
use textual::render::FrameBuffer;

#[test]
fn tree_renders_expanded_and_collapsed_nodes() {
    let console = Console::new();
    let mut options = console.options().clone();
    options.size = (24, 5);
    options.max_width = 24;
    options.max_height = 5;

    let tree = Tree::new(vec![
        TreeNode::new("Root")
            .with_child(TreeNode::new("Child A"))
            .with_child(
                TreeNode::new("Child B")
                    .expanded(false)
                    .with_child(TreeNode::new("Leaf")),
            ),
        TreeNode::new("Other"),
    ]);
    let mut tree = tree;
    tree.set_focus(true);
    let mut rctx = ReactiveCtx::new(NodeId::default());
    tree.set_selected(1, &mut rctx);

    let buf = FrameBuffer::from_renderable(&console, &options, &tree, None);
    insta::assert_snapshot!(buf.debug_dump());
}

#[test]
fn tree_right_key_expands_selected_node() {
    let mut tree = Tree::new(vec![
        TreeNode::new("Root")
            .expanded(false)
            .with_child(TreeNode::new("Child")),
    ]);
    tree.set_focus(true);
    tree.on_layout(24, 5);
    let key = KeyEventData::from_crossterm(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
    let mut ctx = EventCtx::default();
    tree.on_event(&Event::Key(key), &mut ctx);
    assert!(ctx.handled());

    let console = Console::new();
    let mut options = console.options().clone();
    options.size = (24, 5);
    options.max_width = 24;
    options.max_height = 5;
    let buf = FrameBuffer::from_renderable(&console, &options, &tree, None);
    let lines = buf.as_plain_lines();
    assert!(lines.iter().any(|line| line.contains("Child")));
}

#[test]
fn tree_click_on_branch_toggles() {
    let mut tree = Tree::new(vec![
        TreeNode::new("Root")
            .expanded(true)
            .with_child(TreeNode::new("Child")),
    ]);
    tree.on_layout(24, 5);
    let id = NodeId::default();
    let mut ctx = EventCtx::default();
    tree.on_event(
        &Event::MouseDown(MouseDownEvent {
            target: id,
            screen_x: 0,
            screen_y: 0,
            x: 0,
            y: 0,
        }),
        &mut ctx,
    );
    assert!(ctx.handled());

    let console = Console::new();
    let mut options = console.options().clone();
    options.size = (24, 5);
    options.max_width = 24;
    options.max_height = 5;
    let buf = FrameBuffer::from_renderable(&console, &options, &tree, None);
    let lines = buf.as_plain_lines();
    assert!(!lines.iter().any(|line| line.contains("Child")));
}

#[test]
fn tree_content_width_accounts_for_wide_labels() {
    let tree = Tree::new(vec![TreeNode::new("👩‍🚀 Launch")]);
    let width = tree.content_width().expect("tree intrinsic width");
    assert!(width >= rich_rs::cell_len("👩‍🚀 Launch"));
}

#[test]
fn tree_mouse_scroll_clamps_to_bounds() {
    let mut tree = Tree::new(
        (0..10)
            .map(|idx| TreeNode::new(format!("Node {idx}")))
            .collect(),
    );
    tree.on_layout(24, 3);

    let mut ctx = EventCtx::default();
    tree.on_mouse_scroll(0, 100, &mut ctx);
    assert!(ctx.handled());

    let console = Console::new();
    let mut options = console.options().clone();
    options.size = (24, 3);
    options.max_width = 24;
    options.max_height = 3;
    let after_down = FrameBuffer::from_renderable(&console, &options, &tree, None);
    assert!(after_down.as_plain_lines()[0].contains("Node 7"));

    let mut ctx = EventCtx::default();
    tree.on_mouse_scroll(0, -100, &mut ctx);
    assert!(ctx.handled());

    let after_up = FrameBuffer::from_renderable(&console, &options, &tree, None);
    assert!(after_up.as_plain_lines()[0].contains("Node 0"));
}

#[test]
fn tree_navigation_skips_disabled_nodes() {
    let mut tree = Tree::new(vec![
        TreeNode::new("Root")
            .with_child(TreeNode::new("Disabled Child").disabled(true))
            .with_child(TreeNode::new("Enabled Child")),
    ]);
    tree.set_focus(true);
    tree.on_layout(24, 5);
    let mut rctx = ReactiveCtx::new(NodeId::default());
    tree.set_selected(0, &mut rctx);

    let key = KeyEventData::from_crossterm(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
    let mut ctx = EventCtx::default();
    tree.on_event(&Event::Key(key), &mut ctx);
    assert!(ctx.handled());
    assert_eq!(tree.selected(), 2);
}

#[test]
fn tree_mouse_click_ignores_disabled_nodes() {
    let mut tree = Tree::new(vec![
        TreeNode::new("Root").with_child(TreeNode::new("Child").disabled(true)),
    ]);
    tree.on_layout(24, 5);

    let id = NodeId::default();
    let mut ctx = EventCtx::default();
    tree.on_event(
        &Event::MouseDown(MouseDownEvent {
            target: id,
            screen_x: 0,
            screen_y: 1,
            x: 0,
            y: 1,
        }),
        &mut ctx,
    );
    assert!(!ctx.handled());
}

#[test]
fn tree_allows_expansion_without_preloaded_children() {
    let mut tree = Tree::new(vec![
        TreeNode::new("Lazy Root")
            .expanded(false)
            .allow_expand(true),
    ]);
    tree.set_focus(true);
    tree.on_layout(24, 5);

    let console = Console::new();
    let mut options = console.options().clone();
    options.size = (24, 5);
    options.max_width = 24;
    options.max_height = 5;

    let before = FrameBuffer::from_renderable(&console, &options, &tree, None);
    let before_lines = before.as_plain_lines();
    assert!(before_lines.iter().any(|line| line.contains("▶ Lazy Root")));

    let key = KeyEventData::from_crossterm(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
    let mut ctx = EventCtx::default();
    tree.on_event(&Event::Key(key), &mut ctx);
    assert!(ctx.handled());

    let after = FrameBuffer::from_renderable(&console, &options, &tree, None);
    let after_lines = after.as_plain_lines();
    assert!(after_lines.iter().any(|line| line.contains("▼ Lazy Root")));
}

#[test]
fn tree_all_disabled_nodes_do_not_render_highlight_marker() {
    let console = Console::new();
    let mut options = console.options().clone();
    options.size = (24, 3);
    options.max_width = 24;
    options.max_height = 3;

    let tree = Tree::new(vec![
        TreeNode::new("Root").disabled(true),
        TreeNode::new("Other").disabled(true),
    ]);
    let buf = FrameBuffer::from_renderable(&console, &options, &tree, None);
    for line in buf.as_plain_lines() {
        assert!(!line.contains(""));
    }
}

#[test]
fn tree_enter_posts_activation_message_without_toggling() {
    let mut tree = Tree::new(vec![
        TreeNode::new("Root")
            .expanded(false)
            .with_child(TreeNode::new("Child")),
    ]);
    tree.set_focus(true);
    tree.on_layout(24, 4);

    let key = KeyEventData::from_crossterm(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
    let mut ctx = EventCtx::default();
    tree.on_event(&Event::Key(key), &mut ctx);
    assert!(ctx.handled());

    let console = Console::new();
    let mut options = console.options().clone();
    options.size = (24, 4);
    options.max_width = 24;
    options.max_height = 4;
    let buf = FrameBuffer::from_renderable(&console, &options, &tree, None);
    let lines = buf.as_plain_lines();
    assert!(!lines.iter().any(|line| line.contains("Child")));
}

#[test]
fn tree_twisty_click_toggles_without_activation_message() {
    let mut tree = Tree::new(vec![
        TreeNode::new("Root")
            .expanded(true)
            .with_child(TreeNode::new("Child")),
    ]);
    tree.on_layout(24, 4);
    let id = NodeId::default();

    let mut ctx = EventCtx::default();
    tree.on_event(
        &Event::MouseDown(MouseDownEvent {
            target: id,
            screen_x: 0,
            screen_y: 0,
            x: 0,
            y: 0,
        }),
        &mut ctx,
    );
    assert!(ctx.handled());

    let console = Console::new();
    let mut options = console.options().clone();
    options.size = (24, 4);
    options.max_width = 24;
    options.max_height = 4;
    let buf = FrameBuffer::from_renderable(&console, &options, &tree, None);
    let lines = buf.as_plain_lines();
    assert!(!lines.iter().any(|line| line.contains("Child")));
}