ansiq-runtime 0.1.0

Application loop, focus routing, subtree replacement, and redraw orchestration for Ansiq.
Documentation
use ansiq_core::{ElementKind, Node, WidgetKey, WidgetRouteContext};
use ansiq_surface::Key;
use unicode_width::UnicodeWidthChar;

use crate::FocusState;

#[derive(Debug, PartialEq, Eq)]
pub struct RouteEffect<Message> {
    pub handled: bool,
    pub dirty: bool,
    pub message: Option<Message>,
    pub quit: bool,
}

impl<Message> Default for RouteEffect<Message> {
    fn default() -> Self {
        Self {
            handled: false,
            dirty: false,
            message: None,
            quit: false,
        }
    }
}

pub fn handle_key<Message>(
    tree: &mut Node<Message>,
    focus: &mut FocusState,
    key: Key,
) -> RouteEffect<Message> {
    if matches!(key, Key::CtrlC) {
        return RouteEffect {
            handled: true,
            quit: true,
            ..RouteEffect::default()
        };
    }

    if let Some(id) = focus.current() {
        if let Some(node) = find_node_mut(tree, id) {
            let effect = route_to_node(node, key);
            if effect.handled || effect.dirty || effect.message.is_some() || effect.quit {
                return effect;
            }
        }
    }

    match key {
        Key::Tab | Key::Down | Key::Char('j') => {
            focus.next();
            return RouteEffect {
                handled: true,
                ..RouteEffect::default()
            };
        }
        Key::BackTab | Key::Up | Key::Char('k') => {
            focus.prev();
            return RouteEffect {
                handled: true,
                ..RouteEffect::default()
            };
        }
        _ => {}
    }

    RouteEffect::default()
}

fn route_to_node<Message>(node: &mut Node<Message>, key: Key) -> RouteEffect<Message> {
    let Some(widget_key) = map_widget_key(key) else {
        return RouteEffect::default();
    };

    node.element
        .kind
        .route_widget_key(
            widget_key,
            WidgetRouteContext {
                viewport_height: node.rect.height as usize,
                scroll_view_max_offset: scroll_view_max_offset(node),
            },
        )
        .map(|effect| RouteEffect {
            handled: true,
            dirty: effect.dirty,
            message: effect.message,
            quit: false,
        })
        .unwrap_or_default()
}

fn find_node_mut<Message>(node: &mut Node<Message>, id: usize) -> Option<&mut Node<Message>> {
    if node.id == id {
        return Some(node);
    }

    for child in &mut node.children {
        if let Some(found) = find_node_mut(child, id) {
            return Some(found);
        }
    }

    None
}

fn scroll_view_max_offset<Message>(node: &Node<Message>) -> Option<usize> {
    let child = node.children.first()?;
    let (content, _) = scroll_text_content(child)?;
    let lines = wrap_lines(&content, node.rect.width);
    Some(lines.len().saturating_sub(node.rect.height as usize))
}

fn map_widget_key(key: Key) -> Option<WidgetKey> {
    match key {
        Key::Up => Some(WidgetKey::Up),
        Key::Down => Some(WidgetKey::Down),
        Key::Left => Some(WidgetKey::Left),
        Key::Right => Some(WidgetKey::Right),
        Key::Esc => Some(WidgetKey::Escape),
        Key::Enter => Some(WidgetKey::Enter),
        Key::Backspace => Some(WidgetKey::Backspace),
        Key::Char(ch) => Some(WidgetKey::Char(ch)),
        _ => None,
    }
}

fn scroll_text_content<Message>(node: &Node<Message>) -> Option<(String, ())> {
    match &node.element.kind {
        ElementKind::StreamingText(props) => Some((props.content.clone(), ())),
        ElementKind::Text(props) => Some((props.content.clone(), ())),
        ElementKind::Paragraph(props) => Some((props.content.plain(), ())),
        _ => None,
    }
}

fn wrap_lines(content: &str, width: u16) -> Vec<String> {
    if width == 0 {
        return Vec::new();
    }

    let mut lines = Vec::new();

    for raw_line in content.split('\n') {
        if raw_line.is_empty() {
            lines.push(String::new());
            continue;
        }

        let mut current = String::new();
        let mut current_width = 0u16;

        for ch in raw_line.chars() {
            let char_width = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
            if current_width.saturating_add(char_width) > width && !current.is_empty() {
                lines.push(current);
                current = String::new();
                current_width = 0;
            }

            current.push(ch);
            current_width = current_width.saturating_add(char_width.max(1));
        }

        lines.push(current);
    }

    if lines.is_empty() {
        lines.push(String::new());
    }

    lines
}