diff --git a/docs/KULLANIM.md b/docs/KULLANIM.md index a5bcf21..170c3e2 100644 --- a/docs/KULLANIM.md +++ b/docs/KULLANIM.md @@ -123,7 +123,9 @@ tw board --date 2026-04-01 ![Board TUI](images/board.png) -**Seçili kolon** kenarlığı vurgulanır. **Seçili satır** açık ve koyu temalarda okunaklı görünsün diye **turuncu** arka plan ve kalın yazı ile gösterilir. +**Seçili kolon** kenarlığı vurgulanır. **Seçili satır**, o kolonun **vurgu rengiyle** (sarı / mavi / yeşil) aynı arka plan ve kalın yazı ile gösterilir (sarı ve yeşilde siyah, mavi üzerinde beyaz metin). + +**Komut** kutusunun üstünde, tek satırlık bir **durum** şeridi komut çıktısı yokken **seçili görevi** gösterir (kimlik, başlık, etiketler, notlar); kolonda kesilen uzun metinler burada okunabilir; satır terminal genişliğine göre kısaltılabilir. `:` ile bir komut çalıştırdıktan sonra şerit, sonuç veya hatayı **Tab**, ok tuşları veya başka bir gezinme tuşuna basana kadar gösterir. **Klavye (board ekranı):** @@ -141,7 +143,9 @@ tw board --date 2026-04-01 | `:` | Komut satırı (aşağıya bakın) | | `Esc` | Komut satırında iptal | -**Komut satırı (`:`):** Örneğin `add Yeni görev` veya `start 01ABC123` yazın (`tw` öneki isteğe bağlı). **Enter** ile çalıştırın, **Esc** ile iptal. +**`s`**, **`d`** veya **`b`** ile taşıdıktan sonra aynı görev seçili kalır ve yeni kolonunda vurgulanır (güncel görünümde hâlâ listeleniyorsa). + +**Komut satırı (`:`):** Örneğin `add Yeni görev` veya `start 01ABC123` yazın (`tw` öneki isteğe bağlı). **Enter** ile çalıştırın, **Esc** ile iptal. Düzenlerken imleci **←** / **→** ile taşıyın. **İstatistik ekranı:** diff --git a/docs/USAGE.md b/docs/USAGE.md index a6a07ee..617d168 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -119,7 +119,9 @@ tw board --date 2026-04-01 ![Board TUI](images/board.png) -The **focused column** highlights its border. The **selected row** uses an **orange** background with bold text so the cursor stays visible on light and dark terminal themes. +The **focused column** highlights its border. The **selected row** uses the **same accent color as that column** (yellow / blue / green) as its background, with bold text (black on yellow and green, white on blue) so the cursor stays visible. + +Above the **command** box, a one-line **status** strip shows the **selected task** (id, title, tags, and notes) when you are not viewing command output, so you can read fields that are clipped in the column list; very long lines are truncated to the terminal width. After you run a `:` command, that strip shows the result or error until you move with **Tab**, arrows, or another navigation key. **Board keys:** @@ -136,7 +138,9 @@ The **focused column** highlights its border. The **selected row** uses an **ora | `:` | Command line (see below) | | `Esc` | Cancel command line | -**Command line (`:`):** type a line such as `add My task` or `start 01ABC123` (optional `tw` prefix). Press **Enter** to run, **Esc** to cancel. +After **`s`**, **`d`**, or **`b`**, the same task stays selected and highlighted in its new column (if it is still visible in the current view). + +**Command line (`:`):** type a line such as `add My task` or `start 01ABC123` (optional `tw` prefix). Press **Enter** to run, **Esc** to cancel. Use **←** / **→** to move the cursor while editing. **Stats screen:** diff --git a/docs/images/board.png b/docs/images/board.png index adf7190..6f17166 100644 Binary files a/docs/images/board.png and b/docs/images/board.png differ diff --git a/scripts/generate_doc_screenshots.py b/scripts/generate_doc_screenshots.py index a0ee03d..b8bb821 100644 --- a/scripts/generate_doc_screenshots.py +++ b/scripts/generate_doc_screenshots.py @@ -30,8 +30,8 @@ BLUE = (120, 160, 255) GREEN = (120, 220, 160) GRAY = (100, 100, 100) -ORANGE = (255, 165, 0) BLACK = (0, 0, 0) +WHITE = (255, 255, 255) def _font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: @@ -81,8 +81,9 @@ def col_block( for line, is_sel in rows: if is_sel: bbox = dr.textbbox((x + 10, yy), line, font=mono) - dr.rectangle([bbox[0] - 2, bbox[1] - 1, bbox[2] + 2, bbox[3] + 1], fill=ORANGE) - dr.text((x + 10, yy), line, fill=BLACK, font=mono) + dr.rectangle([bbox[0] - 2, bbox[1] - 1, bbox[2] + 2, bbox[3] + 1], fill=border) + txt = WHITE if border == BLUE else BLACK + dr.text((x + 10, yy), line, fill=txt, font=mono) else: dr.text((x + 10, yy), line, fill=FG, font=mono) yy += 20 @@ -91,12 +92,24 @@ def col_block( col_block(8 + col_w, "DOING", titles[1][1], rows_doing, False) col_block(8 + 2 * col_w, "DONE", titles[2][1], rows_done, False) - fy = y0 + col_h + 8 - dr.rectangle([4, fy, w - 5, fy + 32], outline=GRAY, width=1) - dr.text((10, fy + 8), "> ", fill=GRAY, font=mono) - dr.text((10, fy + 52), " command (press :) ", fill=GRAY, font=small) - hint = " s start | d done | b back | a Done view | Tab | g stats | q | : command | Esc " - dr.text((10, h - 28), hint, fill=GRAY, font=small) + footer_top = y0 + col_h + 8 + dr.text( + (10, footer_top), + "01DEF45678901234 Review PR [docs, team] | note: one-line status", + fill=GRAY, + font=small, + ) + cmd_top = footer_top + 20 + dr.rectangle([4, cmd_top, w - 5, cmd_top + 32], outline=GRAY, width=1) + dr.text((10, cmd_top + 4), " command (press :) ", fill=GRAY, font=small) + dr.text((10, cmd_top + 20), "> ", fill=GRAY, font=mono) + hint_top = h - 46 + dr.rectangle([4, hint_top, w - 5, h - 6], outline=GRAY, width=1) + dr.text((10, hint_top + 4), " keymap ", fill=GRAY, font=small) + hint = ( + " s start | d done | b back | a Done view | Tab | g stats | q | : command | Esc | ←/→ " + ) + dr.text((10, hint_top + 22), hint, fill=GRAY, font=small) im.save(path, "PNG") diff --git a/src/ui/board.rs b/src/ui/board.rs index ee71f25..bf1e114 100644 --- a/src/ui/board.rs +++ b/src/ui/board.rs @@ -5,7 +5,7 @@ use ratatui::{ widgets::{Block, Borders, List, ListItem, Paragraph}, Frame, }; -use unicode_width::UnicodeWidthStr; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::state::task::Task; use crate::ui::App; @@ -22,19 +22,47 @@ fn split_main(area: Rect) -> (Rect, Rect, Rect) { (rows[0], rows[1], rows[2]) } -/// Footer: command row (top), status (middle), keys hint (bottom). +/// Footer: single-line status (top), command row (middle), keys hint (bottom). fn split_footer(footer: Rect) -> (Rect, Rect, Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), Constraint::Length(1), Constraint::Length(3), + Constraint::Length(3), ]) .split(footer); (chunks[0], chunks[1], chunks[2]) } +/// Truncate to one terminal row (ellipsis when shortened). +fn fit_status_line(s: &str, max_cols: u16) -> String { + let max = max_cols as usize; + if max == 0 { + return String::new(); + } + if UnicodeWidthStr::width(s) <= max { + return s.to_string(); + } + let ell = "…"; + let ell_w = UnicodeWidthStr::width(ell); + if max <= ell_w { + return ell.chars().take(max).collect(); + } + let budget = max - ell_w; + let mut acc = 0usize; + let mut end_byte = 0usize; + for (i, ch) in s.char_indices() { + let w = UnicodeWidthChar::width(ch).unwrap_or(0); + if acc + w > budget { + break; + } + acc += w; + end_byte = i + ch.len_utf8(); + } + format!("{}{}", &s[..end_byte], ell) +} + fn input_block_title(app: &App) -> &'static str { if app.command_focused { " command " @@ -49,14 +77,18 @@ pub fn command_cursor_position(term_area: Rect, app: &App) -> Option<(u16, u16)> return None; } let (_, _, footer) = split_main(term_area); - let (input_outer, _, _) = split_footer(footer); + let (_, input_outer, _) = split_footer(footer); let block = Block::default() .borders(Borders::ALL) .title(input_block_title(app)); let inner = block.inner(input_outer); let prefix = "> "; - let w = UnicodeWidthStr::width(prefix) - .saturating_add(UnicodeWidthStr::width(app.command_buffer.as_str())); + let mut c = app.command_cursor.min(app.command_buffer.len()); + while c > 0 && !app.command_buffer.is_char_boundary(c) { + c -= 1; + } + let before = &app.command_buffer[..c]; + let w = UnicodeWidthStr::width(prefix).saturating_add(UnicodeWidthStr::width(before)); let w_u16 = u16::try_from(w).unwrap_or(u16::MAX); let col = inner.x.saturating_add(w_u16); let row = inner.y; @@ -117,7 +149,15 @@ pub fn draw(f: &mut Frame, app: &App) { Color::Green, ); - let (input_area, status_area, hint_area) = split_footer(footer_area); + let (status_area, input_area, hint_area) = split_footer(footer_area); + + let status_raw = app.footer_status_text(); + let status_w = status_area.width; + let status_text = fit_status_line(&status_raw, status_w); + let status = Paragraph::new(status_text) + .style(Style::default().fg(Color::DarkGray)) + .block(Block::default().borders(Borders::NONE)); + f.render_widget(status, status_area); let input_label = input_block_title(app); let prompt = if app.command_focused { @@ -143,23 +183,25 @@ pub fn draw(f: &mut Frame, app: &App) { ); f.render_widget(input, input_area); - let status_text = app - .status_line - .as_deref() - .unwrap_or(""); - let status = Paragraph::new(status_text) - .style(Style::default().fg(Color::DarkGray)) - .block(Block::default().borders(Borders::NONE)); - f.render_widget(status, status_area); - let hint = Paragraph::new( - " s start | d done | b back | a Done view | Tab | g stats | q | : command | Esc ", + " s start | d done | b back | a Done view | Tab | g stats | q | : command | Esc | ←/→ ", ) .style(Style::default().fg(Color::DarkGray)) - .block(Block::default().borders(Borders::ALL)); + .block(Block::default().borders(Borders::ALL).title(" keymap ")); f.render_widget(hint, hint_area); } +fn selection_style(column_accent: Color) -> Style { + let fg = match column_accent { + Color::Blue => Color::White, + _ => Color::Black, + }; + Style::default() + .fg(fg) + .bg(column_accent) + .add_modifier(Modifier::BOLD) +} + fn render_column( f: &mut Frame, area: ratatui::layout::Rect, @@ -169,10 +211,7 @@ fn render_column( selected_row: usize, color: Color, ) { - let selected_row_style = Style::default() - .fg(Color::Black) - .bg(Color::Rgb(255, 165, 0)) - .add_modifier(Modifier::BOLD); + let selected_row_style = selection_style(color); let border_style = if is_selected { Style::default().fg(color).add_modifier(Modifier::BOLD) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index bd47dae..d99814f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -15,7 +15,7 @@ use std::io; use crate::commands::execute_board_line; use crate::state::filter::{apply_daily_view, DailyViewMode}; use crate::state::replay; -use crate::state::task::Board; +use crate::state::task::{Board, Task}; use crate::wal::event::{Column, WalEntry, WalEvent}; use crate::wal::append; @@ -33,6 +33,8 @@ pub struct App { pub screen: ActiveScreen, /// Typed command line when `command_focused` is true. pub command_buffer: String, + /// Byte index in `command_buffer` for insert/delete/cursor (UTF-8 boundary). + pub command_cursor: usize, pub command_focused: bool, /// Last command result or error (cleared on next navigation key). pub status_line: Option, @@ -76,6 +78,16 @@ impl App { Ok(()) } + /// Move selection to the row that contains `id` in the current display board (after a move). + fn focus_task_by_id(&mut self, id: &str) { + if let Some((col, row)) = task_position_in_board(&self.display, id) { + self.selected_col = col; + self.selected_row = row; + } else { + self.clamp_selection(); + } + } + fn clear_status(&mut self) { self.status_line = None; } @@ -84,6 +96,7 @@ impl App { let line = self.command_buffer.trim(); if line.is_empty() { self.command_buffer.clear(); + self.command_cursor = 0; self.command_focused = false; self.clear_status(); return Ok(()); @@ -99,9 +112,157 @@ impl App { } } self.command_buffer.clear(); + self.command_cursor = 0; self.command_focused = false; Ok(()) } + + /// Selected task in the current column/row, if any (empty column → `None`). + pub fn selected_task(&self) -> Option<&Task> { + let tasks = match self.selected_col { + 0 => &self.display.todo, + 1 => &self.display.doing, + _ => &self.display.done, + }; + tasks.get(self.selected_row) + } + + /// Footer middle: command result/error if set, otherwise a one-line preview of the selected task. + pub fn footer_status_text(&self) -> String { + if let Some(ref s) = self.status_line { + return s.clone(); + } + self.selected_task() + .map(format_selected_task_preview) + .unwrap_or_default() + } +} + +fn task_position_in_board(board: &Board, id: &str) -> Option<(usize, usize)> { + if let Some(i) = board.todo.iter().position(|t| t.id == id) { + return Some((0, i)); + } + if let Some(i) = board.doing.iter().position(|t| t.id == id) { + return Some((1, i)); + } + if let Some(i) = board.done.iter().position(|t| t.id == id) { + return Some((2, i)); + } + None +} + +fn insert_char_at_cursor(buf: &mut String, cursor: &mut usize, c: char) { + buf.insert(*cursor, c); + *cursor += c.len_utf8(); +} + +fn backspace_at_cursor(buf: &mut String, cursor: &mut usize) { + if *cursor == 0 || *cursor > buf.len() { + return; + } + let prev = buf[..*cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + buf.replace_range(prev..*cursor, ""); + *cursor = prev; +} + +fn cursor_step_left(buf: &str, cursor: &mut usize) { + if *cursor == 0 { + return; + } + *cursor = buf[..*cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); +} + +fn cursor_step_right(buf: &str, cursor: &mut usize) { + if *cursor >= buf.len() { + return; + } + let ch = buf[*cursor..].chars().next().unwrap(); + *cursor += ch.len_utf8(); +} + +fn format_selected_task_preview(task: &Task) -> String { + let mut out = format!("{} {}", task.id, task.title); + if !task.tags.is_empty() { + out.push_str(&format!(" [{}]", task.tags.join(", "))); + } + if !task.notes.is_empty() { + out.push_str(" | "); + out.push_str(&task.notes.join("; ")); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wal::event::Column; + use chrono::Utc; + + #[test] + fn format_preview_includes_id_title_tags_notes() { + let task = Task { + id: "01HZTESTTESTTEST".to_string(), + title: "Fix the board".to_string(), + tags: vec!["bug".to_string(), "ui".to_string()], + column: Column::Todo, + notes: vec!["see BAT-9".to_string(), "wrap long lines".to_string()], + created_at: Utc::now(), + started_at: None, + done_at: None, + created_day: "2026-04-20".to_string(), + }; + let s = format_selected_task_preview(&task); + assert!(s.contains("01HZTESTTESTTEST")); + assert!(s.contains("Fix the board")); + assert!(s.contains("[bug, ui]")); + assert!(s.contains("see BAT-9; wrap long lines")); + } + + #[test] + fn task_position_finds_column_and_row() { + let mut board = Board::default(); + let t = Task { + id: "01HZFINDME000000".to_string(), + title: "x".to_string(), + tags: vec![], + column: Column::Doing, + notes: vec![], + created_at: Utc::now(), + started_at: None, + done_at: None, + created_day: "2026-04-20".to_string(), + }; + board.doing.push(t); + assert_eq!( + task_position_in_board(&board, "01HZFINDME000000"), + Some((1, 0)) + ); + } + + #[test] + fn command_cursor_inserts_and_moves() { + let mut buf = String::new(); + let mut cur = 0usize; + insert_char_at_cursor(&mut buf, &mut cur, 'a'); + insert_char_at_cursor(&mut buf, &mut cur, 'b'); + assert_eq!(buf, "ab"); + assert_eq!(cur, 2); + cursor_step_left(&buf, &mut cur); + assert_eq!(cur, 1); + insert_char_at_cursor(&mut buf, &mut cur, 'X'); + assert_eq!(buf, "aXb"); + backspace_at_cursor(&mut buf, &mut cur); + assert_eq!(buf, "ab"); + assert_eq!(cur, 1); + } } pub fn run(initial_mode: DailyViewMode) -> Result<()> { @@ -121,6 +282,7 @@ pub fn run(initial_mode: DailyViewMode) -> Result<()> { selected_row: 0, screen: ActiveScreen::Board, command_buffer: String::new(), + command_cursor: 0, command_focused: false, status_line: None, }; @@ -160,6 +322,7 @@ pub fn run(initial_mode: DailyViewMode) -> Result<()> { match key.code { KeyCode::Esc => { app.command_buffer.clear(); + app.command_cursor = 0; app.command_focused = false; app.clear_status(); } @@ -167,13 +330,19 @@ pub fn run(initial_mode: DailyViewMode) -> Result<()> { app.run_command_line()?; } KeyCode::Backspace => { - app.command_buffer.pop(); + backspace_at_cursor(&mut app.command_buffer, &mut app.command_cursor); + } + KeyCode::Left => { + cursor_step_left(&app.command_buffer, &mut app.command_cursor); + } + KeyCode::Right => { + cursor_step_right(&app.command_buffer, &mut app.command_cursor); } KeyCode::Char(c) => { if key.modifiers.contains(KeyModifiers::CONTROL) { continue; } - app.command_buffer.push(c); + insert_char_at_cursor(&mut app.command_buffer, &mut app.command_cursor, c); } _ => {} } @@ -185,6 +354,7 @@ pub fn run(initial_mode: DailyViewMode) -> Result<()> { KeyCode::Char(':') => { app.clear_status(); app.command_focused = true; + app.command_cursor = app.command_buffer.len(); } KeyCode::Char('q') => break, KeyCode::Tab => { @@ -204,11 +374,12 @@ pub fn run(initial_mode: DailyViewMode) -> Result<()> { append(&WalEntry { ts: now, event: WalEvent::Move { - id, + id: id.clone(), to: Column::Doing, }, })?; app.reload()?; + app.focus_task_by_id(&id); } } KeyCode::Char('d') => { @@ -218,11 +389,12 @@ pub fn run(initial_mode: DailyViewMode) -> Result<()> { append(&WalEntry { ts: now, event: WalEvent::Move { - id, + id: id.clone(), to: Column::Done, }, })?; app.reload()?; + app.focus_task_by_id(&id); } } KeyCode::Char('b') => { @@ -233,9 +405,13 @@ pub fn run(initial_mode: DailyViewMode) -> Result<()> { let now = chrono::Utc::now(); append(&WalEntry { ts: now, - event: WalEvent::Move { id, to }, + event: WalEvent::Move { + id: id.clone(), + to, + }, })?; app.reload()?; + app.focus_task_by_id(&id); } } }