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, ConsoleOptions, Segment, Segments, Style};
use std::sync::{
    Arc,
    atomic::{AtomicUsize, Ordering},
};
use textual::css::set_style_context;
use textual::event::MouseScrollEvent;
use textual::message::MessageEvent;
use textual::node_id_from_ffi;
use textual::prelude::*;
use textual::render::FrameBuffer;

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
}

struct SpyWidget {
    dismiss_messages: Arc<AtomicUsize>,
    mouse_moves: Arc<AtomicUsize>,
    mouse_scrolls: Arc<AtomicUsize>,
}

impl SpyWidget {
    fn new(
        dismiss_messages: Arc<AtomicUsize>,
        mouse_moves: Arc<AtomicUsize>,
        mouse_scrolls: Arc<AtomicUsize>,
    ) -> Self {
        Self {
            dismiss_messages,
            mouse_moves,
            mouse_scrolls,
        }
    }
}

impl Widget for SpyWidget {
    fn render(&self, _console: &Console, _options: &ConsoleOptions) -> Segments {
        vec![Segment::styled("spy", Style::new())].into()
    }

    fn on_message(&mut self, message: &MessageEvent, _ctx: &mut EventCtx) {
        if matches!(message.message, Message::OverlayDismissRequested(..)) {
            self.dismiss_messages.fetch_add(1, Ordering::Relaxed);
        }
    }

    fn on_mouse_move(&mut self, _x: u16, _y: u16) -> bool {
        self.mouse_moves.fetch_add(1, Ordering::Relaxed);
        true
    }

    fn on_mouse_scroll(&mut self, _delta_x: i32, _delta_y: i32, _ctx: &mut EventCtx) {
        self.mouse_scrolls.fetch_add(1, Ordering::Relaxed);
    }
}

#[test]
fn tooltip_renders_overlay_text_when_visible() {
    let console = Console::new();
    let options = options_for(&console, 30, 6);
    let tooltip = Tooltip::new(Label::new("base content"), "Tooltip text").visible(true);

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

    assert!(lines.iter().any(|line| line.contains("Tooltip text")));
    assert!(lines.iter().any(|line| line.contains("base content")));
}

