use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use ratatui::layout::{Alignment, Position};
use std::fs;
use std::io::{self, Write, stdout};
use std::path::Path;
use crossterm::{
ExecutableCommand,
event::{self, Event, KeyCode, KeyModifiers},
terminal::{
EnterAlternateScreen, LeaveAlternateScreen, SetTitle, disable_raw_mode, enable_raw_mode,
},
};
use ratatui::{
Terminal,
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, ListItem, ListState, Paragraph},
};
use tui_input::{Input, InputRequest};
const TASK_FILES: [(&str, &str); 3] = [
("todo.md", "# To Do Tasks\n"),
("doing.md", "# Doing Tasks\n"),
("done.md", "# Done Tasks\n"),
];
#[derive(Parser)]
#[command(name = "lls-cli-task")]
#[command(about = "A simple file-system-backed task management system", long_about = None)]
struct Cli {
#[arg(long, default_value_t = false)]
local: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Init,
Add {
#[arg(required = true, num_args = 1.., trailing_var_arg = true)]
task: Vec<String>,
},
Status {
from: String,
task_index: String,
to: String,
},
Done {
status: String,
task_index: String,
},
Delete {
status: String,
task_index: String,
},
List { status: Option<String> },
}
fn main() -> Result<()> {
let cli = Cli::parse();
let root = get_task_root(cli.local)?;
let cwd = std::env::current_dir()?;
if root != cwd {
println!("Using tasks at: {:?}", root);
}
match cli.command {
Some(Commands::Init) => {
init_tasks(&root)?;
}
Some(Commands::Add { task }) => {
let (description, metadata) = parse_add_task_args(task)?;
let msg = add_task(&root, &description, metadata)?;
println!("{}", msg);
}
Some(Commands::Status {
from,
task_index,
to,
}) => {
move_task(&root, &from, &to, &task_index)?;
}
Some(Commands::Done { status, task_index }) => {
if status == "done" {
println!("Task is already done.");
} else {
move_task(&root, &status, "done", &task_index)?;
println!("Task {} from {} marked as done.", task_index, status);
}
}
Some(Commands::Delete { status, task_index }) => {
delete_task(&root, &status, &task_index)?;
println!("Task {} from {} deleted successfully.", task_index, status);
}
Some(Commands::List { status }) => {
list_tasks(&root, status)?;
}
None => {
if !is_initialized(&root) {
print!("Tasks not initialized. Would you like to initialize now? (y/n): ");
io::stdout().flush()?;
let mut response = String::new();
io::stdin().read_line(&mut response)?;
if response.trim().to_lowercase() == "y" {
init_tasks(&root)?;
} else {
println!(
"Initialization skipped. Please run 'init' to set up your task lists."
);
return Ok(());
}
}
tui_view(&root)?;
}
}
Ok(())
}
fn get_task_root(local: bool) -> Result<std::path::PathBuf> {
if local {
return Ok(std::env::current_dir()?);
}
let output = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output();
match output {
Ok(out) if out.status.success() => {
let path_str = String::from_utf8_lossy(&out.stdout).trim().to_string();
Ok(Path::new(&path_str).to_path_buf())
}
_ => Ok(std::env::current_dir()?),
}
}
fn get_tasks_dir(root: &Path) -> std::path::PathBuf {
root.join("tasks")
}
fn project_display_name(root: &Path) -> String {
root.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.map(str::to_string)
.unwrap_or_else(|| root.display().to_string())
}
fn app_title(root: &Path) -> String {
format!("clt | {}", project_display_name(root))
}
fn is_initialized(root: &Path) -> bool {
let tasks_dir = get_tasks_dir(root);
if !tasks_dir.exists() {
return false;
}
let files = ["todo.md", "doing.md", "done.md"];
files.iter().all(|f| tasks_dir.join(f).exists())
}
fn get_file_path(root: &Path, status: &str) -> Result<std::path::PathBuf> {
let tasks_dir = get_tasks_dir(root);
let filename = match status {
"todo" => "todo.md",
"doing" => "doing.md",
"done" => "done.md",
_ => anyhow::bail!("Invalid status. Use 'todo', 'doing', or 'done'."),
};
ensure_task_store(root)?;
Ok(tasks_dir.join(filename))
}
fn ensure_task_store(root: &Path) -> Result<()> {
let tasks_dir = get_tasks_dir(root);
fs::create_dir_all(&tasks_dir).context("Failed to create tasks directory")?;
for (filename, content) in TASK_FILES {
let path = tasks_dir.join(filename);
if !path.exists() {
fs::write(&path, content).context(format!("Failed to create file {:?}", path))?;
}
}
Ok(())
}
fn delete_task(root: &Path, status: &str, task_index_str: &str) -> Result<()> {
let path = get_file_path(root, status)?;
let task_index = task_index_str
.parse::<usize>()
.context("Invalid task index. Please provide a number.")?;
if task_index == 0 {
anyhow::bail!("Task index must be 1 or greater.");
}
let content = fs::read_to_string(&path).context("Failed to read file")?;
let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
let task_lines: Vec<(usize, &String)> = lines
.iter()
.enumerate()
.filter(|(_, line)| line.starts_with("- "))
.collect();
if task_index > task_lines.len() {
anyhow::bail!(
"Task index {} out of range. Only {} tasks found in {}.",
task_index,
task_lines.len(),
status
);
}
let (actual_line_idx, _) = task_lines[task_index - 1];
let mut new_lines = lines.clone();
new_lines.remove(actual_line_idx);
let updated_content = new_lines.join("\n");
let final_content = if updated_content.is_empty() {
updated_content
} else {
format!("{}\n", updated_content)
};
fs::write(&path, final_content).context("Failed to update file")?;
Ok(())
}
fn move_task(root: &Path, from: &str, to: &str, task_index_str: &str) -> Result<()> {
let src_path = get_file_path(root, from)?;
let dest_path = get_file_path(root, to)?;
let task_index = task_index_str
.parse::<usize>()
.context("Invalid task index. Please provide a number.")?;
if task_index == 0 {
anyhow::bail!("Task index must be 1 or greater.");
}
let content = fs::read_to_string(&src_path).context("Failed to read source file")?;
let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
let task_lines: Vec<(usize, &String)> = lines
.iter()
.enumerate()
.filter(|(_, line)| line.starts_with("- "))
.collect();
if task_index > task_lines.len() {
anyhow::bail!(
"Task index {} out of range. Only {} tasks found in {}.",
task_index,
task_lines.len(),
from
);
}
let (actual_line_idx, task_line) = task_lines[task_index - 1];
let task_line_content = task_line.clone();
let dest_content = fs::read_to_string(&dest_path).context("Failed to read destination file")?;
let mut dest_lines: Vec<String> = dest_content.lines().map(|s| s.to_string()).collect();
if to == "done" {
let insert_at_idx = dest_lines
.iter()
.position(|line| line.starts_with("- "))
.unwrap_or(dest_lines.len());
dest_lines.insert(insert_at_idx, task_line_content);
} else {
dest_lines.push(task_line_content);
}
fs::write(&dest_path, dest_lines.join("\n") + "\n")
.context("Failed to update destination file")?;
let mut new_src_lines = lines.clone();
new_src_lines.remove(actual_line_idx);
let updated_src_content = new_src_lines.join("\n");
let final_src_content = if updated_src_content.is_empty() {
updated_src_content
} else {
format!("{}\n", updated_src_content)
};
fs::write(&src_path, final_src_content).context("Failed to update source file")?;
Ok(())
}
fn update_task(root: &Path, status: &str, task_index: usize, new_description: &str) -> Result<()> {
let path = get_file_path(root, status)?;
let content = fs::read_to_string(&path).context("Failed to read file")?;
let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
let mut task_count = 0;
let mut updated_lines = Vec::new();
for line in lines {
if line.starts_with("- ") {
task_count += 1;
if task_count == task_index {
updated_lines.push(format!("- {}", new_description));
} else {
updated_lines.push(line);
}
} else {
updated_lines.push(line);
}
}
if task_count < task_index {
anyhow::bail!("Task index {} out of range", task_index);
}
fs::write(&path, updated_lines.join("\n") + "\n").context("Failed to write file")?;
Ok(())
}
fn reorder_task(root: &Path, status: &str, from_idx: usize, to_idx: usize) -> Result<()> {
let path = get_file_path(root, status)?;
let content = fs::read_to_string(&path).context("Failed to read file")?;
let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
let task_indices: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, line)| line.starts_with("- "))
.map(|(i, _)| i)
.collect();
if from_idx >= task_indices.len() {
anyhow::bail!("Task index out of range");
}
let actual_from_idx = task_indices[from_idx];
let task_line = lines[actual_from_idx].clone();
let mut new_lines = lines.clone();
new_lines.remove(actual_from_idx);
let new_task_indices: Vec<usize> = new_lines
.iter()
.enumerate()
.filter(|(_, line)| line.starts_with("- "))
.map(|(i, _)| i)
.collect();
let insert_at_idx = if to_idx < new_task_indices.len() {
new_task_indices[to_idx]
} else {
new_lines.len()
};
new_lines.insert(insert_at_idx, task_line);
fs::write(&path, new_lines.join("\n") + "\n").context("Failed to write file")?;
Ok(())
}
fn list_tasks(root: &Path, filter_status: Option<String>) -> Result<()> {
if let Some(ref s) = filter_status {
let status = match s.as_str() {
"1" => "todo",
"2" => "doing",
"3" => "done",
_ => s.as_str(),
};
let path = get_file_path(root, status)?;
println!("\n--- {} ---", status.to_uppercase());
let content = fs::read_to_string(&path).context(format!("Failed to read {:?}", path))?;
let mut index = 1;
for line in content.lines() {
if line.starts_with("- ") {
println!("{}. {}", index, &line[2..]);
index += 1;
}
}
} else {
let statuses = ["todo", "doing", "done"];
for status in statuses {
let path = get_file_path(root, status)?;
println!("\n--- {} ---", status.to_uppercase());
let content =
fs::read_to_string(&path).context(format!("Failed to read {:?}", path))?;
let mut index = 1;
for line in content.lines() {
if line.starts_with("- ") {
println!("{}. {}", index, &line[2..]);
index += 1;
}
}
}
}
Ok(())
}
fn parse_add_task_args(args: Vec<String>) -> Result<(String, Option<String>)> {
if args.is_empty() {
anyhow::bail!("Task description cannot be empty.");
}
let mut args = args;
let metadata = if args.len() > 1 && looks_like_metadata(args.last().unwrap()) {
args.pop()
} else {
None
};
let description = args.join(" ");
if description.trim().is_empty() {
anyhow::bail!("Task description cannot be empty.");
}
Ok((description, metadata))
}
fn looks_like_metadata(value: &str) -> bool {
value.contains(',')
|| (value.chars().any(|c| c.is_ascii_alphabetic())
&& value
.chars()
.all(|c| !c.is_ascii_lowercase() && !matches!(c, '"' | '\'')))
}
fn add_task(root: &Path, description: &str, metadata: Option<String>) -> Result<String> {
insert_task(root, "todo", None, description, metadata)
.map(|_| "Task added successfully.".to_string())
}
fn insert_task(
root: &Path,
status: &str,
index: Option<usize>,
description: &str,
metadata: Option<String>,
) -> Result<()> {
let path = get_file_path(root, status)?;
let metadata_str = match metadata {
Some(m) => format!(" ({})", m),
None => "".to_string(),
};
let task_line = format!("- {}{}", description, metadata_str);
let content = fs::read_to_string(&path).context("Failed to read file")?;
let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
if let Some(idx) = index {
let task_lines: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, line)| line.starts_with("- "))
.map(|(i, _)| i)
.collect();
if idx < task_lines.len() {
let actual_idx = task_lines[idx] + 1;
lines.insert(actual_idx, task_line);
} else {
lines.push(task_line);
}
} else {
lines.push(task_line);
}
let updated_content = lines.join("\n");
let final_content = if updated_content.is_empty() {
updated_content
} else {
format!("{}\n", updated_content)
};
fs::write(&path, final_content).context("Failed to write task to file")?;
Ok(())
}
fn read_tasks(root: &Path, status: &str) -> Result<Vec<String>> {
let path = get_file_path(root, status)?;
let content = fs::read_to_string(&path).context(format!("Failed to read {:?}", path))?;
let tasks = content
.lines()
.filter(|l| l.starts_with("- "))
.map(|l| l.to_string())
.collect();
Ok(tasks)
}
fn select_first_task_if_present(root: &Path, status: &str, state: &mut ListState) {
let has_tasks = read_tasks(root, status)
.map(|tasks| !tasks.is_empty())
.unwrap_or(false);
state.select(if has_tasks { Some(0) } else { None });
}
fn select_last_task_if_present(root: &Path, status: &str, state: &mut ListState) {
let last_idx = read_tasks(root, status)
.ok()
.and_then(|tasks| tasks.len().checked_sub(1));
state.select(last_idx);
}
fn selected_task_index(root: &Path, status: &str, state: &ListState) -> Option<usize> {
let idx = state.selected()?;
let tasks = read_tasks(root, status).ok()?;
if idx < tasks.len() { Some(idx) } else { None }
}
fn selected_task(root: &Path, status: &str, state: &ListState) -> Option<(usize, String)> {
let idx = state.selected()?;
let tasks = read_tasks(root, status).ok()?;
tasks.get(idx).cloned().map(|task| (idx, task))
}
fn normalize_board_selection(root: &Path, status: &str, state: &mut ListState) {
let selected = state.selected();
let task_count = read_tasks(root, status)
.map(|tasks| tasks.len())
.unwrap_or(0);
match (selected, task_count) {
(Some(idx), 0) if idx == 0 => state.select(None),
(Some(idx), count) if idx >= count => state.select(count.checked_sub(1)),
_ => {}
}
}
fn normalize_board_selections(root: &Path, statuses: &[&str], states: &mut [ListState]) {
for (status, state) in statuses.iter().zip(states.iter_mut()) {
normalize_board_selection(root, status, state);
}
}
fn task_display_height(
task: &str,
idx: usize,
selected_idx: Option<usize>,
col_width: usize,
) -> usize {
let cleaned = task.replace("- ", "");
let desc = if let Some(start) = cleaned.rfind(" (") {
if cleaned.ends_with(')') {
&cleaned[..start]
} else {
&cleaned[..]
}
} else {
&cleaned[..]
};
if Some(idx) == selected_idx {
wrap_text(desc, col_width.saturating_sub(5))
.lines()
.count()
.max(1)
} else {
1
}
}
fn keep_selected_task_visible(
tasks: &[String],
selected_idx: Option<usize>,
scroll_offset: &mut usize,
viewport_height: usize,
col_width: usize,
) {
if tasks.is_empty() || viewport_height == 0 {
*scroll_offset = 0;
return;
}
let Some(selected_idx) = selected_idx.filter(|idx| *idx < tasks.len()) else {
*scroll_offset = (*scroll_offset).min(tasks.len() - 1);
return;
};
if selected_idx < *scroll_offset {
*scroll_offset = selected_idx;
}
while *scroll_offset < selected_idx {
let visible_height: usize = tasks[*scroll_offset..=selected_idx]
.iter()
.enumerate()
.map(|(offset, task)| {
let idx = *scroll_offset + offset;
task_display_height(task, idx, Some(selected_idx), col_width)
})
.sum();
if visible_height <= viewport_height {
break;
}
*scroll_offset += 1;
}
}
enum Mode {
View,
Input,
Edit,
Help,
}
struct TerminalSession;
impl TerminalSession {
fn enter(title: &str) -> Result<Self> {
enable_raw_mode()?;
let mut stdout = stdout();
if let Err(err) = stdout.execute(EnterAlternateScreen) {
let _ = disable_raw_mode();
return Err(err.into());
}
if let Err(err) = stdout.execute(SetTitle(title)) {
let _ = stdout.execute(LeaveAlternateScreen);
let _ = disable_raw_mode();
return Err(err.into());
}
Ok(Self)
}
}
impl Drop for TerminalSession {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = stdout().execute(LeaveAlternateScreen);
}
}
fn wrap_text(text: &str, width: usize) -> String {
if width == 0 {
return text.to_string();
}
let mut result = String::new();
let mut current_line = String::new();
for word in text.split_whitespace() {
let mut word_to_add = word;
while word_to_add.len() > width {
if !current_line.is_empty() {
result.push_str(¤t_line);
result.push('\n');
current_line.clear();
}
let (head, tail) = word_to_add.split_at(width);
result.push_str(head);
result.push('\n');
word_to_add = tail;
}
if current_line.is_empty() {
current_line.push_str(word_to_add);
} else if current_line.len() + 1 + word_to_add.len() <= width {
current_line.push(' ');
current_line.push_str(word_to_add);
} else {
result.push_str(¤t_line);
result.push('\n');
current_line.clear();
current_line.push_str(word_to_add);
}
}
result.push_str(¤t_line);
result
}
fn wrap_input_text(text: &str, width: usize) -> String {
if width == 0 {
return text.to_string();
}
let mut result = String::new();
let mut col = 0;
for ch in text.chars() {
if ch == '\n' {
result.push(ch);
col = 0;
continue;
}
if col >= width {
result.push('\n');
col = 0;
}
result.push(ch);
col += 1;
}
result
}
fn input_cursor_offset_at(text: &str, width: usize, cursor_idx: usize) -> (u16, u16) {
if width == 0 {
return (0, 0);
}
let cursor_idx = clamp_to_char_boundary(text, cursor_idx.min(text.len()));
let mut row = 0;
let mut col = 0;
for ch in text[..cursor_idx].chars() {
if ch == '\n' {
row += 1;
col = 0;
continue;
}
if col >= width {
row += 1;
col = 0;
}
col += 1;
}
if col >= width {
(0, (row + 1) as u16)
} else {
(col as u16, row as u16)
}
}
fn byte_index_at_char(text: &str, char_idx: usize) -> usize {
text.char_indices()
.nth(char_idx)
.map(|(idx, _)| idx)
.unwrap_or(text.len())
}
fn input_cursor_offset_at_char(text: &str, width: usize, cursor_chars: usize) -> (usize, usize) {
if width == 0 {
return (0, 0);
}
let mut row = 0;
let mut col = 0;
for ch in text.chars().take(cursor_chars) {
if ch == '\n' {
row += 1;
col = 0;
continue;
}
if col >= width {
row += 1;
col = 0;
}
col += 1;
}
if col >= width {
(0, row + 1)
} else {
(col, row)
}
}
fn char_index_for_input_offset(
text: &str,
width: usize,
target_row: usize,
target_col: usize,
) -> usize {
if width == 0 {
return 0;
}
let mut row = 0;
let mut col = 0;
for (char_idx, ch) in text.chars().enumerate() {
if row == target_row && col >= target_col {
return char_idx;
}
if ch == '\n' {
if row == target_row {
return char_idx;
}
row += 1;
col = 0;
continue;
}
if col >= width {
row += 1;
col = 0;
if row == target_row && col >= target_col {
return char_idx;
}
}
col += 1;
}
text.chars().count()
}
fn move_input_cursor_row(input: &mut Input, label: &str, width: usize, row_delta: isize) {
if width == 0 {
return;
}
let full_text = format!("{}{}", label, input.value());
let label_chars = label.chars().count();
let input_chars = input.value().chars().count();
let cursor_chars = label_chars + input.cursor();
let (target_col, current_row) = input_cursor_offset_at_char(&full_text, width, cursor_chars);
let target_row = current_row.saturating_add_signed(row_delta);
let target_chars = char_index_for_input_offset(&full_text, width, target_row, target_col);
let input_cursor = target_chars.saturating_sub(label_chars).min(input_chars);
input.handle(InputRequest::SetCursor(input_cursor));
}
fn handle_input_key(input: &mut Input, key: crossterm::event::KeyEvent, label: &str, width: usize) {
let word_modifier =
key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::ALT);
let request = match key.code {
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(InputRequest::GoToStart)
}
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(InputRequest::GoToEnd)
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(InputRequest::DeleteLine)
}
KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(InputRequest::DeleteTillEnd)
}
KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(InputRequest::DeletePrevWord)
}
KeyCode::Backspace if word_modifier => Some(InputRequest::DeletePrevWord),
KeyCode::Delete if word_modifier => Some(InputRequest::DeleteNextWord),
KeyCode::Left if word_modifier => Some(InputRequest::GoToPrevWord),
KeyCode::Right if word_modifier => Some(InputRequest::GoToNextWord),
KeyCode::Left => Some(InputRequest::GoToPrevChar),
KeyCode::Right => Some(InputRequest::GoToNextChar),
KeyCode::Home => Some(InputRequest::GoToStart),
KeyCode::End => Some(InputRequest::GoToEnd),
KeyCode::Backspace => Some(InputRequest::DeletePrevChar),
KeyCode::Delete => Some(InputRequest::DeleteNextChar),
KeyCode::Char(c)
if !key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::ALT) =>
{
Some(InputRequest::InsertChar(c))
}
_ => None,
};
if let Some(request) = request {
input.handle(request);
return;
}
match key.code {
KeyCode::Up => move_input_cursor_row(input, label, width, -1),
KeyCode::Down => move_input_cursor_row(input, label, width, 1),
_ => {}
}
}
fn clamp_to_char_boundary(text: &str, idx: usize) -> usize {
let mut idx = idx.min(text.len());
while idx > 0 && !text.is_char_boundary(idx) {
idx -= 1;
}
idx
}
#[cfg(test)]
fn previous_char_boundary(text: &str, idx: usize) -> usize {
let idx = clamp_to_char_boundary(text, idx);
text[..idx]
.char_indices()
.last()
.map(|(char_idx, _)| char_idx)
.unwrap_or(0)
}
#[cfg(test)]
fn next_char_boundary(text: &str, idx: usize) -> usize {
let idx = clamp_to_char_boundary(text, idx);
if idx >= text.len() {
return text.len();
}
text[idx..]
.char_indices()
.nth(1)
.map(|(offset, _)| idx + offset)
.unwrap_or(text.len())
}
fn tui_view(root: &Path) -> Result<()> {
let title = app_title(root);
let _terminal_session = TerminalSession::enter(&title)?;
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
let console_title = format!("{} Console", project_display_name(root));
let mut current_mode = Mode::View;
let mut task_input = Input::default();
let mut feedback_buffer = String::from(
"Kanban View! Spacebar to create new Task. Arrows to navigate/focus boards, Shift+Arrows or I/K to reorder, Shift+Arrows or J/L to move tasks, Numbers to reorder, Space to add, Enter to edit, 'd' or Delete to delete, 'q' to quit.",
);
let mut selected_board = 0; let mut editing_task_idx: Option<usize> = None;
let mut board_states = [
ListState::default(),
ListState::default(),
ListState::default(),
];
let mut board_scroll_offsets = [0usize; 3];
let statuses = ["todo", "doing", "done"];
let titles = ["To Do", "Doing", "Done"];
let c_1 = Color::Indexed(110);
let c_2 = Color::Indexed(108);
let c_3 = Color::Indexed(139);
let text_color = Color::Indexed(248); let c_highlight = Color::Indexed(221);
let colors = [c_1, c_2, c_3];
loop {
normalize_board_selections(root, &statuses, &mut board_states);
terminal.draw(|f| {
let size = f.area();
let input_height =
if matches!(current_mode, Mode::Input) || matches!(current_mode, Mode::Edit) {
let label = if matches!(current_mode, Mode::Input) {
" Add Task: "
} else {
" Edit Task: "
};
let full_text = format!("{}{}", label, task_input.value());
let available_width = size.width.saturating_sub(2) as usize;
let wrapped = wrap_input_text(&full_text, available_width);
let lines = wrapped.lines().count();
let cursor_idx =
label.len() + byte_index_at_char(task_input.value(), task_input.cursor());
let cursor_row =
input_cursor_offset_at(&full_text, available_width, cursor_idx).1 as usize;
(lines.max(cursor_row + 1) + 2).max(3) as u16
} else {
0
};
let main_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(input_height),
Constraint::Length(3),
])
.split(size);
let kanban_area = main_layout[0];
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(33),
Constraint::Percentage(33),
Constraint::Percentage(33),
])
.split(kanban_area);
for (i, status) in statuses.iter().enumerate() {
let selected_idx = board_states[i].selected();
let col_width = (size.width / 3) as usize;
let tasks = read_tasks(root, status).unwrap_or_default();
let _items: Vec<ListItem> = tasks
.clone()
.into_iter()
.enumerate()
.map(|(idx, t)| {
let cleaned = t.replace("- ", "");
let (desc, meta) = if let Some(start) = cleaned.rfind(" (") {
if cleaned.ends_with(')') {
(
&cleaned[..start],
Some(&cleaned[start + 2..cleaned.len() - 1]),
)
} else {
(&cleaned[..], None)
}
} else {
(&cleaned[..], None)
};
let mut line = Line::from(vec![
Span::raw(format!("{}. ", idx + 1)),
Span::raw(if Some(idx) == selected_idx {
wrap_text(desc, col_width.saturating_sub(5))
} else {
desc.to_string()
}),
]);
if let Some(m) = meta {
line.spans.push(
Span::raw(format!(" ({})", m))
.style(Style::default().bg(Color::DarkGray).fg(Color::White)),
);
}
ListItem::new(line)
})
.collect();
let highlight_style = if matches!(current_mode, Mode::View) {
Style::default().fg(Color::Black).bg(c_highlight)
} else {
Style::default().fg(Color::White).bg(Color::DarkGray)
};
let block = Block::default()
.title(format!(
"{} {}",
titles[i],
if selected_board == i {
" <<<<<< * >>>>>> "
} else {
""
}
))
.title(
Line::from(vec![Span::raw(format!(" {} ", tasks.len()))])
.alignment(Alignment::Right),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(colors[i]));
let inner_area = block.inner(chunks[i]);
keep_selected_task_visible(
&tasks,
selected_idx,
&mut board_scroll_offsets[i],
inner_area.height as usize,
col_width,
);
let mut current_y = 0;
for (idx, t) in tasks.iter().enumerate().skip(board_scroll_offsets[i]) {
let cleaned = t.replace("- ", "");
let is_selected = Some(idx) == selected_idx;
let (desc, _meta) = if let Some(start) = cleaned.rfind(" (") {
if cleaned.ends_with(')') {
(
&cleaned[..start],
Some(&cleaned[start + 2..cleaned.len() - 1]),
)
} else {
(&cleaned[..], None)
}
} else {
(&cleaned[..], None)
};
let text = if is_selected {
wrap_text(desc, col_width.saturating_sub(5))
} else {
desc.to_string()
};
let style = if is_selected {
highlight_style
} else {
Style::default().fg(text_color)
};
let content = format!("{}. {}", idx + 1, text);
let _paragraph = Paragraph::new(content).style(style);
let _area = ratatui::layout::Rect {
x: inner_area.x,
y: inner_area.y + current_y as u16,
width: inner_area.width,
height: 1, };
let wrapped_content = if is_selected {
wrap_text(desc, col_width.saturating_sub(5))
} else {
desc.to_string()
};
let line_count = wrapped_content.lines().count().max(1);
if current_y >= inner_area.height as usize {
break;
}
let visible_height =
(line_count as u16).min(inner_area.height.saturating_sub(current_y as u16));
let item_area = ratatui::layout::Rect {
x: inner_area.x,
y: inner_area.y + current_y as u16,
width: inner_area.width,
height: visible_height,
};
let item_text = format!("{}. {}", idx + 1, wrapped_content);
f.render_widget(Paragraph::new(item_text).style(style), item_area);
current_y += line_count;
if inner_area.y + current_y as u16 >= chunks[i].height {
break;
}
}
f.render_widget(block, chunks[i]);
}
if matches!(current_mode, Mode::Input) || matches!(current_mode, Mode::Edit) {
let label = if matches!(current_mode, Mode::Input) {
" Add Task: "
} else {
" Edit Task: "
};
let input_text = format!("{}{}", label, task_input.value());
let available_width = size.width.saturating_sub(2) as usize;
let wrapped_input = wrap_input_text(&input_text, available_width);
let input_paragraph = Paragraph::new(wrapped_input.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.title("Input Mode (Enter to save, Esc to cancel)"),
)
.style(Style::default().fg(Color::White));
f.render_widget(input_paragraph, main_layout[1]);
let (cursor_x, cursor_y) = input_cursor_offset_at(
&input_text,
available_width,
label.len() + byte_index_at_char(task_input.value(), task_input.cursor()),
);
let input_inner = main_layout[1].inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
f.set_cursor_position(Position::new(
input_inner.x + cursor_x.min(input_inner.width.saturating_sub(1)),
input_inner.y + cursor_y.min(input_inner.height.saturating_sub(1)),
));
}
let feedback_paragraph = Paragraph::new(feedback_buffer.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.title(console_title.as_str()),
)
.style(Style::default().fg(Color::Gray));
let feedback_area = *main_layout.last().unwrap();
f.render_widget(feedback_paragraph, feedback_area);
if matches!(current_mode, Mode::Help) {
let help_text = "TUI Commands:\n\n\
[Space] - Create new task\n\
[Enter] - Edit selected task / Save input\n\
[d/Del] - Delete selected task\n\
[Arrows] - Navigate boards and tasks\n\
[Shift+Arrows] - Reorder/Move tasks\n\
[I, K] - Move task Up/Down\n\
[J, L] - Move task Left/Right\n\
[1, 2, 3] - Switch board focus\n\
[Input Arrows] - Move cursor in wrapped input\n\
[Ctrl/Alt+Left/Right] - Jump input cursor by word\n\
[Ctrl+A/E/W/U/K] - Edit input line\n\
[h / ?] - Toggle Help\n\
[q] - Quit";
let area = f.area();
let popover_width = area.width.min(70);
let popover_height = area.height.min(18);
let x = area.width.saturating_sub(popover_width) / 2;
let y = area.height.saturating_sub(popover_height) / 2;
let popover_area = ratatui::layout::Rect {
x,
y,
width: popover_width,
height: popover_height,
};
let help_paragraph = Paragraph::new(help_text)
.block(
Block::default()
.title(" Help ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.style(Style::default().fg(Color::White));
f.render_widget(help_paragraph, popover_area);
}
})?;
if event::poll(std::time::Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
let input_available_width = terminal.size()?.width.saturating_sub(2) as usize;
match current_mode {
Mode::View => {
if key.modifiers.contains(KeyModifiers::SHIFT) {
match key.code {
KeyCode::Up => {
if let Some(idx) = selected_task_index(
root,
statuses[selected_board],
&board_states[selected_board],
) {
if idx > 0 {
match reorder_task(
root,
statuses[selected_board],
idx,
idx - 1,
) {
Ok(_) => {
feedback_buffer =
format!("Moved task up to position {}", idx)
}
Err(e) => feedback_buffer = format!("Error: {}", e),
}
board_states[selected_board].select(Some(idx - 1));
} else {
feedback_buffer = "Already at the top".to_string();
}
} else {
board_states[selected_board].select(None);
feedback_buffer = "No task selected".to_string();
}
}
KeyCode::Down => {
if let Some(idx) = selected_task_index(
root,
statuses[selected_board],
&board_states[selected_board],
) {
let tasks = read_tasks(root, statuses[selected_board])
.unwrap_or_default();
if tasks.is_empty() {
board_states[selected_board].select(None);
feedback_buffer = "No task selected".to_string();
} else if idx < tasks.len() - 1 {
match reorder_task(
root,
statuses[selected_board],
idx,
idx + 1,
) {
Ok(_) => {
feedback_buffer = format!(
"Moved task down to position {}",
idx + 2
)
}
Err(e) => feedback_buffer = format!("Error: {}", e),
}
board_states[selected_board].select(Some(idx + 1));
} else {
feedback_buffer = "Already at the bottom".to_string();
}
} else {
board_states[selected_board].select(None);
feedback_buffer = "No task selected".to_string();
}
}
KeyCode::Left => {
if let Some(idx) = selected_task_index(
root,
statuses[selected_board],
&board_states[selected_board],
) {
if selected_board > 0 {
let to_board = selected_board - 1;
let from = statuses[selected_board];
let to = statuses[to_board];
match move_task(root, from, to, &(idx + 1).to_string())
{
Ok(_) => {
selected_board = to_board;
for state in board_states.iter_mut() {
state.select(None);
}
select_last_task_if_present(
root,
to,
&mut board_states[selected_board],
);
feedback_buffer =
format!("Moved task to {}", to)
}
Err(e) => feedback_buffer = format!("Error: {}", e),
}
} else {
feedback_buffer =
"Already at the first board".to_string();
}
} else {
board_states[selected_board].select(None);
feedback_buffer = "No task selected".to_string();
}
}
KeyCode::Right => {
if let Some(idx) = selected_task_index(
root,
statuses[selected_board],
&board_states[selected_board],
) {
if selected_board < 2 {
let to_board = selected_board + 1;
let from = statuses[selected_board];
let to = statuses[to_board];
match move_task(root, from, to, &(idx + 1).to_string())
{
Ok(_) => {
selected_board = to_board;
for state in board_states.iter_mut() {
state.select(None);
}
select_last_task_if_present(
root,
to,
&mut board_states[selected_board],
);
feedback_buffer =
format!("Moved task to {}", to)
}
Err(e) => feedback_buffer = format!("Error: {}", e),
}
} else {
feedback_buffer =
"Already at the last board".to_string();
}
} else {
board_states[selected_board].select(None);
feedback_buffer = "No task selected".to_string();
}
}
_ => {}
}
} else if key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT)
{
_ = ();
} else {
match key.code {
KeyCode::Esc => {
let state = &mut board_states[selected_board];
state.select(None);
feedback_buffer = "Task unselected".to_string();
}
KeyCode::Char('q') => break,
KeyCode::Enter => {
if let Some((idx, task)) = selected_task(
root,
statuses[selected_board],
&board_states[selected_board],
) {
current_mode = Mode::Edit;
editing_task_idx = Some(idx + 1);
task_input = Input::new(task.replace("- ", ""));
} else {
board_states[selected_board].select(None);
current_mode = Mode::Input;
task_input.reset();
}
}
KeyCode::Char(' ') => {
if selected_task_index(
root,
statuses[selected_board],
&board_states[selected_board],
)
.is_none()
{
board_states[selected_board].select(None);
}
current_mode = Mode::Input;
task_input.reset();
}
KeyCode::Char('1') => {
selected_board = 0;
for state in board_states.iter_mut() {
state.select(None);
}
select_first_task_if_present(
root,
statuses[selected_board],
&mut board_states[selected_board],
);
}
KeyCode::Char('2') => {
selected_board = 1;
for state in board_states.iter_mut() {
state.select(None);
}
select_first_task_if_present(
root,
statuses[selected_board],
&mut board_states[selected_board],
);
}
KeyCode::Char('3') => {
selected_board = 2;
for state in board_states.iter_mut() {
state.select(None);
}
select_first_task_if_present(
root,
statuses[selected_board],
&mut board_states[selected_board],
);
}
KeyCode::Char('i') | KeyCode::Char('I') => {
if let Some(idx) = selected_task_index(
root,
statuses[selected_board],
&board_states[selected_board],
) {
if idx > 0 {
match reorder_task(
root,
statuses[selected_board],
idx,
idx - 1,
) {
Ok(_) => {
feedback_buffer =
format!("Moved task up to position {}", idx)
}
Err(e) => feedback_buffer = format!("Error: {}", e),
}
board_states[selected_board].select(Some(idx - 1));
} else {
feedback_buffer = "Already at the top".to_string();
}
} else {
board_states[selected_board].select(None);
feedback_buffer = "No task selected".to_string();
}
}
KeyCode::Char('k') | KeyCode::Char('K') => {
if let Some(idx) = selected_task_index(
root,
statuses[selected_board],
&board_states[selected_board],
) {
let tasks = read_tasks(root, statuses[selected_board])
.unwrap_or_default();
if tasks.is_empty() {
board_states[selected_board].select(None);
feedback_buffer = "No task selected".to_string();
} else if idx < tasks.len() - 1 {
match reorder_task(
root,
statuses[selected_board],
idx,
idx + 1,
) {
Ok(_) => {
feedback_buffer = format!(
"Moved task down to position {}",
idx + 2
)
}
Err(e) => feedback_buffer = format!("Error: {}", e),
}
board_states[selected_board].select(Some(idx + 1));
} else {
feedback_buffer = "Already at the bottom".to_string();
}
} else {
board_states[selected_board].select(None);
feedback_buffer = "No task selected".to_string();
}
}
KeyCode::Char('j') | KeyCode::Char('J') => {
if let Some(idx) = selected_task_index(
root,
statuses[selected_board],
&board_states[selected_board],
) {
if selected_board > 0 {
let to_board = selected_board - 1;
let from = statuses[selected_board];
let to = statuses[to_board];
match move_task(root, from, to, &(idx + 1).to_string())
{
Ok(_) => {
selected_board = to_board;
for state in board_states.iter_mut() {
state.select(None);
}
select_last_task_if_present(
root,
to,
&mut board_states[selected_board],
);
feedback_buffer =
format!("Moved task to {}", to)
}
Err(e) => feedback_buffer = format!("Error: {}", e),
}
} else {
feedback_buffer =
"Already at the first board".to_string();
}
} else {
board_states[selected_board].select(None);
feedback_buffer = "No task selected".to_string();
}
}
KeyCode::Char('l') | KeyCode::Char('L') => {
if let Some(idx) = selected_task_index(
root,
statuses[selected_board],
&board_states[selected_board],
) {
if selected_board < 2 {
let to_board = selected_board + 1;
let from = statuses[selected_board];
let to = statuses[to_board];
match move_task(root, from, to, &(idx + 1).to_string())
{
Ok(_) => {
selected_board = to_board;
for state in board_states.iter_mut() {
state.select(None);
}
select_last_task_if_present(
root,
to,
&mut board_states[selected_board],
);
feedback_buffer =
format!("Moved task to {}", to)
}
Err(e) => feedback_buffer = format!("Error: {}", e),
}
} else {
feedback_buffer =
"Already at the last board".to_string();
}
} else {
board_states[selected_board].select(None);
feedback_buffer = "No task selected".to_string();
}
}
KeyCode::Char('d') | KeyCode::Char('D') | KeyCode::Delete => {
if let Some(idx) = selected_task_index(
root,
statuses[selected_board],
&board_states[selected_board],
) {
let status = statuses[selected_board];
match delete_task(root, status, &(idx + 1).to_string()) {
Ok(_) => {
feedback_buffer = format!(
"Deleted task {} from {}",
idx + 1,
status
);
board_states[selected_board].select(if idx > 0 {
Some(idx - 1)
} else {
None
});
}
Err(e) => feedback_buffer = format!("Error: {}", e),
}
} else {
board_states[selected_board].select(None);
feedback_buffer = "No task selected to delete".to_string();
}
}
KeyCode::Char('h') | KeyCode::Char('H') | KeyCode::Char('?') => {
current_mode = Mode::Help;
}
KeyCode::Up => {
let state = &mut board_states[selected_board];
let tasks = read_tasks(root, statuses[selected_board])
.unwrap_or_default();
if !tasks.is_empty() {
let i = state.selected().unwrap_or(0);
if i > 0 {
state.select(Some(i - 1));
} else {
state.select(Some(tasks.len() - 1));
}
}
}
KeyCode::Down => {
let state = &mut board_states[selected_board];
let tasks = read_tasks(root, statuses[selected_board])
.unwrap_or_default();
if !tasks.is_empty() {
let i = state.selected().unwrap_or(0);
if i < tasks.len() - 1 {
state.select(Some(i + 1));
} else {
state.select(Some(0));
}
}
}
KeyCode::Left => {
if selected_board > 0 {
selected_board -= 1;
} else {
selected_board = 2;
}
for state in board_states.iter_mut() {
state.select(None);
}
select_first_task_if_present(
root,
statuses[selected_board],
&mut board_states[selected_board],
);
}
KeyCode::Right => {
if selected_board < 2 {
selected_board += 1;
} else {
selected_board = 0;
}
for state in board_states.iter_mut() {
state.select(None);
}
select_first_task_if_present(
root,
statuses[selected_board],
&mut board_states[selected_board],
);
}
KeyCode::Char(c) if c.is_ascii_digit() => {
let new_pos = (c as u8 - b'0') as usize;
if let Some(idx) = selected_task_index(
root,
statuses[selected_board],
&board_states[selected_board],
) {
if new_pos > 0 {
match reorder_task(
root,
statuses[selected_board],
idx,
new_pos - 1,
) {
Ok(_) => {
feedback_buffer = format!(
"Reordered task to position {}",
new_pos
)
}
Err(e) => feedback_buffer = format!("Error: {}", e),
}
}
} else {
board_states[selected_board].select(None);
feedback_buffer = "No task selected".to_string();
}
}
_ => {}
}
}
}
Mode::Help => match key.code {
KeyCode::Enter
| KeyCode::Esc
| KeyCode::Char('h')
| KeyCode::Char('H')
| KeyCode::Char('?') => {
current_mode = Mode::View;
}
_ => {}
},
Mode::Input => match key.code {
KeyCode::Enter => {
if !task_input.value().trim().is_empty() {
let index = selected_task_index(
root,
statuses[selected_board],
&board_states[selected_board],
);
match insert_task(
root,
statuses[selected_board],
index,
task_input.value(),
None,
) {
Ok(_) => {
feedback_buffer = "Task added successfully.".to_string()
}
Err(e) => feedback_buffer = format!("Error: {}", e),
}
} else {
feedback_buffer = "Task description cannot be empty.".to_string();
}
current_mode = Mode::View;
task_input.reset();
}
KeyCode::Esc => {
current_mode = Mode::View;
task_input.reset();
}
_ => handle_input_key(
&mut task_input,
key,
" Add Task: ",
input_available_width,
),
},
Mode::Edit => match key.code {
KeyCode::Enter => {
if !task_input.value().trim().is_empty() {
if let Some(idx) = editing_task_idx {
match update_task(
root,
statuses[selected_board],
idx,
task_input.value(),
) {
Ok(_) => {
feedback_buffer =
format!("Task {} updated successfully.", idx)
}
Err(e) => feedback_buffer = format!("Error: {}", e),
}
}
} else {
feedback_buffer = "Task description cannot be empty.".to_string();
}
current_mode = Mode::View;
task_input.reset();
editing_task_idx = None;
}
KeyCode::Esc => {
current_mode = Mode::View;
task_input.reset();
editing_task_idx = None;
}
_ => handle_input_key(
&mut task_input,
key,
" Edit Task: ",
input_available_width,
),
},
}
}
}
}
Ok(())
}
fn init_tasks(root: &Path) -> Result<()> {
let tasks_dir = get_tasks_dir(root);
if !tasks_dir.exists() {
fs::create_dir_all(&tasks_dir).context("Failed to create tasks directory")?;
println!("Created directory: {:?}", tasks_dir);
}
for (filename, content) in TASK_FILES {
let path = tasks_dir.join(filename);
if !path.exists() {
let mut file =
fs::File::create(&path).context(format!("Failed to create file {:?}", path))?;
file.write_all(content.as_bytes())
.context(format!("Failed to write to file {:?}", path))?;
println!("Created file: {:?}", path);
} else {
println!("File already exists: {:?}", path);
}
}
println!("Initialization complete.");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_root(name: &str) -> std::path::PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("clt-{}-{}", name, nonce))
}
#[test]
fn add_task_creates_missing_task_store() {
let root = temp_root("auto-init");
let result = add_task(&root, "write from a fresh directory", None);
assert!(result.is_ok());
let todo = fs::read_to_string(root.join("tasks/todo.md")).unwrap();
let doing = fs::read_to_string(root.join("tasks/doing.md")).unwrap();
let done = fs::read_to_string(root.join("tasks/done.md")).unwrap();
assert!(todo.contains("# To Do Tasks"));
assert!(todo.contains("- write from a fresh directory"));
assert_eq!(doing, "# Doing Tasks\n");
assert_eq!(done, "# Done Tasks\n");
fs::remove_dir_all(root).unwrap();
}
#[test]
fn ensure_task_store_preserves_existing_files() {
let root = temp_root("preserve");
let tasks_dir = root.join("tasks");
fs::create_dir_all(&tasks_dir).unwrap();
fs::write(tasks_dir.join("todo.md"), "# Custom Todo\n- keep me\n").unwrap();
ensure_task_store(&root).unwrap();
let todo = fs::read_to_string(tasks_dir.join("todo.md")).unwrap();
let doing = fs::read_to_string(tasks_dir.join("doing.md")).unwrap();
let done = fs::read_to_string(tasks_dir.join("done.md")).unwrap();
assert_eq!(todo, "# Custom Todo\n- keep me\n");
assert_eq!(doing, "# Doing Tasks\n");
assert_eq!(done, "# Done Tasks\n");
fs::remove_dir_all(root).unwrap();
}
#[test]
fn parse_add_task_args_joins_unquoted_description_words() {
let (description, metadata) = parse_add_task_args(vec![
"write".to_string(),
"release".to_string(),
"notes".to_string(),
])
.unwrap();
assert_eq!(description, "write release notes");
assert_eq!(metadata, None);
}
#[test]
fn parse_add_task_args_keeps_tag_like_metadata() {
let (description, metadata) =
parse_add_task_args(vec!["Fix login bug".to_string(), "BUG, HIGH".to_string()])
.unwrap();
assert_eq!(description, "Fix login bug");
assert_eq!(metadata, Some("BUG, HIGH".to_string()));
}
#[test]
fn add_command_accepts_multiple_description_words() {
let cli = Cli::try_parse_from(["clt", "add", "write", "release", "notes"]).unwrap();
match cli.command {
Some(Commands::Add { task }) => {
assert_eq!(task, vec!["write", "release", "notes"]);
}
_ => panic!("expected add command"),
}
}
#[test]
fn project_display_name_uses_folder_name_with_root_fallback() {
assert_eq!(
project_display_name(Path::new("/Users/pro/code/lls/clt")),
"clt"
);
assert_eq!(project_display_name(Path::new("/")), "/");
}
#[test]
fn app_title_includes_project_name() {
assert_eq!(
app_title(Path::new("/Users/pro/code/lls/example")),
"clt | example"
);
}
#[test]
fn move_task_writes_destination_and_removes_source() {
let root = temp_root("move");
add_task(&root, "ship the fix", None).unwrap();
move_task(&root, "todo", "doing", "1").unwrap();
let todo = fs::read_to_string(root.join("tasks/todo.md")).unwrap();
let doing = fs::read_to_string(root.join("tasks/doing.md")).unwrap();
assert_eq!(todo, "# To Do Tasks\n");
assert_eq!(doing, "# Doing Tasks\n- ship the fix\n");
fs::remove_dir_all(root).unwrap();
}
#[test]
fn move_task_to_done_adds_to_top() {
let root = temp_root("move-done-top");
add_task(&root, "older done task", None).unwrap();
add_task(&root, "newer done task", None).unwrap();
move_task(&root, "todo", "done", "1").unwrap();
move_task(&root, "todo", "done", "1").unwrap();
let done = fs::read_to_string(root.join("tasks/done.md")).unwrap();
assert_eq!(done, "# Done Tasks\n- newer done task\n- older done task\n");
fs::remove_dir_all(root).unwrap();
}
#[test]
fn selected_task_ignores_stale_selection() {
let root = temp_root("stale-selection");
ensure_task_store(&root).unwrap();
let mut state = ListState::default();
state.select(Some(0));
assert_eq!(selected_task(&root, "todo", &state), None);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn selected_task_index_ignores_stale_selection() {
let root = temp_root("stale-index");
add_task(&root, "only task", None).unwrap();
let mut state = ListState::default();
state.select(Some(1));
assert_eq!(selected_task_index(&root, "todo", &state), None);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn normalize_board_selection_clears_empty_board_selection() {
let root = temp_root("normalize-empty");
ensure_task_store(&root).unwrap();
let mut state = ListState::default();
state.select(Some(0));
normalize_board_selection(&root, "todo", &mut state);
assert_eq!(state.selected(), None);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn normalize_board_selection_clamps_out_of_range_selection() {
let root = temp_root("normalize-range");
add_task(&root, "only task", None).unwrap();
let mut state = ListState::default();
state.select(Some(4));
normalize_board_selection(&root, "todo", &mut state);
assert_eq!(state.selected(), Some(0));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn keep_selected_task_visible_scrolls_down_to_selection() {
let tasks = vec![
"- task one".to_string(),
"- task two".to_string(),
"- task three".to_string(),
"- task four".to_string(),
];
let mut scroll_offset = 0;
keep_selected_task_visible(&tasks, Some(3), &mut scroll_offset, 3, 20);
assert_eq!(scroll_offset, 1);
}
#[test]
fn keep_selected_task_visible_scrolls_up_to_selection() {
let tasks = vec![
"- task one".to_string(),
"- task two".to_string(),
"- task three".to_string(),
];
let mut scroll_offset = 2;
keep_selected_task_visible(&tasks, Some(0), &mut scroll_offset, 3, 20);
assert_eq!(scroll_offset, 0);
}
#[test]
fn input_cursor_offset_tracks_cursor_inside_wrapped_text() {
let text = " Add Task: hello world";
assert_eq!(wrap_input_text(text, 10), " Add Task:\n hello wor\nld");
assert_eq!(
input_cursor_offset_at(text, 10, " Add Task: hello".len()),
(6, 1)
);
assert_eq!(input_cursor_offset_at(text, 10, text.len()), (2, 2));
}
#[test]
fn input_cursor_helpers_preserve_utf8_boundaries() {
let text = "aéb";
let inside_e = 2;
assert_eq!(clamp_to_char_boundary(text, inside_e), 1);
assert_eq!(previous_char_boundary(text, text.len()), 3);
assert_eq!(previous_char_boundary(text, 3), 1);
assert_eq!(next_char_boundary(text, 1), 3);
assert_eq!(next_char_boundary(text, 3), text.len());
}
#[test]
fn input_key_handler_moves_and_edits_by_words() {
let mut input = Input::new("first second third".to_string());
handle_input_key(
&mut input,
crossterm::event::KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL),
" Add Task: ",
80,
);
handle_input_key(
&mut input,
crossterm::event::KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE),
" Add Task: ",
80,
);
assert_eq!(input.value(), "first second Xthird");
handle_input_key(
&mut input,
crossterm::event::KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL),
" Add Task: ",
80,
);
assert_eq!(input.value(), "first second third");
}
#[test]
fn input_key_handler_moves_vertically_through_wrapped_input() {
let mut input = Input::new("hello world".to_string());
handle_input_key(
&mut input,
crossterm::event::KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
" Add Task: ",
10,
);
assert_eq!(input.cursor(), 1);
handle_input_key(
&mut input,
crossterm::event::KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
" Add Task: ",
10,
);
assert_eq!(input.cursor(), input.value().chars().count());
}
}