aven 0.1.7

Local-first task manager CLI and sync server
Documentation
use std::borrow::Cow;

use ratatui::style::Style;
use ratatui::text::{Line, Span};

use crate::tui::text::char_boundary_at_or_before;
use crate::tui::theme::{BG_ALT, FG};

pub(in crate::tui::ui) fn input_line(
    prefix: &'static str,
    input: &str,
    cursor: usize,
) -> Line<'static> {
    if prefix.is_empty() {
        return Line::from(input_cursor_spans(input, cursor, InputWidth::Full));
    }
    prefixed_input_line(Span::raw(prefix), input, cursor)
}

pub(in crate::tui::ui) fn prefixed_input_line(
    prefix: Span<'static>,
    input: &str,
    cursor: usize,
) -> Line<'static> {
    let mut spans = vec![prefix];
    spans.extend(input_cursor_spans(input, cursor, InputWidth::Full));
    Line::from(spans)
}

pub(in crate::tui::ui) fn clipped_input_line(
    input: &str,
    cursor: usize,
    width: usize,
) -> Line<'static> {
    Line::from(input_cursor_spans(
        input,
        cursor,
        InputWidth::Clipped(width),
    ))
}

#[derive(Clone, Copy)]
pub(in crate::tui::ui) enum InputWidth {
    Full,
    Clipped(usize),
}

pub(in crate::tui::ui) fn input_cursor_spans(
    input: &str,
    cursor: usize,
    width: InputWidth,
) -> Vec<Span<'static>> {
    let cursor = char_boundary_at_or_before(input, cursor);
    let input_chars = input.chars().count();
    let max_width = match width {
        InputWidth::Full => input_chars.saturating_add(1),
        InputWidth::Clipped(width) => width,
    };
    let Some(cursor_char) = input[cursor..].chars().next() else {
        let before = input
            .chars()
            .skip(input_chars.saturating_sub(max_width.saturating_sub(1)))
            .collect::<String>();
        return vec![Span::raw(before), cursor_cell(" ")];
    };
    let cursor_end = cursor + cursor_char.len_utf8();
    let before = &input[..cursor];
    let after = &input[cursor_end..];
    let after_chars = after.chars().count();
    let value_width = input_chars.saturating_add(1).min(max_width);
    let before_visible = value_width.saturating_sub(1 + after_chars);
    let before = before
        .chars()
        .skip(before.chars().count().saturating_sub(before_visible))
        .collect::<String>();
    vec![
        Span::raw(before),
        cursor_cell(cursor_char.to_string()),
        Span::raw(
            after
                .chars()
                .take(max_width.saturating_sub(1))
                .collect::<String>(),
        ),
    ]
}

pub(in crate::tui::ui) fn cursor_cell(content: impl Into<Cow<'static, str>>) -> Span<'static> {
    Span::styled(content, Style::new().fg(BG_ALT).bg(FG))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn cursor_cell_has_correct_style() {
        let span = cursor_cell("a");
        assert_eq!(span.content.as_ref(), "a");
        assert_eq!(span.style.fg, Some(BG_ALT));
        assert_eq!(span.style.bg, Some(FG));
    }

    #[test]
    fn input_line_draws_cursor_as_cell() {
        let line = input_line("", "abc", 1);
        assert_eq!(line.spans[0].content.as_ref(), "a");
        assert_eq!(line.spans[1].content.as_ref(), "b");
        assert_eq!(line.spans[1].style.fg, Some(BG_ALT));
        assert_eq!(line.spans[1].style.bg, Some(FG));
        assert_eq!(line.spans[2].content.as_ref(), "c");
    }

    #[test]
    fn input_cursor_spans_draws_end_cursor_as_blank_cell() {
        let spans = input_cursor_spans("abc", 3, InputWidth::Full);
        assert_eq!(spans[0].content.as_ref(), "abc");
        assert_eq!(spans[1].content.as_ref(), " ");
        assert_eq!(spans[1].style.bg, Some(FG));
    }

    #[test]
    fn clipped_input_line_scrolls_to_cursor_cell() {
        let line = clipped_input_line("abcdef", 5, 4);
        assert_eq!(line.spans[0].content.as_ref(), "cde");
        assert_eq!(line.spans[1].content.as_ref(), "f");
    }

    #[test]
    fn input_cursor_handles_byte_indexed_unicode_cursor() {
        let line = input_line("", "aéz", 3);
        assert_eq!(line.spans[0].content.as_ref(), "");
        assert_eq!(line.spans[1].content.as_ref(), "z");
        assert_eq!(line.spans[1].style.bg, Some(FG));
    }

    #[test]
    fn prefixed_input_line_preserves_prefix() {
        let line = prefixed_input_line(Span::raw("/"), "abc", 1);
        assert_eq!(line.spans[0].content.as_ref(), "/");
        assert_eq!(line.spans[1].content.as_ref(), "a");
        assert_eq!(line.spans[2].content.as_ref(), "b");
    }
}