ansiq-core 0.1.0

Core reactive primitives, element contracts, styles, and shared runtime-facing types for Ansiq.
Documentation
use std::sync::{Arc, Mutex};

use ansiq_core::{
    Alignment, BlockProps, Borders, BoxProps, ChildLayoutKind, Color, Element, ElementKind,
    InputProps, IntoElement, Layout, Length, ListProps, ListState, Padding, PaneProps, Rect,
    ScrollDirection, ScrollViewProps, ScrollbarState, ShellProps, TextProps, TitlePosition,
    WidgetKey, WidgetRouteContext,
};

#[test]
fn new_element_uses_default_layout_and_no_children() {
    let element: Element<()> = Element::new(ElementKind::Text(TextProps {
        content: "ansiq".into(),
    }));

    assert_eq!(
        element.layout,
        Layout {
            width: Length::Fill,
            height: Length::Auto,
        }
    );
    assert!(element.children.is_empty());
    assert!(!element.focusable);
}

#[test]
fn text_helper_builds_text_element() {
    let element = Element::<()>::new_text("hello");

    assert_eq!(element.kind_name(), "Text");
}

#[test]
fn into_element_trait_passes_existing_elements_through() {
    let original = Element::<()>::new_text("hello");
    let converted = original.into_element();

    assert_eq!(converted.kind_name(), "Text");
}

#[test]
fn list_state_matches_ratatui_selection_semantics() {
    let mut state = ListState::default().with_offset(3).with_selected(Some(4));
    assert_eq!(state.offset(), 3);
    assert_eq!(state.selected(), Some(4));

    state.select(None);
    assert_eq!(state.selected(), None);
    assert_eq!(state.offset(), 0);

    state.select_previous();
    assert_eq!(state.selected(), Some(usize::MAX));

    state.select_next();
    assert_eq!(state.selected(), Some(usize::MAX));

    state.select_first();
    assert_eq!(state.selected(), Some(0));

    state.select_last();
    assert_eq!(state.selected(), Some(usize::MAX));
}

#[test]
fn scrollbar_state_matches_ratatui_navigation_semantics() {
    let mut state = ScrollbarState::default()
        .with_content_length(10)
        .with_position(4)
        .with_viewport_content_length(3);

    assert_eq!(state.content_length_value(), 10);
    assert_eq!(state.get_position(), 4);
    assert_eq!(state.viewport_content_length_value(), 3);

    state.prev();
    assert_eq!(state.get_position(), 3);

    state.next();
    assert_eq!(state.get_position(), 4);

    state.scroll(ScrollDirection::Forward);
    assert_eq!(state.get_position(), 5);

    state.scroll(ScrollDirection::Backward);
    assert_eq!(state.get_position(), 4);

    state.first();
    assert_eq!(state.get_position(), 0);

    state.last();
    assert_eq!(state.get_position(), 9);
}

#[test]
fn block_child_layout_spec_uses_inner_rect_and_column_stack() {
    let block: Element<()> = Element::new(ElementKind::Block(BlockProps {
        titles: vec![ansiq_core::BlockTitle::new("Header")],
        title_alignment: Alignment::Left,
        title_position: TitlePosition::Top,
        borders: Borders::ALL,
        border_type: ansiq_core::BorderType::Plain,
        border_set: None,
        padding: Padding::all(1),
        border_style: Default::default(),
        title_style: Default::default(),
    }))
    .with_children(vec![Element::new_text("body")]);

    let spec = block.child_layout_spec(Rect::new(0, 0, 20, 10));

    assert_eq!(spec.bounds, Rect::new(2, 2, 16, 6));
    assert_eq!(
        spec.kind,
        ChildLayoutKind::Stack {
            direction: ansiq_core::Direction::Column,
            gap: 0,
        }
    );
}

#[test]
fn pane_child_layout_spec_uses_inner_rect_and_fill_semantics() {
    let pane: Element<()> = Element::new(ElementKind::Pane(PaneProps {
        title: Some("Output".into()),
    }))
    .with_children(vec![Element::new_text("stream")]);

    let spec = pane.child_layout_spec(Rect::new(0, 0, 12, 6));

    assert_eq!(spec.bounds, Rect::new(1, 1, 10, 4));
    assert_eq!(spec.kind, ChildLayoutKind::Fill);
}

#[test]
fn shell_child_layout_spec_uses_shell_strategy() {
    let shell: Element<()> = Element::new(ElementKind::Shell(ShellProps)).with_children(vec![
        Element::new_text("header"),
        Element::new_text("body"),
        Element::new_text("footer"),
    ]);

    let spec = shell.child_layout_spec(Rect::new(0, 0, 20, 10));

    assert_eq!(spec.bounds, Rect::new(0, 0, 20, 10));
    assert_eq!(spec.kind, ChildLayoutKind::Shell);
}

#[test]
fn scroll_view_and_component_child_layout_specs_fill_bounds() {
    let scroll: Element<()> = Element::new(ElementKind::ScrollView(ScrollViewProps {
        follow_bottom: false,
        offset: Some(3),
        on_scroll: None,
    }))
    .with_children(vec![Element::new_text("body")]);
    let scroll_spec = scroll.child_layout_spec(Rect::new(2, 3, 14, 7));
    assert_eq!(scroll_spec.bounds, Rect::new(2, 3, 14, 7));
    assert_eq!(scroll_spec.kind, ChildLayoutKind::Fill);

    let component = ansiq_core::component("Panel", || {
        Element::<()>::new(ElementKind::Box(BoxProps {
            direction: ansiq_core::Direction::Column,
            gap: 0,
        }))
        .with_children(vec![Element::new_text("body")])
    });
    let component_spec = component.child_layout_spec(Rect::new(1, 2, 9, 4));
    assert_eq!(component_spec.bounds, Rect::new(1, 2, 9, 4));
    assert_eq!(component_spec.kind, ChildLayoutKind::Fill);
}

