aven 0.1.11

Local-first task manager CLI and sync server
Documentation
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::Style;
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::Paragraph;

use super::super::dialog::{Dialog, dialog_hint_line};
use super::super::input::{InputWidth, input_cursor_spans, input_line};
use super::shared::{tail_viewport_start, viewport_start_for_cursor};
use crate::tui::overlay::{MultilineInputView, OverlayRoute};
use crate::tui::text::{
    cell_width_ranges, char_boundary_at_or_before, char_count_ranges, char_count_segment_index,
};
use crate::tui::theme::{FG, FG_DIM, FG_MUTED};

pub(in crate::tui::ui) fn render_multiline_input(frame: &mut Frame, state: &MultilineInputView) {
    match state.route {
        OverlayRoute::AddNote => {
            render_add_note_input(frame, state);
            return;
        }
        OverlayRoute::EditDescription => {
            render_description_input(frame, state);
            return;
        }
        OverlayRoute::AddTaskDescription => {
            render_add_task_description_input(frame, state);
            return;
        }
        OverlayRoute::AddTaskNatural => {
            render_add_task_natural_input(frame, state);
            return;
        }
        OverlayRoute::ConflictManual => {
            render_conflict_manual_input(frame, state);
            return;
        }
        _ => {}
    }

    let visible_rows = 10usize;
    let content_rows = state.lines.len().min(visible_rows).max(1);
    let prompt_rows = usize::from(!state.prompt.is_empty());
    let height = (content_rows + prompt_rows + 4).min(16) as u16;
    let start = tail_viewport_start(state.row, visible_rows);
    let mut lines = Vec::new();
    if !state.prompt.is_empty() {
        lines.push(Line::from(Span::styled(
            &state.prompt,
            Style::new().fg(FG_DIM),
        )));
    }
    for (row_index, line) in state
        .lines
        .iter()
        .enumerate()
        .skip(start)
        .take(visible_rows)
    {
        if row_index == state.row {
            lines.push(input_line("", line, state.column));
        } else {
            lines.push(Line::from(line.clone()));
        }
    }
    lines.push(Line::from(""));
    lines.push(multiline_hint_line());
    Dialog::new(&state.title, 60, height)
        .wrap()
        .render_text(frame, Text::from(lines));
}

fn render_description_input(frame: &mut Frame, state: &MultilineInputView) {
    let frame_area = frame.area();
    let max_height = frame_area.height.saturating_mul(4).saturating_div(5);
    let width = description_editor_width(frame_area.width);
    let line_width = width.saturating_sub(4).max(1) as usize;
    let max_editor_rows = max_height.saturating_sub(4).max(1) as usize;
    let editor_rows = description_visual_row_count(state, line_width).clamp(4, max_editor_rows);
    let height = editor_rows.saturating_add(4) as u16;
    let dialog = Dialog::new(&state.title, width, height);
    let content = dialog.render_block(frame);
    let editor_rows = content.height.saturating_sub(2).max(1) as usize;
    let line_width = content.width.max(1) as usize;
    let (mut lines, cursor_row) = description_editor_lines(state, line_width);
    let start = viewport_start_for_cursor(cursor_row, editor_rows, lines.len(), true);
    lines = lines.into_iter().skip(start).take(editor_rows).collect();
    while lines.len() < editor_rows {
        lines.push(Line::from(""));
    }
    lines.push(Line::from(""));
    lines.push(description_hint_line(state));

    frame.render_widget(
        Paragraph::new(Text::from(lines)).style(Style::new().fg(FG).bg(crate::tui::theme::BG_ALT)),
        content,
    );
}

fn render_add_task_description_input(frame: &mut Frame, state: &MultilineInputView) {
    render_add_task_free_text_input(
        frame,
        state,
        "Optional details, links, or handoff context...",
        add_task_description_hint_line(),
    );
}

fn render_add_task_natural_input(frame: &mut Frame, state: &MultilineInputView) {
    render_add_task_free_text_input(
        frame,
        state,
        "Describe the task in natural language...",
        add_task_natural_hint_line(),
    );
}

fn render_conflict_manual_input(frame: &mut Frame, state: &MultilineInputView) {
    render_add_task_free_text_input(
        frame,
        state,
        CONFLICT_MANUAL_BODY_PLACEHOLDER,
        multiline_hint_line(),
    );
}

