use crate::app_state::{AppState, NowFilter, TodoLayout, ViewMode};
use crate::help;
use crate::md_preview::format_elapsed;
use crate::todo::Item;
use crate::url::strip_urls;
use ratatui::{
layout::{Alignment, Constraint, Direction, Flex, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Tabs},
};
use std::time::SystemTime;
use unicode_width::UnicodeWidthChar;
const SELECTED_ICON: &str = "> ";
const PENDING_FG: Color = Color::Rgb(120, 120, 120);
const MD_META_FG: Color = Color::Rgb(170, 170, 170);
fn selected_icon_span() -> Span<'static> {
Span::styled(
SELECTED_ICON,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
}
pub fn create_todo_spans(todo: &Item) -> Vec<Span<'static>> {
let mut spans = Vec::new();
if todo.completed {
spans.push(Span::styled("✓ ", Style::default().fg(Color::Green)));
}
if let Some(priority) = todo.priority {
let color = match priority {
'A' => Color::Red,
'B' => Color::Yellow,
'C' => Color::Blue,
_ => Color::White,
};
spans.push(Span::styled(
format!("({priority}) "),
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
}
let (display_text, has_urls) = strip_urls(&todo.description);
if has_urls {
spans.push(Span::styled("🔗 ", Style::default().fg(Color::Blue)));
}
spans.push(Span::raw(display_text));
for context in &todo.contexts {
spans.push(Span::styled(
format!(" @{context}"),
Style::default().fg(Color::Cyan),
));
}
spans
}
pub fn get_todo_border_style(is_selected: bool, is_overdue: bool, is_dimmed: bool) -> Style {
if is_selected {
Style::default().fg(Color::Yellow)
} else if is_overdue {
Style::default().fg(Color::Red)
} else if is_dimmed {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(Color::White)
}
}
fn wrap_spans(spans: &[Span<'static>], max_width: usize) -> Vec<Line<'static>> {
if max_width == 0 {
return vec![Line::default()];
}
let mut lines: Vec<Line<'static>> = Vec::new();
let mut current: Vec<Span<'static>> = Vec::new();
let mut line_width: usize = 0;
for span in spans {
let mut chunk = String::new();
for ch in span.content.chars() {
let ch_width = ch.width().unwrap_or(0);
if line_width + ch_width > max_width && line_width > 0 {
if !chunk.is_empty() {
current.push(Span::styled(std::mem::take(&mut chunk), span.style));
}
lines.push(Line::from(std::mem::take(&mut current)));
line_width = 0;
}
chunk.push(ch);
line_width += ch_width;
}
if !chunk.is_empty() {
current.push(Span::styled(chunk, span.style));
}
}
lines.push(Line::from(current));
lines
}
fn meta_label(todo: &Item, now: SystemTime) -> Option<String> {
let meta = todo.md_meta.as_ref()?;
let elapsed = format_elapsed(meta.mtime, now);
Some(match meta.stats {
Some((done, total)) => format!("{done}/{total} {elapsed}"),
None => elapsed,
})
}
fn time_value_span(value: &str) -> Option<Span<'static>> {
let (letter, color) = match value {
"short" => ("S", Color::Green),
"medium" => ("M", Color::Yellow),
"long" => ("L", Color::Red),
_ => return None,
};
Some(Span::styled(
letter,
Style::default().fg(color).add_modifier(Modifier::BOLD),
))
}
fn energy_value_span(value: &str) -> Option<Span<'static>> {
let (glyph, color) = match value {
"low" => ("↓", Color::Green),
"high" => ("↑", Color::Red),
_ => return None,
};
Some(Span::styled(
glyph,
Style::default().fg(color).add_modifier(Modifier::BOLD),
))
}
fn time_chip_span(todo: &Item) -> Option<Span<'static>> {
time_value_span(todo.time_estimate()?)
}
fn energy_chip_span(todo: &Item) -> Option<Span<'static>> {
energy_value_span(todo.energy_estimate()?)
}
fn rec_chip_span(todo: &Item) -> Option<Span<'static>> {
let value = todo.recurrence()?;
Some(Span::styled(
format!("↻{value}"),
Style::default().fg(MD_META_FG),
))
}
const FILTER_BRACKET_STYLE: Style = Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD);
fn layout_chip_spans(layout: TodoLayout) -> Vec<Span<'static>> {
vec![Span::styled(
format!("[{}]", layout.label()),
FILTER_BRACKET_STYLE,
)]
}
fn filter_chip_spans(filter: &NowFilter) -> Vec<Span<'static>> {
let mut spans: Vec<Span<'static>> = vec![Span::styled("[Filter", FILTER_BRACKET_STYLE)];
if let Some(e) = filter.energy.as_deref()
&& let Some(s) = energy_value_span(e)
{
spans.push(Span::raw(" "));
spans.push(s);
}
if let Some(t) = filter.time.as_deref()
&& let Some(s) = time_value_span(t)
{
spans.push(Span::raw(" "));
spans.push(s);
}
spans.push(Span::styled("]", FILTER_BRACKET_STYLE));
spans
}
fn calc_todo_height(todo: &Item, available_width: u16) -> u16 {
let spans = create_todo_spans(todo);
let preview_lines = todo
.md_meta
.as_ref()
.map_or(0, |m| u16::try_from(m.preview.len()).unwrap_or(0));
if available_width > 10 {
let effective_width = usize::from(available_width.saturating_sub(2));
let lines = wrap_spans(&spans, effective_width).len();
let lines_u16 = u16::try_from(lines).unwrap_or(u16::MAX);
(lines_u16 + 2).min(8) + preview_lines } else {
4 + preview_lines
}
}
fn hint_label_span(label: &str) -> Span<'static> {
Span::styled(
format!(" {label} "),
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
}
struct ColumnLayout {
offset: usize,
visible_end: usize,
heights: Vec<u16>,
}
fn compute_column_layout(
project_todos: &[Item],
column_area: Rect,
is_active_column: bool,
selected_in_column: usize,
scroll_offset: usize,
) -> ColumnLayout {
let project_block = Block::default().borders(Borders::ALL);
let inner_area = project_block.inner(column_area);
if project_todos.is_empty() {
return ColumnLayout {
offset: scroll_offset,
visible_end: scroll_offset,
heights: Vec::new(),
};
}
let available_width = inner_area.width;
let available_height = inner_area.height;
let heights: Vec<u16> = project_todos
.iter()
.map(|todo| calc_todo_height(todo, available_width))
.collect();
let mut offset = scroll_offset;
if is_active_column {
if selected_in_column < offset {
offset = selected_in_column;
}
while offset < selected_in_column {
let used: u16 = heights[offset..=selected_in_column].iter().sum();
if used <= available_height {
break;
}
offset += 1;
}
}
let mut used_height: u16 = 0;
let mut visible_end = offset;
for &h in &heights[offset..] {
if used_height + h > available_height {
break;
}
used_height += h;
visible_end += 1;
}
ColumnLayout {
offset,
visible_end,
heights,
}
}
const fn column_params(state: &AppState, col_idx: usize) -> (bool, usize, usize) {
let is_active = col_idx == state.current_column;
let selected = if is_active {
state.selected_in_column
} else {
usize::MAX
};
let scroll = if is_active { state.scroll_offset } else { 0 };
(is_active, selected, scroll)
}
#[allow(clippy::too_many_arguments)]
pub fn draw_project_column(
f: &mut ratatui::Frame,
project_todos: &[Item],
project_name: &str,
column_area: ratatui::layout::Rect,
is_active_column: bool,
selected_in_column: usize,
scroll_offset: usize,
today: chrono::NaiveDate,
now: SystemTime,
col_idx: usize,
hint: Option<&crate::app_state::HintState>,
) -> usize {
let border_style = if is_active_column {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::White)
};
let title_text = format!("{project_name} ({})", project_todos.len());
let title_line = if is_active_column {
Line::from(vec![selected_icon_span(), Span::raw(title_text)])
} else {
Line::from(title_text)
};
let project_block = Block::default()
.title(title_line)
.borders(Borders::ALL)
.border_style(border_style);
let layout = compute_column_layout(
project_todos,
column_area,
is_active_column,
selected_in_column,
scroll_offset,
);
let inner_area = project_block.inner(column_area);
f.render_widget(project_block, column_area);
if project_todos.is_empty() {
return scroll_offset;
}
let visible_todos = &project_todos[layout.offset..layout.visible_end];
let visible_heights = &layout.heights[layout.offset..layout.visible_end];
let constraints: Vec<Constraint> = visible_heights
.iter()
.map(|&h| Constraint::Length(h))
.collect();
let todo_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.flex(Flex::Start)
.split(inner_area);
for (i, todo) in visible_todos.iter().enumerate() {
let actual_idx = layout.offset + i;
let is_pending = todo.is_threshold_pending(today);
let spans = create_todo_spans(todo);
let is_selected = is_active_column && actual_idx == selected_in_column;
let is_overdue = todo.is_overdue(today);
let border_style =
get_todo_border_style(is_selected, is_overdue, todo.completed || is_pending);
let effective_width = usize::from(todo_layout[i].width.saturating_sub(2));
let mut wrapped_lines: Vec<Line<'_>> = wrap_spans(&spans, effective_width);
if let Some(meta) = &todo.md_meta {
for preview_text in &meta.preview {
wrapped_lines.push(Line::from(Span::styled(
format!("☐ {preview_text}"),
Style::default().fg(MD_META_FG),
)));
}
}
let mut block = Block::default()
.borders(Borders::ALL)
.border_style(border_style);
if is_selected {
block = block.title(selected_icon_span());
}
if let Some(label) = hint.and_then(|h| h.cell_label(col_idx, actual_idx)) {
block = block.title(Line::from(hint_label_span(label)).right_aligned());
}
let parts: Vec<Span> = [
energy_chip_span(todo),
time_chip_span(todo),
rec_chip_span(todo),
meta_label(todo, now).map(|l| Span::styled(l, Style::default().fg(MD_META_FG))),
]
.into_iter()
.flatten()
.collect();
if !parts.is_empty() {
let mut spans: Vec<Span> = Vec::with_capacity(parts.len() * 2 - 1);
for (i, part) in parts.into_iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
}
spans.push(part);
}
block = block.title_bottom(Line::from(spans).right_aligned());
}
let mut todo_paragraph = Paragraph::new(wrapped_lines).block(block);
if is_pending {
todo_paragraph = todo_paragraph.style(Style::default().fg(PENDING_FG));
}
f.render_widget(todo_paragraph, todo_layout[i]);
}
layout.offset
}
const MIN_PROJECT_COL_WIDTH: u16 = 32;
fn compute_project_grid_rects(area: Rect, num_projects: usize) -> Vec<Rect> {
if num_projects == 0 {
return vec![];
}
let cols_per_row = ((area.width / MIN_PROJECT_COL_WIDTH) as usize)
.max(1)
.min(num_projects);
let rows = num_projects.div_ceil(cols_per_row);
let col_width = area.width / u16::try_from(cols_per_row).unwrap_or(1);
let row_areas = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![
Constraint::Ratio(1, u32::try_from(rows).unwrap_or(1));
rows
])
.split(area);
let col_constraints = vec![Constraint::Length(col_width); cols_per_row];
let mut rects = Vec::with_capacity(num_projects);
for (row_idx, row_area) in row_areas.iter().enumerate() {
let col_areas = Layout::default()
.direction(Direction::Horizontal)
.constraints(col_constraints.clone())
.split(*row_area);
let remaining = num_projects - row_idx * cols_per_row;
let this_row_cols = remaining.min(cols_per_row);
for area_in_row in col_areas.iter().take(this_row_cols) {
rects.push(*area_in_row);
}
}
rects
}
fn draw_project_columns(f: &mut ratatui::Frame, state: &mut AppState, area: Rect, now: SystemTime) {
let visible_projects = state.project_names.clone();
let num_columns = visible_projects.len();
if num_columns == 0 {
let paragraph = Paragraph::new("No items")
.alignment(Alignment::Center)
.style(Style::default().fg(Color::DarkGray));
f.render_widget(paragraph, area);
return;
}
let today = chrono::Local::now().date_naive();
let columns = compute_project_grid_rects(area, num_columns);
if state.pending_enter_hint {
state.pending_enter_hint = false;
let mut visible_cells: Vec<(usize, usize)> = Vec::new();
for (col_idx, project_name) in visible_projects.iter().enumerate() {
if let Some(project_todos) = state.grouped_todos.get(project_name) {
let (is_active, selected, scroll) = column_params(state, col_idx);
let layout = compute_column_layout(
project_todos,
columns[col_idx],
is_active,
selected,
scroll,
);
for row in layout.offset..layout.visible_end {
visible_cells.push((col_idx, row));
}
}
}
state.enter_hint_mode(&visible_cells);
}
for (col_idx, project_name) in visible_projects.iter().enumerate() {
if let Some(project_todos) = state.grouped_todos.get(project_name) {
let (is_active, selected, scroll) = column_params(state, col_idx);
let new_scroll = draw_project_column(
f,
project_todos,
project_name,
columns[col_idx],
is_active,
selected,
scroll,
today,
now,
col_idx,
state.hint.as_ref(),
);
if is_active {
state.scroll_offset = new_scroll;
}
}
}
}
fn draw_tab_bar(f: &mut ratatui::Frame, state: &AppState, area: Rect) {
let tab_titles: Vec<String> = ViewMode::ALL
.iter()
.enumerate()
.map(|(i, m)| format!("{} ({})", m.label(), state.mode_counts[i]))
.collect();
let tabs = Tabs::new(tab_titles)
.select(state.current_mode_index())
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let version_label = format!("torudo v{} ", env!("CARGO_PKG_VERSION"));
let version_width = u16::try_from(unicode_width::UnicodeWidthStr::width(
version_label.as_str(),
))
.unwrap_or(0);
let layout_spans = (state.view_mode == ViewMode::Todo && state.todo_layout == TodoLayout::Pick)
.then(|| layout_chip_spans(state.todo_layout));
let filter_spans = if state.view_mode == ViewMode::Todo
&& let Some(f) = state.current_filter.as_ref()
&& f.is_active()
{
Some(filter_chip_spans(f))
} else {
None
};
let spans_width = |spans: &[Span<'static>]| -> u16 {
u16::try_from(
spans
.iter()
.map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
.sum::<usize>(),
)
.unwrap_or(0)
};
let mut constraints = vec![Constraint::Length(version_width), Constraint::Min(0)];
if let Some(spans) = &layout_spans {
constraints.push(Constraint::Length(spans_width(spans).saturating_add(1)));
}
if let Some(spans) = &filter_spans {
constraints.push(Constraint::Length(spans_width(spans)));
}
let chunks = Layout::horizontal(constraints).split(area);
f.render_widget(Paragraph::new(version_label), chunks[0]);
f.render_widget(tabs, chunks[1]);
let mut next_chunk = 2;
if let Some(spans) = layout_spans {
f.render_widget(
Paragraph::new(Line::from(spans)).alignment(Alignment::Right),
chunks[next_chunk],
);
next_chunk += 1;
}
if let Some(spans) = filter_spans {
f.render_widget(
Paragraph::new(Line::from(spans)).alignment(Alignment::Right),
chunks[next_chunk],
);
}
}
pub fn draw_ui(f: &mut ratatui::Frame, state: &mut AppState) {
let now = SystemTime::now();
let size = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints(
[
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(3),
]
.as_ref(),
)
.split(size);
draw_tab_bar(f, state, chunks[0]);
draw_project_columns(f, state, chunks[1], now);
let footer_spans: Vec<Span<'_>> = if let Some(ref msg) = state.status_message {
vec![Span::styled(msg.clone(), Style::default().fg(Color::Green))]
} else {
let mut spans: Vec<Span<'_>> = Vec::new();
if let Some(ref v) = state.update_available {
spans.push(Span::styled(
format!("({v} available! Run: torudo update) "),
Style::default().fg(Color::Yellow),
));
}
let is_todo = state.view_mode == ViewMode::Todo;
let is_waiting = state.view_mode == ViewMode::Waiting;
let has_claude = state.crmux_available() || state.claude_available();
let footer_str = help::footer_entries(is_todo, is_waiting, has_claude)
.iter()
.map(|(key, desc)| format!("{key}:{desc}"))
.collect::<Vec<_>>()
.join(" │ ");
spans.push(Span::raw(footer_str));
spans
};
let footer = Paragraph::new(Line::from(footer_spans))
.block(Block::default().borders(Borders::ALL))
.alignment(Alignment::Center);
f.render_widget(footer, chunks[2]);
if let Some(modal) = &state.plan_modal {
draw_plan_modal(f, modal, size);
}
if state.show_help {
let has_claude = state.crmux_available() || state.claude_available();
draw_help_overlay(f, size, state.view_mode, has_claude);
}
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
fn draw_plan_modal(f: &mut ratatui::Frame, modal: &crate::app_state::PlanModal, area: Rect) {
let modal_area = centered_rect(60, 60, area);
f.render_widget(Clear, modal_area);
let block = Block::default()
.title("Get Plans")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(modal_area);
f.render_widget(block, modal_area);
let inner_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(inner);
let visible_height = inner_chunks[0].height as usize;
let scroll_offset = if modal.selected >= visible_height {
modal.selected - visible_height + 1
} else {
0
};
let lines: Vec<Line<'_>> = modal
.plans
.iter()
.enumerate()
.map(|(i, plan)| {
let checkbox = if modal.checked[i] { "[x] " } else { "[ ] " };
let text = format!("{checkbox}{}: {}", plan.project_name, plan.title);
let style = if i == modal.selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
Line::from(Span::styled(text, style))
})
.collect();
let list = Paragraph::new(lines).scroll((u16::try_from(scroll_offset).unwrap_or(u16::MAX), 0));
f.render_widget(list, inner_chunks[0]);
let help = Paragraph::new("j/k: Move | Space: Toggle | Enter: Import | q: Cancel")
.style(Style::default())
.alignment(Alignment::Center);
f.render_widget(help, inner_chunks[1]);
}
fn draw_help_overlay(f: &mut ratatui::Frame, area: Rect, view_mode: ViewMode, has_claude: bool) {
let modal_area = centered_rect(50, 60, area);
f.render_widget(Clear, modal_area);
let block = Block::default()
.title("Keyboard Controls")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(modal_area);
f.render_widget(block, modal_area);
let inner_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(inner);
let is_todo = view_mode == ViewMode::Todo;
let is_waiting = view_mode == ViewMode::Waiting;
let entries = help::visible_entries(is_todo, is_waiting, has_claude);
let max_key_width = entries.iter().map(|e| e.key.len()).max().unwrap_or(0);
let lines: Vec<Line<'_>> = entries
.iter()
.map(|e| {
let mut spans = Vec::new();
if e.indent {
spans.push(Span::styled(" ", Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(
format!("{:<width$} ", e.key, width = max_key_width),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(
format!("{:<width$} ", e.key, width = max_key_width),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
}
spans.push(Span::styled(e.desc, Style::default().fg(Color::White)));
Line::from(spans)
})
.collect();
let list = Paragraph::new(lines);
f.render_widget(list, inner_chunks[0]);
let footer = Paragraph::new("Press ? or q or Esc to close")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
f.render_widget(footer, inner_chunks[1]);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::md_preview::MdMeta;
use crate::todo::Item;
use std::collections::HashMap;
fn make_item(description: &str) -> Item {
Item {
completed: false,
priority: None,
creation_date: None,
completion_date: None,
description: description.to_string(),
projects: vec![],
contexts: vec![],
id: None,
key_values: HashMap::new(),
line_number: 0,
md_meta: None,
}
}
#[test]
fn calc_todo_height_cjk_short() {
let item = make_item("テスト");
assert_eq!(calc_todo_height(&item, 30), 3);
}
#[test]
fn calc_todo_height_cjk_wraps() {
let item = make_item("あいうえおかきくけこさしすせそ");
assert_eq!(calc_todo_height(&item, 20), 4);
}
fn meta_with(now: SystemTime, preview: Vec<String>, stats: Option<(usize, usize)>) -> MdMeta {
MdMeta {
mtime: now,
preview,
stats,
}
}
#[test]
fn calc_todo_height_adds_preview_lines() {
let mut item = make_item("テスト");
let baseline = calc_todo_height(&item, 30);
item.md_meta = Some(meta_with(
SystemTime::now(),
vec!["a".into(), "b".into(), "c".into()],
None,
));
assert_eq!(calc_todo_height(&item, 30), baseline + 3);
}
#[test]
fn calc_todo_height_no_preview_unchanged() {
let item = make_item("テスト");
assert_eq!(calc_todo_height(&item, 30), 3);
}
#[test]
fn meta_label_none_when_no_meta() {
let item = make_item("x");
assert!(meta_label(&item, SystemTime::now()).is_none());
}
fn item_with_time(value: &str) -> Item {
let mut item = make_item("x");
item.key_values
.insert("time".to_string(), value.to_string());
item
}
#[test]
fn time_chip_span_short_is_green_s() {
let span = time_chip_span(&item_with_time("short")).expect("should have chip");
assert_eq!(span.content, "S");
assert_eq!(span.style.fg, Some(Color::Green));
assert!(span.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn time_chip_span_medium_is_yellow_m() {
let span = time_chip_span(&item_with_time("medium")).expect("should have chip");
assert_eq!(span.content, "M");
assert_eq!(span.style.fg, Some(Color::Yellow));
}
#[test]
fn time_chip_span_long_is_red_l() {
let span = time_chip_span(&item_with_time("long")).expect("should have chip");
assert_eq!(span.content, "L");
assert_eq!(span.style.fg, Some(Color::Red));
}
#[test]
fn time_chip_span_none_without_tag() {
assert!(time_chip_span(&make_item("x")).is_none());
}
#[test]
fn time_chip_span_none_for_invalid_value() {
assert!(time_chip_span(&item_with_time("xl")).is_none());
}
fn item_with_energy(value: &str) -> Item {
let mut item = make_item("x");
item.key_values
.insert("energy".to_string(), value.to_string());
item
}
#[test]
fn energy_chip_span_low_is_green_down_arrow() {
let span = energy_chip_span(&item_with_energy("low")).expect("should have chip");
assert_eq!(span.content, "↓");
assert_eq!(span.style.fg, Some(Color::Green));
assert!(span.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn energy_chip_span_high_is_red_up_arrow() {
let span = energy_chip_span(&item_with_energy("high")).expect("should have chip");
assert_eq!(span.content, "↑");
assert_eq!(span.style.fg, Some(Color::Red));
}
#[test]
fn energy_chip_span_none_without_tag() {
assert!(energy_chip_span(&make_item("x")).is_none());
}
#[test]
fn energy_chip_span_none_for_invalid_value() {
assert!(energy_chip_span(&item_with_energy("medium")).is_none());
}
fn item_with_rec(value: &str) -> Item {
let mut item = make_item("x");
item.key_values.insert("rec".to_string(), value.to_string());
item
}
#[test]
fn rec_chip_span_shows_the_pattern() {
let span = rec_chip_span(&item_with_rec("1w")).expect("should have chip");
assert_eq!(span.content, "↻1w");
assert_eq!(span.style.fg, Some(MD_META_FG));
}
#[test]
fn rec_chip_span_keeps_the_strict_marker() {
let span = rec_chip_span(&item_with_rec("+1m")).expect("should have chip");
assert_eq!(span.content, "↻+1m");
}
#[test]
fn rec_chip_span_none_without_tag() {
assert!(rec_chip_span(&make_item("x")).is_none());
}
#[test]
fn rec_chip_span_none_for_an_unparseable_pattern() {
assert!(rec_chip_span(&item_with_rec("banana")).is_none());
assert!(rec_chip_span(&item_with_rec("1b")).is_none());
assert!(rec_chip_span(&item_with_rec("0w")).is_none());
}
#[test]
fn filter_chip_spans_energy_and_time() {
let f = NowFilter {
energy: Some("low".to_string()),
time: Some("short".to_string()),
};
let spans = filter_chip_spans(&f);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("Filter"));
assert!(text.contains('↓'));
assert!(text.contains('S'));
}
#[test]
fn filter_chip_spans_energy_only() {
let f = NowFilter {
energy: Some("high".to_string()),
time: None,
};
let spans = filter_chip_spans(&f);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains('↑'));
assert!(!text.contains('S'));
assert!(!text.contains('M'));
assert!(!text.contains('L'));
}
#[test]
fn filter_chip_spans_time_only() {
let f = NowFilter {
energy: None,
time: Some("long".to_string()),
};
let spans = filter_chip_spans(&f);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains('L'));
assert!(!text.contains('↓'));
assert!(!text.contains('↑'));
}
#[test]
fn meta_label_elapsed_only_when_no_stats() {
let mut item = make_item("x");
let now = SystemTime::now();
item.md_meta = Some(meta_with(now, vec![], None));
assert_eq!(meta_label(&item, now).as_deref(), Some(" 0s"));
}
#[test]
fn meta_label_includes_done_total() {
let mut item = make_item("x");
let now = SystemTime::now();
item.md_meta = Some(meta_with(now, vec![], Some((2, 7))));
assert_eq!(meta_label(&item, now).as_deref(), Some("2/7 0s"));
}
fn render_paragraph_to_lines(description: &str, width: u16, height: u16) -> Vec<String> {
use ratatui::backend::TestBackend;
let item = make_item(description);
let spans = create_todo_spans(&item);
let area = Rect::new(0, 0, width, height);
let backend = TestBackend::new(width, height);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let effective_width = usize::from(width.saturating_sub(2));
let wrapped_lines: Vec<Line<'_>> = wrap_spans(&spans, effective_width);
terminal
.draw(|f| {
let p = Paragraph::new(wrapped_lines).block(Block::default().borders(Borders::ALL));
f.render_widget(p, area);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
(1..height - 1)
.map(|y| {
let row: String = (1..width - 1)
.map(|x| buf.cell((x, y)).unwrap().symbol().to_string())
.collect();
row.trim_end().to_string()
})
.collect()
}
#[test]
fn render_cjk_paragraph_actual_lines() {
let lines = render_paragraph_to_lines("あいうえおかきくけこさしすせそ", 20, 6);
eprintln!("rendered lines: {lines:?}");
assert!(
!lines[0].is_empty(),
"first content row should not be blank"
);
let content_line_count = lines.iter().filter(|l| !l.is_empty()).count();
eprintln!("content line count: {content_line_count}");
}
fn assert_height_matches_render(desc: &str, width: u16) {
let calc_h = calc_todo_height(&make_item(desc), width);
let lines = render_paragraph_to_lines(desc, width, 14);
let actual_content_lines =
u16::try_from(lines.iter().filter(|l| !l.is_empty()).count()).unwrap();
let actual_h = actual_content_lines + 2;
eprintln!("desc={desc}");
eprintln!("lines={lines:?}");
eprintln!("calc_h={calc_h}, actual_h={actual_h}, content_lines={actual_content_lines}");
assert_eq!(
calc_h, actual_h,
"height mismatch for \"{desc}\" at width={width}"
);
}
#[test]
fn calc_todo_height_matches_actual_render_cjk() {
assert_height_matches_render("あいうえおかきくけこさしすせそ", 20);
}
#[test]
fn calc_todo_height_matches_actual_render_with_spaces() {
assert_height_matches_render(
"[要望] テストの確認のために結果投稿のレスポンスにURLが欲しい [#12345] https://example.com/projects/52/tasks/12345",
30,
);
}
fn priority_span(priority: char) -> Span<'static> {
let mut item = make_item("x");
item.priority = Some(priority);
create_todo_spans(&item)
.into_iter()
.next()
.expect("priority span should come first")
}
#[test]
fn priority_a_span_is_red() {
let span = priority_span('A');
assert_eq!(span.content, "(A) ");
assert_eq!(span.style.fg, Some(Color::Red));
assert!(span.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn priority_b_span_is_yellow() {
assert_eq!(priority_span('B').style.fg, Some(Color::Yellow));
}
#[test]
fn priority_c_span_is_blue() {
assert_eq!(priority_span('C').style.fg, Some(Color::Blue));
}
#[test]
fn priority_beyond_c_is_white() {
let span = priority_span('D');
assert_eq!(span.content, "(D) ");
assert_eq!(span.style.fg, Some(Color::White));
}
#[test]
fn wrap_spans_preserves_each_span_style() {
let spans = vec![
Span::styled("(A) ", Style::default().fg(Color::Cyan)),
Span::raw("task"),
];
let lines = wrap_spans(&spans, 20);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].spans[0].content, "(A) ");
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Cyan));
assert_eq!(lines[0].spans[1].content, "task");
assert_eq!(lines[0].spans[1].style.fg, None);
}
#[test]
fn wrap_spans_keeps_style_when_splitting_inside_a_span() {
let spans = vec![Span::styled("あいうえお", Style::default().fg(Color::Cyan))];
let lines = wrap_spans(&spans, 4);
assert_eq!(lines.len(), 3);
assert!(
lines
.iter()
.flat_map(|l| &l.spans)
.all(|s| s.style.fg == Some(Color::Cyan)),
"style should survive the wrap"
);
let text: String = lines
.iter()
.flat_map(|l| &l.spans)
.map(|s| s.content.as_ref())
.collect();
assert_eq!(text, "あいうえお");
}
#[test]
fn wrap_spans_wraps_across_span_boundary_by_display_width() {
let spans = vec![Span::raw("あい"), Span::raw("うえ")];
let lines = wrap_spans(&spans, 4);
assert_eq!(lines.len(), 2);
let line_text =
|l: &Line<'static>| -> String { l.spans.iter().map(|s| s.content.as_ref()).collect() };
assert_eq!(line_text(&lines[0]), "あい");
assert_eq!(line_text(&lines[1]), "うえ");
}
#[test]
fn wrap_spans_empty_input_yields_one_line() {
assert_eq!(wrap_spans(&[], 10).len(), 1);
assert_eq!(wrap_spans(&[Span::raw("x")], 0).len(), 1);
}
#[test]
fn get_todo_border_style_dimmed_is_darkgray() {
let style = get_todo_border_style(false, false, true);
assert_eq!(style, Style::default().fg(Color::DarkGray));
}
#[test]
fn get_todo_border_style_overdue_is_red() {
let style = get_todo_border_style(false, true, false);
assert_eq!(style, Style::default().fg(Color::Red));
}
#[test]
fn get_todo_border_style_selected_trumps_overdue() {
let style = get_todo_border_style(true, true, false);
assert_eq!(style, Style::default().fg(Color::Yellow));
}
#[test]
fn get_todo_border_style_overdue_trumps_dimmed() {
let style = get_todo_border_style(false, true, true);
assert_eq!(style, Style::default().fg(Color::Red));
}
fn make_item_with_id(description: &str, id: &str, project: &str) -> Item {
Item {
completed: false,
priority: None,
creation_date: None,
completion_date: None,
description: description.to_string(),
projects: vec![project.to_string()],
contexts: vec![],
id: Some(id.to_string()),
key_values: HashMap::new(),
line_number: 0,
md_meta: None,
}
}
#[test]
fn grid_rects_empty_when_no_projects() {
let rects = compute_project_grid_rects(Rect::new(0, 0, 100, 50), 0);
assert!(rects.is_empty());
}
#[test]
fn grid_rects_single_row_when_all_fit() {
let rects = compute_project_grid_rects(Rect::new(0, 0, 132, 50), 4);
assert_eq!(rects.len(), 4);
let expected_w = 132 / 4;
assert_eq!(rects[0], Rect::new(0, 0, expected_w, 50));
assert_eq!(rects[3].x, expected_w * 3);
assert_eq!(rects[3].y, 0);
}
#[test]
fn grid_rects_wraps_to_second_row() {
let rects = compute_project_grid_rects(Rect::new(0, 0, 132, 50), 6);
assert_eq!(rects.len(), 6);
let col_w = 132 / 4;
let row_h = 50 / 2;
assert_eq!(rects[0], Rect::new(0, 0, col_w, row_h));
assert_eq!(rects[3], Rect::new(col_w * 3, 0, col_w, row_h));
assert_eq!(rects[4], Rect::new(0, row_h, col_w, row_h));
assert_eq!(rects[5], Rect::new(col_w, row_h, col_w, row_h));
}
#[test]
fn grid_rects_fewer_projects_than_per_row_splits_evenly() {
let rects = compute_project_grid_rects(Rect::new(0, 0, 132, 50), 3);
assert_eq!(rects.len(), 3);
assert_eq!(rects[0].width, 132 / 3);
}
#[test]
fn grid_rects_narrow_terminal_single_column_per_row() {
let rects = compute_project_grid_rects(Rect::new(0, 0, 20, 60), 3);
assert_eq!(rects.len(), 3);
assert_eq!(rects[0].width, 20);
assert_eq!(rects[0].height, 20);
assert_eq!(rects[1].y, 20);
assert_eq!(rects[2].y, 40);
}
#[test]
fn draw_ui_paints_priority_prefix_with_its_color() {
use ratatui::backend::TestBackend;
let mut todo = make_item_with_id("task a", "a", "p1");
todo.priority = Some('A');
let mut state = AppState::new(vec![todo], String::new(), String::new());
let backend = TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| draw_ui(f, &mut state)).unwrap();
let buf = terminal.backend().buffer().clone();
let painted_red = (0..buf.area.height).any(|y| {
(0..buf.area.width).any(|x| {
let cell = buf.cell((x, y)).expect("cell in area");
cell.symbol() == "A" && cell.fg == Color::Red
})
});
assert!(
painted_red,
"priority letter should reach the buffer in red, not as plain text"
);
}
#[test]
fn draw_ui_enters_hint_mode_when_pending_flag_set() {
use ratatui::backend::TestBackend;
let todos = vec![
make_item_with_id("task a", "a", "p1"),
make_item_with_id("task b", "b", "p1"),
make_item_with_id("task c", "c", "p2"),
];
let mut state = AppState::new(todos, String::new(), String::new());
state.pending_enter_hint = true;
let backend = TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw_ui(f, &mut state);
})
.unwrap();
assert!(
!state.pending_enter_hint,
"pending flag should clear after draw"
);
let hint = state.hint.as_ref().expect("hint should be set after draw");
assert_eq!(
hint.labels.len(),
3,
"all 3 visible todos should receive a hint label"
);
}
}