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::prelude::*;
use textual::render::FrameBuffer;

fn render_buffer_with_size(palette: &CommandPalette, width: usize, height: usize) -> FrameBuffer {
    let _guard = set_style_context(default_widget_stylesheet());
    let console = Console::new();
    let mut options = console.options().clone();
    options.size = (width, height);
    options.max_width = width;
    options.max_height = height;
    FrameBuffer::from_renderable(&console, &options, &WidgetRenderable::new(palette), None)
}

fn render_buffer(palette: &CommandPalette) -> FrameBuffer {
    render_buffer_with_size(palette, 72, 14)
}

#[test]
fn command_palette_closed_snapshot() {
    let palette = CommandPalette::new(Label::new("Body content"));
    let buf = render_buffer(&palette);
    insta::assert_snapshot!(buf.debug_dump());
}

#[test]
fn command_palette_open_snapshot() {
    let mut palette = CommandPalette::new(Label::new("Body content"));
    let mut ctx = EventCtx::default();
    palette.on_event(&Event::Action(Action::CommandPalette), &mut ctx);
    assert!(ctx.handled());

    let buf = render_buffer(&palette);
    insta::assert_snapshot!(buf.debug_dump());
}

#[test]
fn command_palette_help_rows_keep_panel_surface_and_dim_style() {
    let mut palette = CommandPalette::new(Label::new("Body content"));
    let mut ctx = EventCtx::default();
    palette.on_event(&Event::Action(Action::CommandPalette), &mut ctx);
    assert!(ctx.handled());

    let buf = render_buffer(&palette);

    let lines = buf.as_plain_lines();
    let (help_y, help_x) = lines
        .iter()
        .enumerate()
        .find_map(|(y, line)| {
            line.find("Show help for the focused widget")
                .map(|x| (y, x))
        })
        .expect("help row should be present");
    let help_text = buf.get(help_x, help_y);
    let help_pad = buf.get(buf.width.saturating_sub(2), help_y);

    let text_style = help_text
        .style
        .as_ref()
        .expect("help text cell should carry style");
    let pad_style = help_pad
        .style
        .as_ref()
        .expect("help row padding cell should carry panel style");

    assert_eq!(
        text_style.bgcolor, pad_style.bgcolor,
        "help text and trailing padding should share panel surface background"
    );
    assert_eq!(
        text_style.dim,
        Some(true),
        "help description should use dim text style"
    );
}

#[test]
fn command_palette_placeholder_uses_dim_style() {
    let mut palette = CommandPalette::new(Label::new("Body content"));
    let mut ctx = EventCtx::default();
    palette.on_event(&Event::Action(Action::CommandPalette), &mut ctx);
    assert!(ctx.handled());

    let buf = render_buffer(&palette);
    let lines = buf.as_plain_lines();
    let (search_y, search_x) = lines
        .iter()
        .enumerate()
        .find_map(|(y, line)| line.find("Search for commands").map(|x| (y, x)))
        .expect("search placeholder row should be present");
    let placeholder_cell = buf.get(search_x.saturating_add(3), search_y);
    let style = placeholder_cell
        .style
        .as_ref()
        .expect("placeholder cell should have style");
    assert_eq!(
        style.dim,
        Some(true),
        "placeholder text should be rendered dim"
    );
}

#[test]
fn command_palette_search_row_uses_panel_surface_background() {
    let mut palette = CommandPalette::new(Label::new("Body content"));
    palette.set_commands(vec![
        PaletteCommand::new("alpha", "Alpha", "First command"),
        PaletteCommand::new("beta", "Beta", "Second command"),
    ]);
    let mut ctx = EventCtx::default();
    palette.on_event(&Event::Action(Action::CommandPalette), &mut ctx);
    assert!(ctx.handled());

    let buf = render_buffer(&palette);
    let lines = buf.as_plain_lines();
    let (search_y, search_x) = lines
        .iter()
        .enumerate()
        .find_map(|(y, line)| line.find("Search for commands").map(|x| (y, x)))
        .expect("search placeholder row should be present");

    let search_style = buf
        .get(search_x.saturating_add(3), search_y)
        .style
        .as_ref()
        .expect("search placeholder should have style");
    let list_bg_style = buf
        .as_plain_lines()
        .iter()
        .enumerate()
        .find_map(|(y, line)| line.find("Beta").map(|x| (y, x)))
        .and_then(|(y, x)| buf.get(x, y).style.as_ref().cloned())
        .expect("list row should have style");

    assert_eq!(
        search_style.bgcolor, list_bg_style.bgcolor,
        "search input row should share the panel/list surface background"
    );
}

#[test]
fn command_palette_unselected_rows_use_panel_surface_background() {
    let mut palette = CommandPalette::new(Label::new("Body content"));
    palette.set_commands(vec![
        PaletteCommand::new("alpha", "Alpha", "First command"),
        PaletteCommand::new("beta", "Beta", "Second command"),
    ]);
    let mut ctx = EventCtx::default();
    palette.on_event(&Event::Action(Action::CommandPalette), &mut ctx);
    assert!(ctx.handled());

    let buf = render_buffer(&palette);
    let lines = buf.as_plain_lines();
    let (title_y, title_x) = lines
        .iter()
        .enumerate()
        .find_map(|(y, line)| line.find("Beta").map(|x| (y, x)))
        .expect("title row should be present");
    let (help_y, help_x) = lines
        .iter()
        .enumerate()
        .find_map(|(y, line)| line.find("Second command").map(|x| (y, x)))
        .expect("help row should be present");

    let title_style = buf
        .get(title_x, title_y)
        .style
        .as_ref()
        .expect("title row should have style");
    let help_style = buf
        .get(help_x, help_y)
        .style
        .as_ref()
        .expect("help row should have style");
    assert!(
        title_style.bgcolor.is_some(),
        "title row should carry panel background"
    );
    assert!(
        help_style.bgcolor.is_some(),
        "help row should carry panel background"
    );

    let app_bg = buf.get(10, 0).style.as_ref().and_then(|s| s.bgcolor);
    let title_bg = title_style.bgcolor;
    assert_ne!(
        title_bg, app_bg,
        "palette rows should not reuse the app background color"
    );
}

#[test]
fn command_palette_renders_markup_commands_without_literal_tags() {
    let mut palette = CommandPalette::new(Label::new("Body content"));
    palette.set_commands(vec![PaletteCommand::new(
        "markup",
        "[bold]Deploy[/]",
        "[green]Ship[/] current build",
    )]);
    let mut ctx = EventCtx::default();
    palette.on_event(&Event::Action(Action::CommandPalette), &mut ctx);
    assert!(ctx.handled());

    let buf = render_buffer(&palette);
    let lines = buf.as_plain_lines();
    assert!(lines.iter().any(|line| line.contains("Deploy")));
    assert!(lines.iter().any(|line| line.contains("Ship current build")));
    assert!(!lines.iter().any(|line| line.contains("[bold]")));
    assert!(!lines.iter().any(|line| line.contains("[green]")));
}

#[test]
fn command_palette_open_render_handles_small_viewport() {
    let mut palette = CommandPalette::new(Label::new("Body content"));
    palette.on_layout(40, 4);
    let mut ctx = EventCtx::default();
    palette.on_event(&Event::Action(Action::CommandPalette), &mut ctx);
    assert!(ctx.handled());

    let buf = render_buffer_with_size(&palette, 40, 4);
    let lines = buf.as_plain_lines();
    assert_eq!(lines.len(), 4);
    assert!(
        lines
            .iter()
            .any(|line| line.contains("Search for commands"))
    );
}