diff --git a/crates/plannotator-tui/README.md b/crates/plannotator-tui/README.md index 384bbf2..3f8558f 100644 --- a/crates/plannotator-tui/README.md +++ b/crates/plannotator-tui/README.md @@ -12,8 +12,8 @@ cargo build --release | Where | Keys | |---|---| -| anywhere | `Tab` cycle focus (tree · document · rail) · `E` send feedback (clipboard) · `t` show/hide tree · `r` reload · `q` quit | -| document | drag with the mouse, or `v` then `hjkl` / `w` `b` / `0` `$` to select; `Enter` confirms · `j`/`k` or click selects a block · `c` comments on the block · `x` clears the block's annotations | +| anywhere | `Tab` cycle focus (tree · document · rail) · `E` send feedback (clipboard) · `C` clear all annotations on the current document (confirm) · `t` show/hide tree · `r` reload · `q` quit | +| document | drag with the mouse, or `v` then `hjkl` / `w` `b` / `0` `$` to select; `Enter` confirms · `j`/`k` or click selects a block · `c` comments on the block · `x` clears the block's annotations · wide Markdown tables wrap by cell | | selection toolbar | `a` 👍 looks good · `c` 💬 comment (opens a box at the selection) · `d` ✗ delete · `Esc` clears | | rail | `j`/`k` move · `e` / `Enter` edit body · `x` remove · click a bubble to focus it | | file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) | diff --git a/crates/plannotator-tui/src/app/draw.rs b/crates/plannotator-tui/src/app/draw.rs index a56778c..13aa0ad 100644 --- a/crates/plannotator-tui/src/app/draw.rs +++ b/crates/plannotator-tui/src/app/draw.rs @@ -96,7 +96,7 @@ impl App { Mode::Pick => self.draw_pick(frame), Mode::Archive => self.draw_archive(frame), Mode::ReviewMenu => self.draw_review_menu(frame), - Mode::Browse | Mode::ConfirmQuit => {} + Mode::Browse | Mode::ConfirmQuit | Mode::ConfirmClearDocument => {} } } @@ -372,6 +372,14 @@ impl App { } fn draw_footer(&mut self, frame: &mut Frame, mut area: Rect) { + if self.mode == Mode::ConfirmClearDocument { + let question = format!( + " clear all {} annotation(s) on current document? y clear · n cancel", + self.open.store.len() + ); + frame.render_widget(Paragraph::new(Line::from(Span::raw(question).bold())), area); + return; + } if self.mode == Mode::ConfirmQuit { // The question owns the footer: the browse help would name keys that are not // live while it is up. diff --git a/crates/plannotator-tui/src/app/input.rs b/crates/plannotator-tui/src/app/input.rs index 869b228..2c6d643 100644 --- a/crates/plannotator-tui/src/app/input.rs +++ b/crates/plannotator-tui/src/app/input.rs @@ -19,6 +19,7 @@ impl App { Event::Key(key) if key.kind != KeyEventKind::Release => match &self.mode { Mode::Browse => self.browse_key(*key), Mode::ConfirmQuit => self.confirm_quit_key(*key), + Mode::ConfirmClearDocument => self.confirm_clear_document_key(*key), Mode::Pick => self.pick_key(*key), Mode::Archive => { self.archive_key(*key); @@ -56,6 +57,10 @@ impl App { return Ok(()); } (KeyCode::Char('E'), _) => return self.send_feedback(), + (KeyCode::Char('C'), _) => { + self.request_clear_document(); + return Ok(()); + } (KeyCode::Char('m'), _) if self.is_file_review() => { self.open_review_menu(); return Ok(()); @@ -109,6 +114,36 @@ impl App { Ok(()) } + /// Ask before deleting every annotation on the current document. + fn request_clear_document(&mut self) { + if self.open.store.len() == 0 { + self.status = Some("no annotations on current document".into()); + } else { + self.mode = Mode::ConfirmClearDocument; + } + } + + /// Confirm or cancel the current-document clear operation. + fn confirm_clear_document_key(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Char('y' | 'Y') | KeyCode::Enter => { + let removed = self.open.store.clear_all()?; + self.mark_unsent(); + self.clear_selection(); + self.rail_cursor = 0; + self.sync_tree_counts(); + self.mode = Mode::Browse; + self.status = Some(format!("cleared {removed} annotation(s) on current document")); + } + KeyCode::Char('n' | 'N') | KeyCode::Esc => { + self.mode = Mode::Browse; + self.status = Some("clear cancelled".into()); + } + _ => {} + } + Ok(()) + } + fn cycle_focus(&mut self) { let has_tree = self.tree.is_some(); let has_rail = !self.open.store.placed().is_empty(); @@ -304,6 +339,7 @@ impl App { Mode::Compose | Mode::Browse | Mode::ConfirmQuit + | Mode::ConfirmClearDocument | Mode::Pick | Mode::Archive | Mode::ReviewMenu => { diff --git a/crates/plannotator-tui/src/app/mod.rs b/crates/plannotator-tui/src/app/mod.rs index c1c6925..cd34ddd 100644 --- a/crates/plannotator-tui/src/app/mod.rs +++ b/crates/plannotator-tui/src/app/mod.rs @@ -53,6 +53,8 @@ enum Mode { Edit(String), /// Quit was asked for while feedback is unsent; the footer asks first. ConfirmQuit, + /// Clearing every annotation on the current document was requested; the footer asks first. + ConfirmClearDocument, /// Choosing which of the agent's recent messages to review. Pick, /// Restoring annotations from finished file reviews. diff --git a/crates/plannotator-tui/src/app/tests.rs b/crates/plannotator-tui/src/app/tests.rs index 324630d..ea69c01 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -146,6 +146,25 @@ fn quitting_with_unsent_feedback_asks_before_it_quits() { assert_eq!(app.send_state, SendState::Ready, "nothing was sent"); } +#[test] +fn uppercase_c_clears_all_annotations_on_the_current_document_after_confirmation() { + let mut app = app(Box::new(Discard)); + app.add_block_annotation(0, Kind::Comment, "first".to_owned()).expect("annotation"); + app.add_block_annotation(1, Kind::LooksGood, String::new()).expect("annotation"); + assert_eq!(app.open.store.len(), 2); + + app.handle_event(&key(KeyCode::Char('C'), KeyModifiers::NONE)).expect("clear request"); + assert_eq!(app.mode, Mode::ConfirmClearDocument); + app.handle_event(&key(KeyCode::Char('n'), KeyModifiers::NONE)).expect("cancel clear"); + assert_eq!(app.open.store.len(), 2); + + app.handle_event(&key(KeyCode::Char('C'), KeyModifiers::NONE)).expect("clear request"); + app.handle_event(&key(KeyCode::Char('y'), KeyModifiers::NONE)).expect("confirm clear"); + assert_eq!(app.mode, Mode::Browse); + assert_eq!(app.open.store.len(), 0); + assert!(app.status.as_deref().is_some_and(|status| status.starts_with("cleared 2"))); +} + fn candidates() -> Vec { use plannotator_tui_hosts::{Message, Role}; let message = |id: &str, text: &str, at: &str| Message { diff --git a/crates/plannotator-tui/src/layout.rs b/crates/plannotator-tui/src/layout.rs index 0251e27..bb5ee8a 100644 --- a/crates/plannotator-tui/src/layout.rs +++ b/crates/plannotator-tui/src/layout.rs @@ -12,7 +12,7 @@ use tui_markdown::{Options, StyleSheet}; use crate::doc::{BlockKind, Document}; use crate::srcmap::{LineOffsets, align}; -use crate::wrap::{Row, clip_line, wrap_line}; +use crate::wrap::{Row, clip_line, wrap_line, wrap_table}; /// Rows of vertical space between blocks. const BLOCK_GAP: usize = 1; @@ -111,7 +111,9 @@ impl DocLayout { for block in &mut self.blocks { block.first_row = row; let lines = block.text.lines.iter().zip(&block.offsets); - block.rows = if block.kind.preserves_columns() { + block.rows = if block.kind == BlockKind::Table { + wrap_table(&block.text.lines, &block.offsets, width) + } else if block.kind.preserves_columns() { lines.map(|(l, o)| clip_line(l, o, width)).collect() } else { lines.flat_map(|(l, o)| wrap_line(l, o, width)).collect() diff --git a/crates/plannotator-tui/src/store.rs b/crates/plannotator-tui/src/store.rs index 236a452..5716c98 100644 --- a/crates/plannotator-tui/src/store.rs +++ b/crates/plannotator-tui/src/store.rs @@ -262,6 +262,15 @@ impl Store { Ok(removed) } + /// Remove every annotation belonging to the open document. Returns how many were removed. + pub(crate) fn clear_all(&mut self) -> Result { + let removed = self.annotations.len(); + self.annotations.clear(); + self.resolved.clear(); + self.save()?; + Ok(removed) + } + fn remove_unsaved(&mut self, id: &str) -> bool { let Some(index) = self.annotations.iter().position(|a| a.id == id) else { return false }; self.annotations.remove(index); diff --git a/crates/plannotator-tui/src/wrap.rs b/crates/plannotator-tui/src/wrap.rs index 6df76b3..5e8169c 100644 --- a/crates/plannotator-tui/src/wrap.rs +++ b/crates/plannotator-tui/src/wrap.rs @@ -24,6 +24,13 @@ struct Cell { style: Style, } +#[derive(Clone, Copy)] +enum TableBorder { + Top, + Middle, + Bottom, +} + /// Flatten a line into cells, pairing each char with its source offset. fn cells_of(line: &Line<'_>, offsets: &[Option]) -> Vec { let mut offsets = offsets.iter(); @@ -127,6 +134,218 @@ pub(crate) fn clip_line(line: &Line<'_>, offsets: &[Option], width: usize finish_row(kept, line.style) } +/// Reflow a rendered Unicode table to fit `width`, preserving cell boundaries and source offsets. +/// The Markdown renderer emits a complete table before this layer sees it, so the table is +/// identified from its box-drawing borders and each rendered cell is wrapped independently. +pub(crate) fn wrap_table(lines: &[Line<'_>], offsets: &[Vec>], width: usize) -> Vec { + let Some((original_widths, border_kind, border_style)) = + lines.first().zip(offsets.first()).and_then(|(line, map)| table_border(line, map)) + else { + return lines.iter().zip(offsets).flat_map(|(line, map)| wrap_line(line, map, width)).collect(); + }; + + let column_widths = fit_table_columns(&original_widths, width.max(1)); + let table_wraps = lines + .iter() + .zip(offsets) + .filter_map(|(line, map)| table_content_cells(line, map).map(|(cells, _)| cells)) + .any(|cells| table_content_wraps(&cells, &column_widths)); + let mut out = Vec::new(); + for (line_index, (line, map)) in lines.iter().zip(offsets).enumerate() { + if let Some((_, kind, style)) = table_border(line, map) { + // Keep the detected border kind for each row; the first line's kind only supplies + // the style fallback when a renderer emits an unusual border sequence. + let kind = if matches!(kind, TableBorder::Top | TableBorder::Middle | TableBorder::Bottom) { + kind + } else { + border_kind + }; + out.push(render_table_border(&column_widths, kind, style, border_style)); + } else if let Some((cells, style)) = table_content_cells(line, map) { + let content_rows = render_table_content(&cells, &column_widths, style, border_style); + out.extend(content_rows); + let next_is_content = lines + .get(line_index + 1) + .zip(offsets.get(line_index + 1)) + .is_some_and(|(next_line, next_map)| table_content_cells(next_line, next_map).is_some()); + if table_wraps && next_is_content { + out.push(render_table_border( + &column_widths, + TableBorder::Middle, + border_style, + border_style, + )); + } + } else { + out.extend(wrap_line(line, map, width)); + } + } + out +} + +fn table_border(line: &Line<'_>, offsets: &[Option]) -> Option<(Vec, TableBorder, Style)> { + let cells = cells_of(line, offsets); + let first = cells.first()?.ch; + let (kind, left, intersection, right) = match first { + '┌' => (TableBorder::Top, '┌', '┬', '┐'), + '├' => (TableBorder::Middle, '├', '┼', '┤'), + '└' => (TableBorder::Bottom, '└', '┴', '┘'), + _ => return None, + }; + if cells.last()?.ch != right { + return None; + } + let mut widths = Vec::new(); + let mut current = 0usize; + let mut seen_left = false; + for cell in &cells { + if cell.ch == left && !seen_left { + seen_left = true; + } else if seen_left && cell.ch == '─' { + current += cell.width; + } else if seen_left && matches!(cell.ch, c if c == intersection || c == right) { + if current == 0 { + return None; + } + widths.push(current.saturating_sub(2).max(1)); + current = 0; + } else if seen_left { + return None; + } + } + (!widths.is_empty()).then(|| (widths, kind, cells.first().map_or(Style::default(), |c| c.style))) +} + +fn table_content_cells(line: &Line<'_>, offsets: &[Option]) -> Option<(Vec>, Style)> { + let cells = cells_of(line, offsets); + if cells.first()?.ch != '│' || cells.last()?.ch != '│' { + return None; + } + let separator_indices: Vec = + cells.iter().enumerate().filter_map(|(index, cell)| (cell.ch == '│').then_some(index)).collect(); + if separator_indices.len() < 2 { + return None; + } + let mut columns = Vec::with_capacity(separator_indices.len() - 1); + for pair in separator_indices.windows(2) { + let [left, right] = pair else { continue }; + let mut column = cells.get(left + 1..*right)?.to_vec(); + while column.first().is_some_and(|cell| cell.ch.is_whitespace()) { + column.remove(0); + } + while column.last().is_some_and(|cell| cell.ch.is_whitespace()) { + column.pop(); + } + columns.push(column); + } + Some((columns, cells.first().map_or(Style::default(), |c| c.style))) +} + +fn fit_table_columns(original: &[usize], width: usize) -> Vec { + let columns = original.len(); + let overhead = columns.saturating_mul(3).saturating_add(1); + let content_budget = width.saturating_sub(overhead).max(columns); + let original_total: usize = original.iter().sum(); + if original_total <= content_budget { + return original.to_vec(); + } + let mut fitted = vec![1; columns]; + let mut remaining = content_budget.saturating_sub(columns); + let mut order: Vec = (0..columns).collect(); + order.sort_by_key(|&index| std::cmp::Reverse(original.get(index).copied().unwrap_or(0))); + let mut cursor = 0usize; + while remaining > 0 && !order.is_empty() { + let Some(&index) = order.get(cursor % order.len()) else { break }; + if let Some(slot) = fitted.get_mut(index) { + *slot += 1; + } + cursor += 1; + remaining -= 1; + } + fitted +} + +fn render_table_border(widths: &[usize], kind: TableBorder, style: Style, fallback_style: Style) -> Row { + let (left, intersection, right) = match kind { + TableBorder::Top => ('┌', '┬', '┐'), + TableBorder::Middle => ('├', '┼', '┤'), + TableBorder::Bottom => ('└', '┴', '┘'), + }; + let style = if style == Style::default() { fallback_style } else { style }; + let mut cells = vec![Cell { ch: left, width: 1, offset: None, style }]; + for (index, &width) in widths.iter().enumerate() { + cells.extend((0..width + 2).map(|_| Cell { ch: '─', width: 1, offset: None, style })); + if index + 1 < widths.len() { + cells.push(Cell { ch: intersection, width: 1, offset: None, style }); + } + } + cells.push(Cell { ch: right, width: 1, offset: None, style }); + finish_row(cells, Style::default()) +} + +fn render_table_content( + columns: &[Vec], + widths: &[usize], + cell_style: Style, + border_style: Style, +) -> Vec { + let wrapped: Vec> = widths + .iter() + .enumerate() + .map(|(index, &width)| { + let line = finish_row(columns.get(index).cloned().unwrap_or_default(), cell_style); + wrap_line(&line.line, &line.cells, width.max(1)) + }) + .collect(); + let height = wrapped.iter().map(Vec::len).max().unwrap_or(1); + let mut rows = Vec::with_capacity(height); + for row_index in 0..height { + let mut cells = vec![Cell { ch: '│', width: 1, offset: None, style: border_style }]; + for (column_index, &width) in widths.iter().enumerate() { + let row = wrapped.get(column_index).and_then(|rows| rows.get(row_index)); + let row_style = + row.and_then(|value| value.line.spans.first().map(|span| span.style)).unwrap_or(cell_style); + cells.push(Cell { ch: ' ', width: 1, offset: None, style: row_style }); + if let Some(row) = row { + cells.extend(cells_from_row(row)); + } + let used = row.map_or(0, |value| value.cells.len()); + cells.extend((0..=width.saturating_sub(used)).map(|_| Cell { + ch: ' ', + width: 1, + offset: None, + style: row_style, + })); + cells.push(Cell { ch: '│', width: 1, offset: None, style: border_style }); + } + rows.push(finish_row(cells, Style::default())); + } + rows +} + +fn table_content_wraps(columns: &[Vec], widths: &[usize]) -> bool { + widths.iter().enumerate().any(|(index, &width)| { + let line = finish_row(columns.get(index).cloned().unwrap_or_default(), Style::default()); + wrap_line(&line.line, &line.cells, width.max(1)).len() > 1 + }) +} + +fn cells_from_row(row: &Row) -> Vec { + let mut offsets = row.cells.iter(); + row.line + .spans + .iter() + .flat_map(|span| span.content.chars().map(move |ch| (ch, span.style))) + .map(|(ch, style)| { + let offset = offsets.next().copied().flatten(); + for _ in 1..ch.width().unwrap_or(0) { + let _ = offsets.next(); + } + Cell { ch, width: ch.width().unwrap_or(0), offset, style } + }) + .collect() +} + #[cfg(test)] #[allow(clippy::expect_used, clippy::indexing_slicing, reason = "tests assert by panicking")] mod tests { @@ -175,4 +394,29 @@ mod tests { fn empty_line_is_one_row() { assert_eq!(wrap_line(&Line::from(""), &[], 20).len(), 1); } + + #[test] + fn table_cells_wrap_without_breaking_the_box() { + let lines = [ + Line::from("┌────────────┬──────────────┐"), + Line::from("│ Header │ Description │"), + Line::from("├────────────┼──────────────┤"), + Line::from("│ item │ TABLE_TAIL is a long value │"), + Line::from("│ second │ another long value │"), + Line::from("│ third │ short │"), + Line::from("└────────────┴──────────────┘"), + ]; + let offsets: Vec>> = + lines.iter().map(|line| (0..line.to_string().chars().count()).map(Some).collect()).collect(); + let rows = wrap_table(&lines, &offsets, 24); + let rendered = rows.iter().map(|row| row.line.to_string()).collect::>().join("\n"); + + assert!(rows.iter().all(|row| row.cells.len() <= 24)); + assert!( + rendered.contains("TABLE_TAI") && rendered.contains("L is a"), + "rendered table was {rendered:?}" + ); + assert!(rendered.contains("┌") && rendered.contains("└")); + assert_eq!(rendered.lines().filter(|line| line.starts_with("├")).count(), 3); + } }