tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
use ratatui::Frame;
use ratatui::buffer::{Buffer, CellDiffOption};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};

use crate::model::Issue;

use std::num::NonZeroU16;

mod render;
mod state;
pub use render::*;
pub use state::*;
// ---------------------------------------------------------------------------
// The time table (WP05 T024), shared by the two surfaces that draw one
// ---------------------------------------------------------------------------
//
// Both the pane's time block and the `F7` Stats page's Time tab list the same
// entries in the same columns, so the row and the dash rule are written once, here,
// and imported by the tab. They live beside the pane because the pane's block is
// where the table is introduced; nothing here computes a figure — every cell is a
// field of WP03's [`crate::time_tracking::TimeEntry`], and the *total* those rows
// belong to is the shared [`crate::time_tracking::aggregate::TimeTotal`]'s.

/// An absent number as `-`, the crate's convention for "no value".
///
/// The rendering `commands::time`'s own private `number` performs, restated here
/// because that helper belongs to the command adapter and the TUI must not reach
/// into it: a zero total and a scope with nothing to total never print the same
/// bytes (C-005, [`crate::time_tracking::aggregate::TimeTotalState`]).
pub(crate) fn dash_number<T: std::fmt::Display>(value: Option<T>) -> String {
    value.map_or_else(|| "-".to_string(), |value| value.to_string())
}

/// One table cell: `-` when the value is absent or blank, on one line whatever the
/// file holds.
///
/// The collapse is the renderer's own guarantee, not the caller's: a hand-edited
/// note that is a YAML literal block round-trips a line break, and a table row that
/// broke across two lines would misread as a field nobody wrote.
pub(crate) fn dash_cell(value: Option<&str>) -> String {
    let value = value.unwrap_or_default().trim();
    if value.is_empty() {
        return "-".to_string();
    }
    value
        .chars()
        .map(|ch| if ch == '\n' || ch == '\r' { ' ' } else { ch })
        .collect()
}

/// The table's heading line, for a surface that draws one.
pub(crate) fn time_entry_heading() -> String {
    format!(
        "{:<18} {:>7}  {:<10}  {:<14}  {}",
        TIME_COLUMNS[0], TIME_COLUMNS[1], TIME_COLUMNS[2], TIME_COLUMNS[3], TIME_COLUMNS[4]
    )
}

/// One current entry as one line of the shared time table (WP05 T024).
///
/// The columns are the entry's own fields — the stable id, the minutes, the optional
/// date, the author and the optional note — each rendered through the dash rule, so
/// a malformed entry reads as *absent* rather than as zero and its finding (which
/// the caller draws beside the total) explains why. The id is the one field the
/// service's one-line `entry_summary` does not carry, which is why this table is
/// built from the fields rather than reusing that summary.
pub(crate) fn time_entry_row(entry: &crate::time_tracking::TimeEntry) -> String {
    let cells = [
        dash_cell(Some(entry.id.as_str())),
        dash_number(entry.minutes()),
        dash_cell(entry.date.as_deref()),
        dash_cell(Some(entry.author.as_str())),
        dash_cell(entry.note.as_deref()),
    ];
    let mut row = String::new();
    for (index, cell) in cells.iter().enumerate() {
        if index > 0 {
            row.push_str("  ");
        }
        let width = TIME_WIDTHS[index];
        if index == 1 {
            row.push_str(&format!("{cell:>width$}"));
        } else if width == 0 {
            row.push_str(cell);
        } else {
            row.push_str(&format!("{cell:<width$}"));
        }
    }
    row
}

#[cfg(test)]
mod tests;