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::message::MessageEvent;
use textual::node_id_from_ffi;
use textual::prelude::*;
use textual::render::FrameBuffer;
use textual::style::parse_color_like;

fn options_for(console: &Console, width: usize, height: usize) -> rich_rs::ConsoleOptions {
    let mut options = console.options().clone();
    options.size = (width, height);
    options.max_width = width;
    options.max_height = height;
    options
}

#[test]
fn help_panel_renders_key_bindings() {
    let console = Console::new();
    let options = options_for(&console, 36, 6);
    let panel = HelpPanel::new().with_bindings(vec![
        FooterBinding::new("^q", "Quit"),
        FooterBinding::new("tab", "Focus next"),
    ]);

    let buf = FrameBuffer::from_renderable(&console, &options, &panel, None);
    let lines = buf.as_plain_lines();

    assert!(lines.iter().any(|line| line.contains("^q")));
    assert!(lines.iter().any(|line| line.contains("Quit")));
}

#[test]
fn help_panel_renders_help_markdown_when_configured() {
    let console = Console::new();
    let options = options_for(&console, 40, 8);
    let panel = HelpPanel::new().with_help("## Widget help\nUse arrows to move.");

    let buf = FrameBuffer::from_renderable(&console, &options, &panel, None);
    let lines = buf.as_plain_lines();

    assert!(lines.iter().any(|line| line.contains("Widget help")));
}

#[test]
fn help_panel_updates_bindings_from_binding_hints_event() {
    let console = Console::new();
    let options = options_for(&console, 40, 7);
    let mut panel = HelpPanel::new();

    panel.on_event(
        &Event::BindingsChanged(vec![BindingHint::new("f1", "Toggle help")]),
        &mut EventCtx::default(),
    );

    let buf = FrameBuffer::from_renderable(&console, &options, &panel, None);
    let lines = buf.as_plain_lines();

    assert!(lines.iter().any(|line| line.contains("f1")));
    assert!(lines.iter().any(|line| line.contains("Toggle help")));
}

#[test]
fn help_panel_clear_help_hides_help_state() {
    let mut panel = HelpPanel::new().with_help("## Widget help\nUse arrows to move.");
    assert!(panel.showing_help());
    panel.clear_help();
    assert!(!panel.showing_help());
}

#[test]
fn help_panel_keeps_bindings_visible_with_help_in_short_layouts() {
    let console = Console::new();
    let options = options_for(&console, 40, 2);
    let panel = HelpPanel::new()
        .with_help("## Widget help")
        .with_bindings(vec![FooterBinding::new("^q", "Quit")]);

    let buf = FrameBuffer::from_renderable(&console, &options, &panel, None);
    let lines = buf.as_plain_lines();

    assert!(lines.iter().any(|line| line.contains("Widget help")));
    assert!(lines.iter().any(|line| line.contains("^q")));
}

#[test]
fn help_panel_hides_help_section_when_app_is_inactive() {
    let console = Console::new();
    let options = options_for(&console, 40, 8);
    let mut panel = HelpPanel::new()
        .with_help("## Widget help\nUse arrows to move.")
        .with_bindings(vec![FooterBinding::new("^q", "Quit")]);

    panel.on_event(&Event::AppFocus(false), &mut EventCtx::default());
    let inactive = FrameBuffer::from_renderable(&console, &options, &panel, None);
    let inactive_lines = inactive.as_plain_lines();
    assert!(
        inactive_lines
            .iter()
            .all(|line| !line.contains("Widget help"))
    );
    assert!(inactive_lines.iter().any(|line| line.contains("^q")));

    panel.on_event(&Event::AppFocus(true), &mut EventCtx::default());
    let active = FrameBuffer::from_renderable(&console, &options, &panel, None);
    let active_lines = active.as_plain_lines();
    assert!(active_lines.iter().any(|line| line.contains("Widget help")));
}

#[test]
fn help_panel_show_help_class_tracks_app_focus_state() {
    let mut panel = HelpPanel::new().with_help("## Widget help");
    assert!(
        panel
            .style_classes()
            .iter()
            .any(|class| class == "-show-help")
    );

    panel.on_event(&Event::AppFocus(false), &mut EventCtx::default());
    assert!(
        !panel
            .style_classes()
            .iter()
            .any(|class| class == "-show-help")
    );

    panel.on_event(&Event::AppFocus(true), &mut EventCtx::default());
    assert!(
        panel
            .style_classes()
            .iter()
            .any(|class| class == "-show-help")
    );
}