#[test]
fn tooltip_hides_on_escape_key() {
    let console = Console::new();
    let options = options_for(&console, 30, 6);
    let mut tooltip = Tooltip::new(Label::new("base"), "tip").visible(true);

    let key = KeyEventData::from_crossterm(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
    tooltip.on_event(&Event::Key(key), &mut EventCtx::default());

    let buf = FrameBuffer::from_renderable(&console, &options, &tooltip, None);
    let lines = buf.as_plain_lines();
    assert!(lines.iter().all(|line| !line.contains("tip")));
    assert!(lines.iter().any(|line| line.contains("base")));
}

#[test]
fn tooltip_visibility_can_be_driven_via_overlay_messages() {
    let console = Console::new();
    let options = options_for(&console, 30, 6);
    let mut tooltip = Tooltip::new(Label::new("base"), "tip").visible(true);

    tooltip.on_message(
        &MessageEvent {
            sender: NodeId::default(),
            message: Message::OverlaySetVisible(OverlaySetVisible {
                overlay: NodeId::default(),
                visible: false,
            }),
            control: None,
        },
        &mut EventCtx::default(),
    );

    let buf = FrameBuffer::from_renderable(&console, &options, &tooltip, None);
    let lines = buf.as_plain_lines();
    assert!(lines.iter().all(|line| !line.contains("tip")));
}

#[test]
fn tooltip_positions_above_anchor_when_bottom_space_is_insufficient() {
    let console = Console::new();
    let options = options_for(&console, 28, 6);
    let tooltip = Tooltip::new(Label::new("base"), "anchored")
        .visible(true)
        .with_anchor(14, 5);

    let buf = FrameBuffer::from_renderable(&console, &options, &tooltip, None);
    let lines = buf.as_plain_lines();
    let line_idx = lines
        .iter()
        .position(|line| line.contains("anchored"))
        .expect("tooltip line");

    assert_eq!(line_idx, 2);
}

#[test]
fn tooltip_clamps_horizontally_when_anchor_is_left_of_viewport() {
    let console = Console::new();
    let options = options_for(&console, 20, 6);
    let tooltip = Tooltip::new(Label::new("base"), "left-edge")
        .visible(true)
        .with_anchor(0, 1);

    let buf = FrameBuffer::from_renderable(&console, &options, &tooltip, None);
    let lines = buf.as_plain_lines();
    let line = lines
        .iter()
        .find(|line| line.contains("left-edge"))
        .expect("tooltip line");
    let x = line.find("left-edge").expect("x position");

    assert_eq!(x, 2);
}

#[test]
fn tooltip_updates_anchor_from_runtime_mouse_events() {
    let console = Console::new();
    let options = options_for(&console, 30, 8);
    let mut tooltip = Tooltip::new(Label::new("base"), "tip")
        .visible(true)
        .with_anchor(2, 0);

    let before = FrameBuffer::from_renderable(&console, &options, &tooltip, None);
    let before_lines = before.as_plain_lines();
    let before_line = before_lines
        .iter()
        .find(|line| line.contains("tip"))
        .expect("tip line before");
    let before_x = before_line.find("tip").expect("tip x before");

    let target = tooltip.anchor_target_id();
    tooltip.on_event(
        &Event::MouseScroll(MouseScrollEvent {
            target: Some(target),
            screen_x: 22,
            screen_y: 1,
            x: 22,
            y: 1,
            delta_x: 0,
            delta_y: 1,
            modifiers: KeyModifiers::empty(),
        }),
        &mut EventCtx::default(),
    );

    let after = FrameBuffer::from_renderable(&console, &options, &tooltip, None);
    let after_lines = after.as_plain_lines();
    let after_line = after_lines
        .iter()
        .find(|line| line.contains("tip"))
        .expect("tip line after");
    let after_x = after_line.find("tip").expect("tip x after");

    assert!(after_x > before_x);
}

#[test]
fn tooltip_anchor_can_be_driven_by_overlay_anchor_messages() {
    let console = Console::new();
    let options = options_for(&console, 30, 8);
    let mut tooltip = Tooltip::new(Label::new("base"), "tip")
        .visible(true)
        .with_anchor(2, 0);

    tooltip.on_message(
        &MessageEvent {
            sender: NodeId::default(),
            message: Message::OverlaySetAnchor(OverlaySetAnchor {
                overlay: NodeId::default(),
                x: 22,
                y: 1,
            }),
            control: None,
        },
        &mut EventCtx::default(),
    );

    let after_set = FrameBuffer::from_renderable(&console, &options, &tooltip, None);
    let set_lines = after_set.as_plain_lines();
    let set_line = set_lines
        .iter()
        .find(|line| line.contains("tip"))
        .expect("tip line after set-anchor");
    let set_x = set_line.find("tip").expect("tip x after set-anchor");

    tooltip.on_message(
        &MessageEvent {
            sender: NodeId::default(),
            message: Message::OverlayClearAnchor(OverlayClearAnchor {
                overlay: NodeId::default(),
            }),
            control: None,
        },
        &mut EventCtx::default(),
    );

    let after_clear = FrameBuffer::from_renderable(&console, &options, &tooltip, None);
    let clear_lines = after_clear.as_plain_lines();
    let clear_line = clear_lines
        .iter()
        .find(|line| line.contains("tip"))
        .expect("tip line after clear-anchor");
    let clear_x = clear_line.find("tip").expect("tip x after clear-anchor");

    assert!(set_x > clear_x);
}

#[test]
fn tooltip_forwards_non_matching_overlay_dismiss_to_child() {
    let dismiss_messages = Arc::new(AtomicUsize::new(0));
    let mouse_moves = Arc::new(AtomicUsize::new(0));
    let mouse_scrolls = Arc::new(AtomicUsize::new(0));
    let spy = SpyWidget::new(Arc::clone(&dismiss_messages), mouse_moves, mouse_scrolls);
    let mut tooltip = Tooltip::new(spy, "tip").visible(true);

    tooltip.on_message(
        &MessageEvent {
            sender: NodeId::default(),
            message: Message::OverlayDismissRequested(OverlayDismissRequested {
                overlay: Some(node_id_from_ffi(999)),
            }),
            control: None,
        },
        &mut EventCtx::default(),
    );

    assert_eq!(dismiss_messages.load(Ordering::Relaxed), 1);
}

#[test]
fn tooltip_delegates_mouse_hooks_to_child() {
    let dismiss_messages = Arc::new(AtomicUsize::new(0));
    let mouse_moves = Arc::new(AtomicUsize::new(0));
    let mouse_scrolls = Arc::new(AtomicUsize::new(0));
    let spy = SpyWidget::new(
        dismiss_messages,
        Arc::clone(&mouse_moves),
        Arc::clone(&mouse_scrolls),
    );
    let mut tooltip = Tooltip::new(spy, "tip").visible(true);

    assert!(tooltip.on_mouse_move(3, 2));
    let mut ctx = EventCtx::default();
    tooltip.on_mouse_scroll(0, 1, &mut ctx);

    assert_eq!(mouse_moves.load(Ordering::Relaxed), 1);
    assert_eq!(mouse_scrolls.load(Ordering::Relaxed), 1);
}

#[test]
fn tooltip_unmount_resets_visibility_and_anchor_state() {
    let console = Console::new();
    let options = options_for(&console, 30, 8);
    let mut tooltip = Tooltip::new(Label::new("base"), "tip")
        .visible(true)
        .with_anchor(22, 1);

    let before = FrameBuffer::from_renderable(&console, &options, &tooltip, None);
    let before_lines = before.as_plain_lines();
    let before_line = before_lines
        .iter()
        .find(|line| line.contains("tip"))
        .expect("tip line before unmount");
    let anchored_x = before_line.find("tip").expect("anchored x");

    tooltip.on_unmount();
    tooltip.on_message(
        &MessageEvent {
            sender: NodeId::default(),
            message: Message::OverlaySetVisible(OverlaySetVisible {
                overlay: NodeId::default(),
                visible: true,
            }),
            control: None,
        },
        &mut EventCtx::default(),
    );

    let after = FrameBuffer::from_renderable(&console, &options, &tooltip, None);
    let after_lines = after.as_plain_lines();
    let after_line = after_lines
        .iter()
        .find(|line| line.contains("tip"))
        .expect("tip line after remount lifecycle");
    let reset_x = after_line.find("tip").expect("reset x");

    assert!(anchored_x > reset_x);
}

#[test]
fn tooltip_accepts_vkey_border_styles_from_css() {
    let console = Console::new();
    let options = options_for(&console, 20, 4);
    let _guard = set_style_context(StyleSheet::parse(
        "Tooltip { border-left: vkey $foreground; }",
    ));
    let tooltip = Tooltip::new(Label::new("base"), "tip").visible(true);
    let renderable = WidgetRenderable::new(&tooltip);
    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}')),
        "expected vkey left border glyph on tooltip, got {lines:?}"
    );
}