Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/plannotator-tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ 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 · `i` moves the cursor with those keys first, so `v` can start mid-block · `j`/`k` or click selects a block · `c` comments on the block · `x` clears the block's annotations |
| document | drag with the mouse, or `v` then `hjkl` / `w` `b` / `0` `$` to select; `Enter` confirms · `i` moves the cursor with those keys first, so `v` can start mid-block · `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) |
Expand Down
12 changes: 7 additions & 5 deletions crates/plannotator-tui/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -110,11 +110,13 @@ impl DocLayout {
let mut row = 0usize;
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() {
lines.map(|(l, o)| clip_line(l, o, width)).collect()
let lines = || block.text.lines.iter().zip(&block.offsets);
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()
lines().flat_map(|(l, o)| wrap_line(l, o, width)).collect()
};
row += block.rows.len() + BLOCK_GAP;
}
Expand Down
84 changes: 57 additions & 27 deletions crates/plannotator-tui/src/wrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
//! `tui-markdown` emits one `Line` per logical line and never wraps; the layout needs
//! exact row counts and the selection needs to know which source byte sits under each
//! screen column, so both live here. Generic text wrapping — no markdown knowledge.
//! Reflowing a rendered table is the one shape-aware job, and it lives in `wrap::table`.

mod table;

use ratatui::style::Style;
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthChar;

pub(crate) use table::wrap_table;

/// One screen row: styled text plus, per column, the source byte it came from.
#[derive(Debug)]
pub(crate) struct Row {
Expand Down Expand Up @@ -39,11 +44,37 @@ fn cells_of(line: &Line<'_>, offsets: &[Option<usize>]) -> Vec<Cell> {
.collect()
}

/// Turn accumulated cells into a row, merging same-style runs into spans.
fn finish_row(mut cells: Vec<Cell>, line_style: Style) -> Row {
/// Drop trailing whitespace: a wrapped piece can end on the space it broke at.
fn trim_trailing_space(mut cells: Vec<Cell>) -> Vec<Cell> {
while cells.last().is_some_and(|c| c.ch.is_whitespace()) {
cells.pop();
}
cells
}

/// Keep the leading cells that fit `width` columns, dropping the rest.
fn clip_cells(cells: Vec<Cell>, width: usize) -> Vec<Cell> {
let mut used = 0usize;
cells
.into_iter()
.take_while(|cell| {
let fits = used + cell.width <= width;
if fits {
used += cell.width;
}
fits
})
.collect()
}

/// Columns a run of cells occupies on screen.
fn cells_width(cells: &[Cell]) -> usize {
cells.iter().map(|cell| cell.width).sum()
}

/// Turn accumulated cells into a row, merging same-style runs into spans.
fn finish_row(cells: Vec<Cell>, line_style: Style) -> Row {
let cells = trim_trailing_space(cells);
let mut spans: Vec<Span<'static>> = Vec::new();
let mut columns: Vec<Option<usize>> = Vec::new();
for cell in &cells {
Expand All @@ -56,26 +87,27 @@ fn finish_row(mut cells: Vec<Cell>, line_style: Style) -> Row {
Row { line: Line::from(spans).style(line_style), cells: columns }
}

/// Wrap one logical line into as many rows as needed for `width` columns.
/// `offsets` has one entry per char of the line. An empty line yields one empty row.
pub(crate) fn wrap_line(line: &Line<'_>, offsets: &[Option<usize>], width: usize) -> Vec<Row> {
/// Split cells into the runs that fit `width` columns each. One empty piece for no cells.
///
/// Cells, not `Row`s: a `Row` records one offset per *column*, so reading offsets back out
/// of one would mis-pair them after any wide or zero-width char.
fn wrap_cells(cells: &[Cell], width: usize) -> Vec<Vec<Cell>> {
let width = width.max(1);
let cells = cells_of(line, offsets);
let mut rows: Vec<Row> = Vec::new();
let mut pieces: Vec<Vec<Cell>> = Vec::new();
let mut current: Vec<Cell> = Vec::new();
let mut current_width = 0usize;

// Tokens are unbreakable runs: a word, or a run of whitespace.
let mut rest = cells.as_slice();
let mut rest = cells;
while let Some(first) = rest.first() {
let is_space = first.ch.is_whitespace();
let len = rest.iter().take_while(|c| c.ch.is_whitespace() == is_space).count();
let (token, tail) = rest.split_at(len);
rest = tail;
let token_width: usize = token.iter().map(|c| c.width).sum();
let token_width = cells_width(token);

// Whitespace at a row start is dropped, except leading indentation on the first row.
if is_space && current.is_empty() && !rows.is_empty() {
if is_space && current.is_empty() && !pieces.is_empty() {
continue;
}
if current_width + token_width <= width {
Expand All @@ -84,7 +116,7 @@ pub(crate) fn wrap_line(line: &Line<'_>, offsets: &[Option<usize>], width: usize
continue;
}
if !current.is_empty() {
rows.push(finish_row(std::mem::take(&mut current), line.style));
pieces.push(std::mem::take(&mut current));
current_width = 0;
if is_space {
continue;
Expand All @@ -97,34 +129,32 @@ pub(crate) fn wrap_line(line: &Line<'_>, offsets: &[Option<usize>], width: usize
// Token wider than a row: hard-split by cells.
for cell in token {
if current_width + cell.width > width && !current.is_empty() {
rows.push(finish_row(std::mem::take(&mut current), line.style));
pieces.push(std::mem::take(&mut current));
current_width = 0;
}
current.push(*cell);
current_width += cell.width;
}
}
}
if !current.is_empty() || rows.is_empty() {
rows.push(finish_row(current, line.style));
if !current.is_empty() || pieces.is_empty() {
pieces.push(current);
}
rows
pieces
}

/// Wrap one logical line into as many rows as needed for `width` columns.
/// `offsets` has one entry per char of the line. An empty line yields one empty row.
pub(crate) fn wrap_line(line: &Line<'_>, offsets: &[Option<usize>], width: usize) -> Vec<Row> {
wrap_cells(&cells_of(line, offsets), width)
.into_iter()
.map(|piece| finish_row(piece, line.style))
.collect()
}

/// Keep one row; clip anything past `width` (code and tables keep their columns).
pub(crate) fn clip_line(line: &Line<'_>, offsets: &[Option<usize>], width: usize) -> Row {
let mut used = 0usize;
let kept = cells_of(line, offsets)
.into_iter()
.take_while(|cell| {
let fits = used + cell.width <= width;
if fits {
used += cell.width;
}
fits
})
.collect();
finish_row(kept, line.style)
finish_row(clip_cells(cells_of(line, offsets), width), line.style)
}

#[cfg(test)]
Expand Down
Loading
Loading