use ratatui::Frame;
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::{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(&[
("^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(&[("^S", "parse"), ("Enter", "newline"), ("Esc", "cancel")])
}
pub(in crate::tui::ui) fn render_add_note_input(frame: &mut Frame, state: &MultilineInputView) {
render_tail_viewport_multiline(frame, state, 60, multiline_hint_line(), add_note_input_line);
}
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 content_rows = state.lines.len().min(visible_rows).max(1);
let height = (content_rows as u16).saturating_add(4).min(13);
let start = tail_viewport_start(state.row, visible_rows);
let mut lines = Vec::new();
for (row_index, line) in state
.lines
.iter()
.enumerate()
.skip(start)
.take(visible_rows)
{
lines.push(make_line(
line,
if row_index == state.row {
Some(state.column)
} else {
None
},
));
}
lines.push(Line::from(""));
lines.push(hint_line);
Dialog::new(&state.title, width, height)
.wrap()
.render_text(frame, Text::from(lines));
}
pub(in crate::tui::ui) fn add_note_input_line(line: &str, cursor: Option<usize>) -> Line<'static> {
if line.is_empty() && cursor.is_some() {
return Line::from(vec![
super::super::input::cursor_cell("n"),
Span::styled("ote body", 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+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+S", "submit"),
("Ctrl+X Ctrl+E", "editor"),
("Esc", "cancel"),
]);
line.spans
.push(Span::styled(position, Style::new().fg(FG_MUTED)));
line
}