fn render_add_task_free_text_input(
    frame: &mut Frame,
    state: &MultilineInputView,
    placeholder: &'static str,
    hint_line: Line<'static>,
) {
    render_tail_viewport_multiline(frame, state, 70, hint_line, |line, cursor| {
        add_task_free_text_input_line(
            line,
            cursor,
            line.is_empty() && state.lines.len() == 1,
            placeholder,
        )
    });
}

pub(in crate::tui::ui) fn description_editor_width(frame_width: u16) -> u16 {
    let max_width = frame_width.saturating_sub(4).max(1);
    frame_width
        .saturating_mul(4)
        .saturating_div(5)
        .max(80)
        .min(max_width)
}

pub(in crate::tui::ui) fn description_visual_row_count(
    state: &MultilineInputView,
    line_width: usize,
) -> usize {
    state
        .lines
        .iter()
        .map(|line| char_count_ranges(line, line_width).len())
        .sum::<usize>()
        .max(1)
}

pub(in crate::tui::ui) fn description_editor_lines(
    state: &MultilineInputView,
    line_width: usize,
) -> (Vec<Line<'static>>, usize) {
    let mut lines = Vec::new();
    let mut cursor_row = 0;
    let show_placeholder = state.lines.len() == 1 && state.lines[0].is_empty();
    for (row_index, line) in state.lines.iter().enumerate() {
        let ranges = char_count_ranges(line, line_width);
        if row_index == state.row {
            let cursor = char_boundary_at_or_before(line, state.column);
            let cursor_segment = char_count_segment_index(line, cursor, line_width);
            cursor_row = lines.len().saturating_add(cursor_segment);
            for (range_index, (start, end)) in ranges.into_iter().enumerate() {
                if range_index == cursor_segment {
                    lines.push(description_input_line(
                        &line[start..end],
                        cursor - start,
                        show_placeholder,
                    ));
                } else {
                    lines.push(Line::from(line[start..end].to_string()));
                }
            }
        } else {
            for (start, end) in ranges {
                lines.push(Line::from(line[start..end].to_string()));
            }
        }
    }
    (lines, cursor_row)
}

pub(in crate::tui::ui) fn description_input_line(
    line: &str,
    cursor: usize,
    show_placeholder: bool,
) -> Line<'static> {
    if show_placeholder && line.is_empty() && cursor == 0 {
        return Line::from(vec![
            super::super::input::cursor_cell("E"),
            Span::styled("nter task description here...", Style::new().fg(FG_DIM)),
        ]);
    }
    input_line("", line, cursor)
}

pub(in crate::tui::ui) fn add_task_description_input_line(
    line: &str,
    cursor: Option<usize>,
    show_placeholder: bool,
) -> Line<'static> {
    add_task_free_text_input_line(
        line,
        cursor,
        show_placeholder,
        "Optional details, links, or handoff context...",
    )
}

pub(in crate::tui::ui) fn add_task_free_text_input_line(
    line: &str,
    cursor: Option<usize>,
    show_placeholder: bool,
    placeholder: &'static str,
) -> Line<'static> {
    if show_placeholder {
        if cursor.is_some() {
            return Line::from(vec![
                super::super::input::cursor_cell(&placeholder[..1]),
                Span::styled(&placeholder[1..], Style::new().fg(FG_DIM)),
            ]);
        }
        return Line::from(Span::styled(placeholder, Style::new().fg(FG_DIM)));
    }
    match cursor {
        Some(cursor) => Line::from(input_cursor_spans(line, cursor, InputWidth::Full)),
        None => Line::from(line.to_string()),
    }
}

pub(in crate::tui::ui) const CONFLICT_MANUAL_BODY_PLACEHOLDER: &str = "Enter manual value here...";

pub(in crate::tui::ui) fn add_task_description_hint_line() -> Line<'static> {
    dialog_hint_line(&[
        ("Ctrl-Enter / ^S", "create"),
        ("Enter", "newline"),
        ("^P", "project"),
        ("^R", "priority"),
        ("Esc", "cancel"),
    ])
}