#[test]
fn shell_intrinsic_height_sums_child_heights() {
    let shell: Element<()> = Element::new(ElementKind::Shell(ShellProps))
        .with_children(vec![Element::new_text("header"), Element::new_text("body")]);

    assert_eq!(shell.intrinsic_height(20, &[2, 5]), 7);
}

#[test]
fn block_intrinsic_height_adds_border_title_and_padding_to_children() {
    let block: Element<()> = Element::new(ElementKind::Block(BlockProps {
        titles: vec![ansiq_core::BlockTitle::new("Header")],
        title_alignment: Alignment::Left,
        title_position: TitlePosition::Top,
        borders: Borders::ALL,
        border_type: ansiq_core::BorderType::Plain,
        border_set: None,
        padding: Padding::all(1),
        border_style: Default::default(),
        title_style: Default::default(),
    }))
    .with_children(vec![Element::new_text("body")]);

    assert_eq!(block.intrinsic_height(20, &[3]), 7);
}

#[test]
fn scroll_view_intrinsic_height_tracks_only_the_first_child() {
    let scroll: Element<()> = Element::new(ElementKind::ScrollView(ScrollViewProps {
        follow_bottom: false,
        offset: Some(3),
        on_scroll: None,
    }))
    .with_children(vec![
        Element::new_text("body"),
        Element::new_text("ignored"),
    ]);

    assert_eq!(scroll.intrinsic_height(20, &[4, 9]), 4);
}

#[test]
fn layout_only_containers_only_invalidate_themselves_when_styled() {
    let plain_box: Element<()> = Element::new(ElementKind::Box(BoxProps {
        direction: ansiq_core::Direction::Column,
        gap: 0,
    }));
    assert!(!plain_box.invalidates_self_on_layout_change());

    let styled_box: Element<()> = Element::new(ElementKind::Box(BoxProps {
        direction: ansiq_core::Direction::Column,
        gap: 0,
    }))
    .with_style(Color::Blue.into());
    assert!(styled_box.invalidates_self_on_layout_change());

    let component = ansiq_core::component("Panel", || Element::<()>::new_text("body"));
    assert!(!component.invalidates_self_on_layout_change());
}

#[test]
fn list_route_widget_key_updates_selection_and_emits_message() {
    let mut kind = ElementKind::List(ListProps {
        block: None,
        items: vec!["alpha".into(), "beta".into(), "gamma".into()],
        state: ListState::default().with_selected(Some(0)),
        highlight_symbol: None,
        highlight_style: Default::default(),
        highlight_spacing: Default::default(),
        repeat_highlight_symbol: false,
        direction: Default::default(),
        scroll_padding: 0,
        on_select: Some(Box::new(|index| Some(index))),
    });

    let effect = kind
        .route_widget_key(
            WidgetKey::Down,
            WidgetRouteContext {
                viewport_height: 2,
                scroll_view_max_offset: None,
            },
        )
        .expect("list should handle down");

    assert!(effect.dirty);
    assert_eq!(effect.message, Some(1));
    match kind {
        ElementKind::List(props) => {
            assert_eq!(props.state.selected(), Some(1));
            assert_eq!(props.state.offset(), 0);
        }
        _ => panic!("expected list"),
    }
}

#[test]
fn input_route_widget_key_edits_value_and_cursor() {
    let changes = Arc::new(Mutex::new(Vec::new()));
    let mut kind = ElementKind::Input(InputProps {
        value: String::new(),
        placeholder: String::new(),
        on_change: Some(Box::new({
            let changes = Arc::clone(&changes);
            move |value| changes.lock().unwrap().push(value)
        })),
        on_submit: Some(Box::new(|value| Some(value))),
        cursor: 0,
    });

    let insert = kind
        .route_widget_key(WidgetKey::Char('h'), WidgetRouteContext::default())
        .expect("input should handle char");
    assert!(insert.dirty);

    let submit = kind
        .route_widget_key(WidgetKey::Enter, WidgetRouteContext::default())
        .expect("input should handle enter");
    assert_eq!(submit.message, Some("h".to_string()));

    match kind {
        ElementKind::Input(props) => {
            assert_eq!(props.value, "h");
            assert_eq!(props.cursor, 1);
        }
        _ => panic!("expected input"),
    }
    assert_eq!(&*changes.lock().unwrap(), &["h".to_string()]);
}

#[test]
fn scroll_view_route_widget_key_updates_offset_within_bounds() {
    let mut kind = ElementKind::ScrollView(ScrollViewProps {
        follow_bottom: false,
        offset: Some(1),
        on_scroll: Some(Box::new(|offset| Some(offset))),
    });

    let effect = kind
        .route_widget_key(
            WidgetKey::Down,
            WidgetRouteContext {
                viewport_height: 0,
                scroll_view_max_offset: Some(3),
            },
        )
        .expect("scroll view should handle down");

    assert!(effect.dirty);
    assert_eq!(effect.message, Some(2));
    match kind {
        ElementKind::ScrollView(props) => assert_eq!(props.offset, Some(2)),
        _ => panic!("expected scroll view"),
    }
}