#[test]
fn help_panel_help_can_be_driven_via_messages() {
    let mut panel = HelpPanel::new();
    let mut ctx = EventCtx::default();
    panel.on_message(
        &MessageEvent {
            sender: NodeId::default(),
            message: Message::HelpPanelSetHelp(HelpPanelSetHelp {
                panel: NodeId::default(),
                markup: "## Runtime help".to_string(),
            }),
            control: None,
        },
        &mut ctx,
    );

    assert!(ctx.handled());
    assert!(panel.showing_help());
    assert_eq!(panel.help(), "## Runtime help");

    let mut clear_ctx = EventCtx::default();
    panel.on_message(
        &MessageEvent {
            sender: NodeId::default(),
            message: Message::HelpPanelClearHelp(HelpPanelClearHelp {
                panel: NodeId::default(),
            }),
            control: None,
        },
        &mut clear_ctx,
    );
    assert!(clear_ctx.handled());
    assert!(!panel.showing_help());
}

#[test]
fn help_panel_handles_focused_help_pipeline_messages() {
    let mut panel = HelpPanel::new();
    let mut set_ctx = EventCtx::default();
    panel.on_message(
        &MessageEvent {
            sender: node_id_from_ffi(100),
            message: Message::HelpPanelFocusedHelpChanged(HelpPanelFocusedHelpChanged {
                source: node_id_from_ffi(100),
                markup: "## Focused widget help".to_string(),
            }),
            control: None,
        },
        &mut set_ctx,
    );
    assert!(panel.showing_help());
    assert_eq!(panel.help(), "## Focused widget help");

    let mut clear_ctx = EventCtx::default();
    panel.on_message(
        &MessageEvent {
            sender: NodeId::default(),
            message: Message::HelpPanelFocusedHelpCleared(HelpPanelFocusedHelpCleared),
            control: None,
        },
        &mut clear_ctx,
    );
    assert!(!panel.showing_help());
    assert_eq!(panel.help(), "");
}

#[test]
fn help_panel_unmount_resets_app_focus_gate() {
    let mut panel = HelpPanel::new().with_help("## Widget help");

    panel.on_event(&Event::AppFocus(false), &mut EventCtx::default());
    assert!(
        !panel
            .style_classes()
            .iter()
            .any(|class| class == "-show-help")
    );

    panel.on_unmount();
    assert!(
        panel
            .style_classes()
            .iter()
            .any(|class| class == "-show-help")
    );
}

#[test]
fn help_panel_default_css_uses_vkey_border_glyphs() {
    let console = Console::new();
    let options = options_for(&console, 40, 6);
    let _guard = set_style_context(default_widget_stylesheet());
    let panel = HelpPanel::new().with_bindings(vec![FooterBinding::new("^q", "Quit")]);
    let renderable = WidgetRenderable::new(&panel);
    let buf = FrameBuffer::from_renderable(&console, &options, &renderable, None);
    let lines = buf.as_plain_lines();

    assert!(
        lines
            .iter()
            .any(|line| line.starts_with('\u{258f}') || line.ends_with('\u{2595}')),
        "expected vkey border glyphs in help panel output, got {lines:?}"
    );
}

#[test]
fn help_panel_border_color_composes_foreground_alpha_over_background() {
    let console = Console::new();
    let options = options_for(&console, 40, 6);
    let _guard = set_style_context(default_widget_stylesheet());
    let panel = HelpPanel::new().with_bindings(vec![FooterBinding::new("^q", "Quit")]);
    let renderable = WidgetRenderable::new(&panel);
    let buf = FrameBuffer::from_renderable(&console, &options, &renderable, None);

    let border_cell = buf.get(0, 0);
    let border_style = border_cell.style.expect("border style should exist");
    let actual = border_style.color.expect("border fg color should exist");

    let foreground = parse_color_like("$foreground")
        .expect("foreground token")
        .with_alpha(0.30);
    let background = parse_color_like("$background").expect("background token");
    let expected = foreground.flatten_over(background).to_simple_opaque();

    assert_eq!(actual, expected);
}