pub(in crate::tui::ui) fn add_task_natural_hint_line() -> Line<'static> {
    dialog_hint_line(&[
        ("Ctrl-Enter / ^S", "parse"),
        ("Enter", "newline"),
        ("Esc", "cancel"),
    ])
}

pub(in crate::tui::ui) fn render_add_note_input(frame: &mut Frame, state: &MultilineInputView) {
    let show_placeholder = state.lines.len() == 1 && state.lines[0].is_empty();
    render_tail_viewport_multiline(frame, state, 60, multiline_hint_line(), |line, cursor| {
        add_note_input_line(line, cursor, show_placeholder)
    });
}

fn render_tail_viewport_multiline(
    frame: &mut Frame,
    state: &MultilineInputView,
    width: u16,
    hint_line: Line<'static>,
    mut make_line: impl FnMut(&str, Option<usize>) -> Line<'static>,
) {
    let visible_rows = 8usize;
    let start = tail_viewport_start(state.row, visible_rows);
    let line_width = width
        .min(frame.area().width.saturating_sub(2))
        .saturating_sub(4)
        .max(1) as usize;
    let mut lines = Vec::new();
    let mut cursor_row = 0;
    for (row_index, line) in state
        .lines
        .iter()
        .enumerate()
        .skip(start)
        .take(visible_rows)
    {
        let ranges = cell_width_ranges(line, line_width);
        let cursor =
            (row_index == state.row).then(|| char_boundary_at_or_before(line, state.column));
        let cursor_segment = cursor.and_then(|cursor| {
            ranges
                .iter()
                .position(|(range_start, range_end)| {
                    cursor < *range_end || (*range_start == *range_end && cursor == *range_start)
                })
                .or_else(|| ranges.len().checked_sub(1))
        });
        for (range_index, (range_start, range_end)) in ranges.into_iter().enumerate() {
            let segment_cursor = cursor
                .filter(|_| cursor_segment == Some(range_index))
                .map(|cursor| cursor.saturating_sub(range_start));
            if segment_cursor.is_some() {
                cursor_row = lines.len();
            }
            lines.push(make_line(&line[range_start..range_end], segment_cursor));
        }
    }

    let visual_rows = lines.len().max(1);
    let height = (visual_rows.min(9) as u16).saturating_add(4);
    let content = Dialog::new(&state.title, width, height).render_block(frame);
    let [editor, _, hints] = Layout::vertical([
        Constraint::Fill(1),
        Constraint::Length(1),
        Constraint::Length(1),
    ])
    .areas(content);
    let scroll =
        viewport_start_for_cursor(cursor_row, editor.height.max(1) as usize, visual_rows, true);
    frame.render_widget(
        Paragraph::new(Text::from(lines))
            .style(Style::new().fg(FG).bg(crate::tui::theme::BG_ALT))
            .scroll((scroll as u16, 0)),
        editor,
    );
    frame.render_widget(Paragraph::new(hint_line), hints);
}

pub(in crate::tui::ui) fn add_note_input_line(
    line: &str,
    cursor: Option<usize>,
    show_placeholder: bool,
) -> Line<'static> {
    if show_placeholder && line.is_empty() && cursor.is_some() {
        return Line::from(vec![
            super::super::input::cursor_cell("E"),
            Span::styled("nter note body here...", Style::new().fg(FG_DIM)),
        ]);
    }
    match cursor {
        Some(cursor) => Line::from(input_cursor_spans(line, cursor, InputWidth::Full)),
        None => Line::from(line.to_string()),
    }
}

pub(in crate::tui::ui) fn multiline_hint_line() -> Line<'static> {
    dialog_hint_line(&[("Ctrl-Enter / ^S", "submit"), ("Esc", "cancel")])
}

pub(in crate::tui::ui) fn description_hint_line(state: &MultilineInputView) -> Line<'static> {
    let position = format!(
        "  line {}/{}",
        state.row.saturating_add(1),
        state.lines.len().max(1)
    );
    let mut line = dialog_hint_line(&[
        ("Ctrl-Enter / ^S", "submit"),
        ("Ctrl+X Ctrl+E", "editor"),
        ("Esc", "cancel"),
    ]);
    line.spans
        .push(Span::styled(position, Style::new().fg(FG_MUTED)));
    line
}