feat(tui): Phase 2 Issue List + MR List screens

Implement state, action, and view layers for both list screens:
- Issue List: keyset pagination, snapshot fence, filter DSL, label aggregation
- MR List: mirrors Issue pattern with draft/reviewer/target branch filters
- Migration 027: covering indexes for TUI list screen queries
- Updated Msg types to use typed Page structs instead of raw Vec<Row>
- 303 tests passing, clippy clean

Beads: bd-3ei1, bd-2kr0, bd-3pm2
This commit is contained in:
teernisse
2026-02-18 13:06:06 -05:00
parent c5b7f4c864
commit 90c8b43267
33 changed files with 7850 additions and 483 deletions

View File

@@ -0,0 +1,676 @@
#![allow(dead_code)] // Phase 2: consumed by Issue List + MR List screens
//! Generic entity table widget for list screens.
//!
//! `EntityTable<R>` renders rows with sortable, responsive columns.
//! Columns hide gracefully when the terminal is too narrow, using
//! priority-based visibility.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
// ---------------------------------------------------------------------------
// Column definition
// ---------------------------------------------------------------------------
/// Describes a single table column.
#[derive(Debug, Clone)]
pub struct ColumnDef {
/// Display name shown in the header.
pub name: &'static str,
/// Minimum width in characters. Column is hidden if it can't meet this.
pub min_width: u16,
/// Flex weight for distributing extra space.
pub flex_weight: u16,
/// Visibility priority (0 = always shown, higher = hidden first).
pub priority: u8,
/// Text alignment within the column.
pub align: Align,
}
/// Text alignment within a column.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Align {
#[default]
Left,
Right,
}
// ---------------------------------------------------------------------------
// TableRow trait
// ---------------------------------------------------------------------------
/// Trait for types that can be rendered as a table row.
pub trait TableRow {
/// Return the cell text for each column, in column order.
fn cells(&self, col_count: usize) -> Vec<String>;
}
// ---------------------------------------------------------------------------
// EntityTable state
// ---------------------------------------------------------------------------
/// Rendering state for the entity table.
#[derive(Debug, Clone)]
pub struct EntityTableState {
/// Index of the selected row (0-based, within the full data set).
pub selected: usize,
/// Scroll offset (first visible row index).
pub scroll_offset: usize,
/// Index of the column used for sorting.
pub sort_column: usize,
/// Sort direction.
pub sort_ascending: bool,
}
impl Default for EntityTableState {
fn default() -> Self {
Self {
selected: 0,
scroll_offset: 0,
sort_column: 0,
sort_ascending: true,
}
}
}
impl EntityTableState {
/// Move selection down by 1.
pub fn select_next(&mut self, total_rows: usize) {
if total_rows == 0 {
return;
}
self.selected = (self.selected + 1).min(total_rows - 1);
}
/// Move selection up by 1.
pub fn select_prev(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
/// Page down (move by `page_size` rows).
pub fn page_down(&mut self, total_rows: usize, page_size: usize) {
if total_rows == 0 {
return;
}
self.selected = (self.selected + page_size).min(total_rows - 1);
}
/// Page up.
pub fn page_up(&mut self, page_size: usize) {
self.selected = self.selected.saturating_sub(page_size);
}
/// Jump to top.
pub fn select_first(&mut self) {
self.selected = 0;
}
/// Jump to bottom.
pub fn select_last(&mut self, total_rows: usize) {
if total_rows > 0 {
self.selected = total_rows - 1;
}
}
/// Cycle sort column forward (wraps around).
pub fn cycle_sort(&mut self, col_count: usize) {
if col_count == 0 {
return;
}
self.sort_column = (self.sort_column + 1) % col_count;
}
/// Toggle sort direction on current column.
pub fn toggle_sort_direction(&mut self) {
self.sort_ascending = !self.sort_ascending;
}
/// Ensure scroll offset keeps selection visible.
fn adjust_scroll(&mut self, visible_rows: usize) {
if visible_rows == 0 {
return;
}
if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
}
if self.selected >= self.scroll_offset + visible_rows {
self.scroll_offset = self.selected - visible_rows + 1;
}
}
}
// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
/// Colors for the entity table. Will be replaced by Theme injection.
pub struct TableColors {
pub header_fg: PackedRgba,
pub header_bg: PackedRgba,
pub row_fg: PackedRgba,
pub row_alt_bg: PackedRgba,
pub selected_fg: PackedRgba,
pub selected_bg: PackedRgba,
pub sort_indicator: PackedRgba,
pub border: PackedRgba,
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Compute which columns are visible given the available width.
///
/// Returns indices of visible columns sorted by original order,
/// along with their allocated widths.
pub fn visible_columns(columns: &[ColumnDef], available_width: u16) -> Vec<(usize, u16)> {
// Sort by priority (lowest = most important).
let mut indexed: Vec<(usize, &ColumnDef)> = columns.iter().enumerate().collect();
indexed.sort_by_key(|(_, col)| col.priority);
let mut result: Vec<(usize, u16)> = Vec::new();
let mut used_width: u16 = 0;
let gap = 1u16; // 1-char gap between columns.
for (idx, col) in &indexed {
let needed = col.min_width + if result.is_empty() { 0 } else { gap };
if used_width + needed <= available_width {
result.push((*idx, col.min_width));
used_width += needed;
}
}
// Distribute remaining space by flex weight.
let remaining = available_width.saturating_sub(used_width);
if remaining > 0 {
let total_weight: u16 = result
.iter()
.map(|(idx, _)| columns[*idx].flex_weight)
.sum();
if total_weight > 0 {
for (idx, width) in &mut result {
let weight = columns[*idx].flex_weight;
let extra =
(u32::from(remaining) * u32::from(weight) / u32::from(total_weight)) as u16;
*width += extra;
}
}
}
// Sort by original column order for rendering.
result.sort_by_key(|(idx, _)| *idx);
result
}
/// Render the entity table header row.
pub fn render_header(
frame: &mut Frame<'_>,
columns: &[ColumnDef],
visible: &[(usize, u16)],
state: &EntityTableState,
y: u16,
area_x: u16,
colors: &TableColors,
) {
let header_cell = Cell {
fg: colors.header_fg,
bg: colors.header_bg,
..Cell::default()
};
let sort_cell = Cell {
fg: colors.sort_indicator,
bg: colors.header_bg,
..Cell::default()
};
// Fill header background.
let total_width: u16 = visible.iter().map(|(_, w)| w + 1).sum();
let header_rect = Rect::new(area_x, y, total_width, 1);
frame.draw_rect_filled(
header_rect,
Cell {
bg: colors.header_bg,
..Cell::default()
},
);
let mut x = area_x;
for (col_idx, col_width) in visible {
let col = &columns[*col_idx];
let col_max = x.saturating_add(*col_width);
let after_name = frame.print_text_clipped(x, y, col.name, header_cell, col_max);
// Sort indicator.
if *col_idx == state.sort_column {
let arrow = if state.sort_ascending { " ^" } else { " v" };
frame.print_text_clipped(after_name, y, arrow, sort_cell, col_max);
}
x = col_max.saturating_add(1); // gap
}
}
/// Style context for rendering a single row.
pub struct RowContext<'a> {
pub columns: &'a [ColumnDef],
pub visible: &'a [(usize, u16)],
pub is_selected: bool,
pub is_alt: bool,
pub colors: &'a TableColors,
}
/// Render a data row.
pub fn render_row<R: TableRow>(
frame: &mut Frame<'_>,
row: &R,
y: u16,
area_x: u16,
ctx: &RowContext<'_>,
) {
let (fg, bg) = if ctx.is_selected {
(ctx.colors.selected_fg, ctx.colors.selected_bg)
} else if ctx.is_alt {
(ctx.colors.row_fg, ctx.colors.row_alt_bg)
} else {
(ctx.colors.row_fg, Cell::default().bg)
};
let cell_style = Cell {
fg,
bg,
..Cell::default()
};
// Fill row background if selected or alt.
if ctx.is_selected || ctx.is_alt {
let total_width: u16 = ctx.visible.iter().map(|(_, w)| w + 1).sum();
frame.draw_rect_filled(
Rect::new(area_x, y, total_width, 1),
Cell {
bg,
..Cell::default()
},
);
}
let cells = row.cells(ctx.columns.len());
let mut x = area_x;
for (col_idx, col_width) in ctx.visible {
let col_max = x.saturating_add(*col_width);
let text = cells.get(*col_idx).map(String::as_str).unwrap_or("");
match ctx.columns[*col_idx].align {
Align::Left => {
frame.print_text_clipped(x, y, text, cell_style, col_max);
}
Align::Right => {
let text_len = text.len() as u16;
let start = if text_len < *col_width {
x + col_width - text_len
} else {
x
};
frame.print_text_clipped(start, y, text, cell_style, col_max);
}
}
x = col_max.saturating_add(1); // gap
}
}
/// Render a complete entity table: header + scrollable rows.
pub fn render_entity_table<R: TableRow>(
frame: &mut Frame<'_>,
rows: &[R],
columns: &[ColumnDef],
state: &mut EntityTableState,
area: Rect,
colors: &TableColors,
) {
if area.height < 2 || area.width < 5 {
return;
}
let visible = visible_columns(columns, area.width);
if visible.is_empty() {
return;
}
// Header row.
render_header(frame, columns, &visible, state, area.y, area.x, colors);
// Separator.
let sep_y = area.y.saturating_add(1);
let sep_cell = Cell {
fg: colors.border,
..Cell::default()
};
let rule = "".repeat(area.width as usize);
frame.print_text_clipped(
area.x,
sep_y,
&rule,
sep_cell,
area.x.saturating_add(area.width),
);
// Data rows.
let data_start_y = area.y.saturating_add(2);
let visible_rows = area.height.saturating_sub(2) as usize; // minus header + separator
state.adjust_scroll(visible_rows);
let start = state.scroll_offset;
let end = (start + visible_rows).min(rows.len());
for (i, row) in rows[start..end].iter().enumerate() {
let row_y = data_start_y.saturating_add(i as u16);
let absolute_idx = start + i;
let ctx = RowContext {
columns,
visible: &visible,
is_selected: absolute_idx == state.selected,
is_alt: absolute_idx % 2 == 1,
colors,
};
render_row(frame, row, row_y, area.x, &ctx);
}
// Scroll indicator if more rows below.
if end < rows.len() {
let indicator_y = data_start_y.saturating_add(visible_rows as u16);
if indicator_y < area.y.saturating_add(area.height) {
let muted = Cell {
fg: colors.border,
..Cell::default()
};
let remaining = rows.len() - end;
frame.print_text_clipped(
area.x,
indicator_y,
&format!("... {remaining} more"),
muted,
area.x.saturating_add(area.width),
);
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn test_columns() -> Vec<ColumnDef> {
vec![
ColumnDef {
name: "IID",
min_width: 5,
flex_weight: 0,
priority: 0,
align: Align::Right,
},
ColumnDef {
name: "Title",
min_width: 10,
flex_weight: 3,
priority: 0,
align: Align::Left,
},
ColumnDef {
name: "State",
min_width: 8,
flex_weight: 1,
priority: 1,
align: Align::Left,
},
ColumnDef {
name: "Author",
min_width: 8,
flex_weight: 1,
priority: 2,
align: Align::Left,
},
ColumnDef {
name: "Updated",
min_width: 10,
flex_weight: 0,
priority: 3,
align: Align::Right,
},
]
}
struct TestRow {
cells: Vec<String>,
}
impl TableRow for TestRow {
fn cells(&self, _col_count: usize) -> Vec<String> {
self.cells.clone()
}
}
fn test_colors() -> TableColors {
TableColors {
header_fg: PackedRgba::rgb(0xFF, 0xFF, 0xFF),
header_bg: PackedRgba::rgb(0x30, 0x30, 0x30),
row_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
row_alt_bg: PackedRgba::rgb(0x28, 0x28, 0x24),
selected_fg: PackedRgba::rgb(0xFF, 0xFF, 0xFF),
selected_bg: PackedRgba::rgb(0xDA, 0x70, 0x2C),
sort_indicator: PackedRgba::rgb(0xDA, 0x70, 0x2C),
border: PackedRgba::rgb(0x87, 0x87, 0x80),
}
}
#[test]
fn test_visible_columns_all_fit() {
let cols = test_columns();
let vis = visible_columns(&cols, 100);
assert_eq!(vis.len(), 5, "All 5 columns should fit at 100 cols");
}
#[test]
fn test_visible_columns_hides_low_priority() {
let cols = test_columns();
// min widths: 5 + 10 + 8 + 8 + 10 + 4 gaps = 45.
// At 25 cols, only priority 0 columns (IID + Title) should fit.
let vis = visible_columns(&cols, 25);
let visible_indices: Vec<usize> = vis.iter().map(|(idx, _)| *idx).collect();
assert!(visible_indices.contains(&0), "IID should always be visible");
assert!(
visible_indices.contains(&1),
"Title should always be visible"
);
assert!(
!visible_indices.contains(&4),
"Updated (priority 3) should be hidden"
);
}
#[test]
fn test_column_hiding_at_60_cols() {
let cols = test_columns();
let vis = visible_columns(&cols, 60);
// min widths for priority 0,1,2: 5+10+8+8 + 3 gaps = 34.
// Priority 3 (Updated, min 10 + gap) = 45 total, should still fit.
assert!(vis.len() >= 3, "At least 3 columns at 60 cols");
}
#[test]
fn test_state_select_next_prev() {
let mut state = EntityTableState::default();
state.select_next(5);
assert_eq!(state.selected, 1);
state.select_next(5);
assert_eq!(state.selected, 2);
state.select_prev();
assert_eq!(state.selected, 1);
}
#[test]
fn test_state_select_bounds() {
let mut state = EntityTableState::default();
state.select_prev(); // at 0, can't go below
assert_eq!(state.selected, 0);
state.select_next(3);
state.select_next(3);
state.select_next(3); // at 2, can't go above last
assert_eq!(state.selected, 2);
}
#[test]
fn test_state_page_up_down() {
let mut state = EntityTableState::default();
state.page_down(20, 5);
assert_eq!(state.selected, 5);
state.page_up(3);
assert_eq!(state.selected, 2);
}
#[test]
fn test_state_first_last() {
let mut state = EntityTableState {
selected: 5,
..Default::default()
};
state.select_first();
assert_eq!(state.selected, 0);
state.select_last(10);
assert_eq!(state.selected, 9);
}
#[test]
fn test_state_cycle_sort() {
let mut state = EntityTableState::default();
assert_eq!(state.sort_column, 0);
state.cycle_sort(5);
assert_eq!(state.sort_column, 1);
state.sort_column = 4;
state.cycle_sort(5); // wraps to 0
assert_eq!(state.sort_column, 0);
}
#[test]
fn test_state_toggle_sort_direction() {
let mut state = EntityTableState::default();
assert!(state.sort_ascending);
state.toggle_sort_direction();
assert!(!state.sort_ascending);
}
#[test]
fn test_state_adjust_scroll() {
let mut state = EntityTableState {
selected: 15,
scroll_offset: 0,
..Default::default()
};
state.adjust_scroll(10);
assert_eq!(state.scroll_offset, 6); // selected=15 should be at bottom of 10-row window
}
#[test]
fn test_render_entity_table_no_panic() {
with_frame!(80, 20, |frame| {
let cols = test_columns();
let rows = vec![
TestRow {
cells: vec![
"#42".into(),
"Fix auth bug".into(),
"opened".into(),
"taylor".into(),
"2h ago".into(),
],
},
TestRow {
cells: vec![
"#43".into(),
"Add tests".into(),
"merged".into(),
"alice".into(),
"1d ago".into(),
],
},
];
let mut state = EntityTableState::default();
let colors = test_colors();
render_entity_table(
&mut frame,
&rows,
&cols,
&mut state,
Rect::new(0, 0, 80, 20),
&colors,
);
});
}
#[test]
fn test_render_entity_table_tiny_noop() {
with_frame!(3, 1, |frame| {
let cols = test_columns();
let rows: Vec<TestRow> = vec![];
let mut state = EntityTableState::default();
let colors = test_colors();
render_entity_table(
&mut frame,
&rows,
&cols,
&mut state,
Rect::new(0, 0, 3, 1),
&colors,
);
});
}
#[test]
fn test_render_entity_table_empty_rows() {
with_frame!(80, 10, |frame| {
let cols = test_columns();
let rows: Vec<TestRow> = vec![];
let mut state = EntityTableState::default();
let colors = test_colors();
render_entity_table(
&mut frame,
&rows,
&cols,
&mut state,
Rect::new(0, 0, 80, 10),
&colors,
);
});
}
#[test]
fn test_state_select_next_empty() {
let mut state = EntityTableState::default();
state.select_next(0); // no rows
assert_eq!(state.selected, 0);
}
}

View File

@@ -23,7 +23,15 @@ pub fn render_error_toast(
let max_toast_width = (area.width / 2).clamp(20, 60);
let toast_text = if msg.len() as u16 > max_toast_width.saturating_sub(4) {
let trunc_len = max_toast_width.saturating_sub(7) as usize;
format!(" {}... ", &msg[..trunc_len.min(msg.len())])
// Find a char boundary at or before trunc_len to avoid panicking
// on multi-byte UTF-8 (e.g., emoji or CJK in error messages).
let safe_end = msg
.char_indices()
.take_while(|&(i, _)| i <= trunc_len)
.last()
.map_or(0, |(i, c)| i + c.len_utf8())
.min(msg.len());
format!(" {}... ", &msg[..safe_end])
} else {
format!(" {msg} ")
};

View File

@@ -0,0 +1,469 @@
#![allow(dead_code)] // Phase 2: consumed by Issue List + MR List screens
//! Filter bar widget for list screens.
//!
//! Wraps a text input with DSL parsing, inline diagnostics for unknown
//! fields, and rendered filter chips below the input.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::filter_dsl::{self, FilterToken};
// ---------------------------------------------------------------------------
// Filter bar state
// ---------------------------------------------------------------------------
/// State for the filter bar widget.
#[derive(Debug, Clone, Default)]
pub struct FilterBarState {
/// Current filter input text.
pub input: String,
/// Cursor position within the input string (byte offset).
pub cursor: usize,
/// Whether the filter bar has focus.
pub focused: bool,
/// Parsed tokens from the current input.
pub tokens: Vec<FilterToken>,
/// Fields that are unknown for the current entity type.
pub unknown_fields: Vec<String>,
}
impl FilterBarState {
/// Update parsed tokens from the current input text.
pub fn reparse(&mut self, known_fields: &[&str]) {
self.tokens = filter_dsl::parse_filter_tokens(&self.input);
self.unknown_fields = filter_dsl::unknown_fields(&self.tokens, known_fields)
.into_iter()
.map(String::from)
.collect();
}
/// Insert a character at the cursor position.
pub fn insert_char(&mut self, ch: char) {
if self.cursor > self.input.len() {
self.cursor = self.input.len();
}
self.input.insert(self.cursor, ch);
self.cursor += ch.len_utf8();
}
/// Delete the character before the cursor (backspace).
pub fn delete_back(&mut self) {
if self.cursor > 0 && !self.input.is_empty() {
// Find the previous character boundary.
let prev = self.input[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
self.input.remove(prev);
self.cursor = prev;
}
}
/// Delete the character at the cursor (delete key).
pub fn delete_forward(&mut self) {
if self.cursor < self.input.len() {
self.input.remove(self.cursor);
}
}
/// Move cursor left by one character.
pub fn move_left(&mut self) {
if self.cursor > 0 {
self.cursor = self.input[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
}
}
/// Move cursor right by one character.
pub fn move_right(&mut self) {
if self.cursor < self.input.len() {
self.cursor = self.input[self.cursor..]
.chars()
.next()
.map(|ch| self.cursor + ch.len_utf8())
.unwrap_or(self.input.len());
}
}
/// Move cursor to start.
pub fn move_home(&mut self) {
self.cursor = 0;
}
/// Move cursor to end.
pub fn move_end(&mut self) {
self.cursor = self.input.len();
}
/// Clear the input.
pub fn clear(&mut self) {
self.input.clear();
self.cursor = 0;
self.tokens.clear();
self.unknown_fields.clear();
}
/// Whether the filter has any active tokens.
pub fn is_active(&self) -> bool {
!self.tokens.is_empty()
}
}
// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
/// Colors for the filter bar.
pub struct FilterBarColors {
pub input_fg: PackedRgba,
pub input_bg: PackedRgba,
pub cursor_fg: PackedRgba,
pub cursor_bg: PackedRgba,
pub chip_fg: PackedRgba,
pub chip_bg: PackedRgba,
pub error_fg: PackedRgba,
pub label_fg: PackedRgba,
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Render the filter bar.
///
/// Layout:
/// ```text
/// Row 0: [Filter: ][input text with cursor___________]
/// Row 1: [chip1] [chip2] [chip3] (if tokens present)
/// ```
///
/// Returns the number of rows consumed (1 or 2).
pub fn render_filter_bar(
frame: &mut Frame<'_>,
state: &FilterBarState,
area: Rect,
colors: &FilterBarColors,
) -> u16 {
if area.height == 0 || area.width < 10 {
return 0;
}
let max_x = area.x.saturating_add(area.width);
let y = area.y;
// Label.
let label = if state.focused { "Filter: " } else { "/ " };
let label_cell = Cell {
fg: colors.label_fg,
..Cell::default()
};
let after_label = frame.print_text_clipped(area.x, y, label, label_cell, max_x);
// Input text.
let input_cell = Cell {
fg: colors.input_fg,
bg: if state.focused {
colors.input_bg
} else {
Cell::default().bg
},
..Cell::default()
};
if state.input.is_empty() && !state.focused {
let muted = Cell {
fg: colors.label_fg,
..Cell::default()
};
frame.print_text_clipped(after_label, y, "type / to filter", muted, max_x);
} else {
// Render input text with cursor highlight.
render_input_with_cursor(frame, state, after_label, y, max_x, input_cell, colors);
}
// Error indicators for unknown fields.
if !state.unknown_fields.is_empty() {
let err_cell = Cell {
fg: colors.error_fg,
..Cell::default()
};
let err_msg = format!("Unknown: {}", state.unknown_fields.join(", "));
// Right-align the error.
let err_x = max_x.saturating_sub(err_msg.len() as u16 + 1);
frame.print_text_clipped(err_x, y, &err_msg, err_cell, max_x);
}
// Chip row (if tokens present and space available).
if !state.tokens.is_empty() && area.height >= 2 {
let chip_y = y.saturating_add(1);
render_chips(frame, &state.tokens, area.x, chip_y, max_x, colors);
return 2;
}
1
}
/// Render input text with cursor highlight at the correct position.
fn render_input_with_cursor(
frame: &mut Frame<'_>,
state: &FilterBarState,
start_x: u16,
y: u16,
max_x: u16,
base_cell: Cell,
colors: &FilterBarColors,
) {
if !state.focused {
frame.print_text_clipped(start_x, y, &state.input, base_cell, max_x);
return;
}
// Split at cursor position.
let cursor = state.cursor;
let input = &state.input;
let (before, after) = if cursor <= input.len() {
(&input[..cursor], &input[cursor..])
} else {
(input.as_str(), "")
};
let mut x = frame.print_text_clipped(start_x, y, before, base_cell, max_x);
// Cursor character (or space if at end).
let cursor_cell = Cell {
fg: colors.cursor_fg,
bg: colors.cursor_bg,
..Cell::default()
};
if let Some(ch) = after.chars().next() {
let s = String::from(ch);
x = frame.print_text_clipped(x, y, &s, cursor_cell, max_x);
let remaining = &after[ch.len_utf8()..];
frame.print_text_clipped(x, y, remaining, base_cell, max_x);
} else {
// Cursor at end — render a visible block.
frame.print_text_clipped(x, y, " ", cursor_cell, max_x);
}
}
/// Render filter chips as compact tags.
fn render_chips(
frame: &mut Frame<'_>,
tokens: &[FilterToken],
start_x: u16,
y: u16,
max_x: u16,
colors: &FilterBarColors,
) {
let chip_cell = Cell {
fg: colors.chip_fg,
bg: colors.chip_bg,
..Cell::default()
};
let mut x = start_x;
for token in tokens {
if x >= max_x {
break;
}
let label = match token {
FilterToken::FieldValue { field, value } => format!("{field}:{value}"),
FilterToken::Negation { field, value } => format!("-{field}:{value}"),
FilterToken::FreeText(text) => text.clone(),
FilterToken::QuotedValue(text) => format!("\"{text}\""),
};
let chip_text = format!("[{label}]");
x = frame.print_text_clipped(x, y, &chip_text, chip_cell, max_x);
x = x.saturating_add(1); // gap between chips
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::filter_dsl::ISSUE_FIELDS;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn test_colors() -> FilterBarColors {
FilterBarColors {
input_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
input_bg: PackedRgba::rgb(0x28, 0x28, 0x24),
cursor_fg: PackedRgba::rgb(0x00, 0x00, 0x00),
cursor_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
chip_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
chip_bg: PackedRgba::rgb(0x40, 0x40, 0x3C),
error_fg: PackedRgba::rgb(0xAF, 0x3A, 0x29),
label_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
}
}
#[test]
fn test_filter_bar_state_insert_char() {
let mut state = FilterBarState::default();
state.insert_char('a');
state.insert_char('b');
assert_eq!(state.input, "ab");
assert_eq!(state.cursor, 2);
}
#[test]
fn test_filter_bar_state_delete_back() {
let mut state = FilterBarState {
input: "abc".into(),
cursor: 3,
..Default::default()
};
state.delete_back();
assert_eq!(state.input, "ab");
assert_eq!(state.cursor, 2);
}
#[test]
fn test_filter_bar_state_delete_back_at_start() {
let mut state = FilterBarState {
input: "abc".into(),
cursor: 0,
..Default::default()
};
state.delete_back();
assert_eq!(state.input, "abc");
assert_eq!(state.cursor, 0);
}
#[test]
fn test_filter_bar_state_move_left_right() {
let mut state = FilterBarState {
input: "abc".into(),
cursor: 2,
..Default::default()
};
state.move_left();
assert_eq!(state.cursor, 1);
state.move_right();
assert_eq!(state.cursor, 2);
}
#[test]
fn test_filter_bar_state_home_end() {
let mut state = FilterBarState {
input: "hello".into(),
cursor: 3,
..Default::default()
};
state.move_home();
assert_eq!(state.cursor, 0);
state.move_end();
assert_eq!(state.cursor, 5);
}
#[test]
fn test_filter_bar_state_clear() {
let mut state = FilterBarState {
input: "state:opened".into(),
cursor: 12,
tokens: vec![FilterToken::FieldValue {
field: "state".into(),
value: "opened".into(),
}],
..Default::default()
};
state.clear();
assert!(state.input.is_empty());
assert_eq!(state.cursor, 0);
assert!(state.tokens.is_empty());
}
#[test]
fn test_filter_bar_state_reparse() {
let mut state = FilterBarState {
input: "state:opened bogus:val".into(),
..Default::default()
};
state.reparse(ISSUE_FIELDS);
assert_eq!(state.tokens.len(), 2);
assert_eq!(state.unknown_fields, vec!["bogus"]);
}
#[test]
fn test_filter_bar_state_is_active() {
let mut state = FilterBarState::default();
assert!(!state.is_active());
state.input = "state:opened".into();
state.reparse(ISSUE_FIELDS);
assert!(state.is_active());
}
#[test]
fn test_render_filter_bar_unfocused_no_panic() {
with_frame!(80, 2, |frame| {
let state = FilterBarState::default();
let colors = test_colors();
let rows = render_filter_bar(&mut frame, &state, Rect::new(0, 0, 80, 2), &colors);
assert_eq!(rows, 1);
});
}
#[test]
fn test_render_filter_bar_focused_no_panic() {
with_frame!(80, 2, |frame| {
let mut state = FilterBarState {
input: "state:opened".into(),
cursor: 12,
focused: true,
..Default::default()
};
state.reparse(ISSUE_FIELDS);
let colors = test_colors();
let rows = render_filter_bar(&mut frame, &state, Rect::new(0, 0, 80, 2), &colors);
assert_eq!(rows, 2); // chips rendered
});
}
#[test]
fn test_render_filter_bar_tiny_noop() {
with_frame!(5, 1, |frame| {
let state = FilterBarState::default();
let colors = test_colors();
let rows = render_filter_bar(&mut frame, &state, Rect::new(0, 0, 5, 1), &colors);
assert_eq!(rows, 0);
});
}
#[test]
fn test_filter_bar_unicode_cursor() {
let mut state = FilterBarState {
input: "author:田中".into(),
cursor: 7, // points at start of 田
..Default::default()
};
state.move_right();
assert_eq!(state.cursor, 10); // past 田 (3 bytes)
state.move_left();
assert_eq!(state.cursor, 7); // back to 田
}
}

View File

@@ -5,13 +5,17 @@
//! no side effects.
mod breadcrumb;
pub mod entity_table;
mod error_toast;
pub mod filter_bar;
mod help_overlay;
mod loading;
mod status_bar;
pub use breadcrumb::render_breadcrumb;
pub use entity_table::{ColumnDef, EntityTableState, TableColors, TableRow, render_entity_table};
pub use error_toast::render_error_toast;
pub use filter_bar::{FilterBarColors, FilterBarState, render_filter_bar};
pub use help_overlay::render_help_overlay;
pub use loading::render_loading;
pub use status_bar::render_status_bar;

View File

@@ -0,0 +1,554 @@
#![allow(dead_code)] // Phase 2: wired into render_screen dispatch
//! Dashboard screen view — entity counts, project sync status, recent activity.
//!
//! Responsive layout using [`crate::layout::classify_width`]:
//! - Wide (Lg/Xl, >=120 cols): 3-column `[Stats | Projects | Recent]`
//! - Medium (Md, 90119): 2-column `[Stats+Projects | Recent]`
//! - Narrow (Xs/Sm, <90): single column stacked
use ftui::core::geometry::Rect;
use ftui::layout::{Breakpoint, Constraint, Flex};
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::layout::classify_width;
use crate::state::dashboard::{DashboardState, EntityCounts, LastSyncInfo, RecentActivityItem};
// ---------------------------------------------------------------------------
// Colors (Flexoki palette — will use injected Theme in a later phase)
// ---------------------------------------------------------------------------
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // green
const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15); // yellow
const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // cyan
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
/// Render the full dashboard screen into `area`.
pub fn render_dashboard(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
if area.height < 2 || area.width < 10 {
return; // Too small to render.
}
let bp = classify_width(area.width);
match bp {
Breakpoint::Lg | Breakpoint::Xl => render_wide(frame, state, area),
Breakpoint::Md => render_medium(frame, state, area),
Breakpoint::Xs | Breakpoint::Sm => render_narrow(frame, state, area),
}
}
// ---------------------------------------------------------------------------
// Layout variants
// ---------------------------------------------------------------------------
/// Wide: 3-column [Stats | Projects | Recent Activity].
fn render_wide(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
let cols = Flex::horizontal()
.constraints([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(area);
render_stat_panel(frame, &state.counts, cols[0]);
render_project_list(frame, state, cols[1]);
render_recent_activity(frame, state, cols[2]);
}
/// Medium: 2-column [Stats+Projects stacked | Recent Activity].
fn render_medium(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
let cols = Flex::horizontal()
.constraints([Constraint::Ratio(2, 5), Constraint::Ratio(3, 5)])
.split(area);
// Left column: stats on top, projects below.
let left_rows = Flex::vertical()
.constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)])
.split(cols[0]);
render_stat_panel(frame, &state.counts, left_rows[0]);
render_project_list(frame, state, left_rows[1]);
render_recent_activity(frame, state, cols[1]);
}
/// Narrow: single column stacked.
fn render_narrow(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
let rows = Flex::vertical()
.constraints([
Constraint::Fixed(8), // stats
Constraint::Fixed(4), // projects (compact)
Constraint::Fill, // recent
])
.split(area);
render_stat_panel(frame, &state.counts, rows[0]);
render_project_list(frame, state, rows[1]);
render_recent_activity(frame, state, rows[2]);
}
// ---------------------------------------------------------------------------
// Panels
// ---------------------------------------------------------------------------
/// Entity counts panel.
fn render_stat_panel(frame: &mut Frame<'_>, counts: &EntityCounts, area: Rect) {
if area.height == 0 || area.width < 5 {
return;
}
let title_cell = Cell {
fg: ACCENT,
..Cell::default()
};
let label_cell = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let value_cell = Cell {
fg: TEXT,
..Cell::default()
};
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
let x = area.x.saturating_add(1); // 1-char left padding
// Title
frame.print_text_clipped(x, y, "Entity Counts", title_cell, max_x);
y = y.saturating_add(1);
// Separator
render_horizontal_rule(frame, area.x, y, area.width, BORDER);
y = y.saturating_add(1);
// Stats rows
let stats: &[(&str, String)] = &[
(
"Issues",
format!("{} open / {}", counts.issues_open, counts.issues_total),
),
(
"MRs",
format!("{} open / {}", counts.mrs_open, counts.mrs_total),
),
("Discussions", counts.discussions.to_string()),
(
"Notes",
format!(
"{} ({}% system)",
counts.notes_total, counts.notes_system_pct
),
),
("Documents", counts.documents.to_string()),
("Embeddings", counts.embeddings.to_string()),
];
for (label, value) in stats {
if y >= area.y.saturating_add(area.height) {
break;
}
let after_label = frame.print_text_clipped(x, y, label, label_cell, max_x);
let after_colon = frame.print_text_clipped(after_label, y, ": ", label_cell, max_x);
frame.print_text_clipped(after_colon, y, value, value_cell, max_x);
y = y.saturating_add(1);
}
}
/// Per-project sync freshness list.
fn render_project_list(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
if area.height == 0 || area.width < 5 {
return;
}
let title_cell = Cell {
fg: ACCENT,
..Cell::default()
};
let label_cell = Cell {
fg: TEXT,
..Cell::default()
};
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
let x = area.x.saturating_add(1);
frame.print_text_clipped(x, y, "Projects", title_cell, max_x);
y = y.saturating_add(1);
render_horizontal_rule(frame, area.x, y, area.width, BORDER);
y = y.saturating_add(1);
if state.projects.is_empty() {
let muted = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
frame.print_text_clipped(x, y, "No projects synced", muted, max_x);
return;
}
for proj in &state.projects {
if y >= area.y.saturating_add(area.height) {
break;
}
let freshness_color = staleness_color(proj.minutes_since_sync);
let freshness_cell = Cell {
fg: freshness_color,
..Cell::default()
};
let indicator = staleness_indicator(proj.minutes_since_sync);
let after_dot = frame.print_text_clipped(x, y, &indicator, freshness_cell, max_x);
let after_space = frame.print_text_clipped(after_dot, y, " ", label_cell, max_x);
frame.print_text_clipped(after_space, y, &proj.path, label_cell, max_x);
y = y.saturating_add(1);
}
// Last sync summary if available.
if let Some(ref sync) = state.last_sync
&& y < area.y.saturating_add(area.height)
{
y = y.saturating_add(1); // blank line
render_sync_summary(frame, sync, x, y, max_x);
}
}
/// Scrollable recent activity list.
fn render_recent_activity(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
if area.height == 0 || area.width < 5 {
return;
}
let title_cell = Cell {
fg: ACCENT,
..Cell::default()
};
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
let x = area.x.saturating_add(1);
frame.print_text_clipped(x, y, "Recent Activity", title_cell, max_x);
y = y.saturating_add(1);
render_horizontal_rule(frame, area.x, y, area.width, BORDER);
y = y.saturating_add(1);
if state.recent.is_empty() {
let muted = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
frame.print_text_clipped(x, y, "No recent activity", muted, max_x);
return;
}
let visible_rows = (area.y.saturating_add(area.height)).saturating_sub(y) as usize;
let items = &state.recent;
let start = state.scroll_offset.min(items.len().saturating_sub(1));
let end = (start + visible_rows).min(items.len());
for item in &items[start..end] {
if y >= area.y.saturating_add(area.height) {
break;
}
render_activity_row(frame, item, x, y, max_x);
y = y.saturating_add(1);
}
// Scroll indicator if there's more content.
if end < items.len() && y < area.y.saturating_add(area.height) {
let muted = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let remaining = items.len() - end;
frame.print_text_clipped(x, y, &format!("... {remaining} more"), muted, max_x);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Render a single recent activity row.
fn render_activity_row(
frame: &mut Frame<'_>,
item: &RecentActivityItem,
x: u16,
y: u16,
max_x: u16,
) {
let type_color = if item.entity_type == "issue" {
CYAN
} else {
ACCENT
};
let type_cell = Cell {
fg: type_color,
..Cell::default()
};
let text_cell = Cell {
fg: TEXT,
..Cell::default()
};
let muted_cell = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let type_label = if item.entity_type == "issue" {
format!("#{}", item.iid)
} else {
format!("!{}", item.iid)
};
let after_type = frame.print_text_clipped(x, y, &type_label, type_cell, max_x);
let after_space = frame.print_text_clipped(after_type, y, " ", text_cell, max_x);
// Truncate title to leave room for time.
let time_str = format_relative_time(item.minutes_ago);
let time_width = time_str.len() as u16 + 2; // " " + time
let title_max = max_x.saturating_sub(time_width);
let after_title = frame.print_text_clipped(after_space, y, &item.title, text_cell, title_max);
// Right-align time string.
let time_x = max_x.saturating_sub(time_str.len() as u16 + 1);
if time_x > after_title {
frame.print_text_clipped(time_x, y, &time_str, muted_cell, max_x);
}
}
/// Render a last-sync summary line.
fn render_sync_summary(frame: &mut Frame<'_>, sync: &LastSyncInfo, x: u16, y: u16, max_x: u16) {
let status_color = if sync.status == "succeeded" {
GREEN
} else {
RED
};
let cell = Cell {
fg: status_color,
..Cell::default()
};
let muted = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let label_end = frame.print_text_clipped(x, y, "Last sync: ", muted, max_x);
let status_end = frame.print_text_clipped(label_end, y, &sync.status, cell, max_x);
if let Some(ref err) = sync.error {
let err_cell = Cell {
fg: RED,
..Cell::default()
};
let after_space = frame.print_text_clipped(status_end, y, "", muted, max_x);
frame.print_text_clipped(after_space, y, err, err_cell, max_x);
}
}
/// Draw a horizontal rule across a row.
fn render_horizontal_rule(frame: &mut Frame<'_>, x: u16, y: u16, width: u16, color: PackedRgba) {
let cell = Cell {
fg: color,
..Cell::default()
};
let rule = "".repeat(width as usize);
frame.print_text_clipped(x, y, &rule, cell, x.saturating_add(width));
}
/// Staleness color: green <60min, yellow <360min, red >360min.
const fn staleness_color(minutes: u64) -> PackedRgba {
if minutes == u64::MAX {
RED // Never synced.
} else if minutes < 60 {
GREEN
} else if minutes < 360 {
YELLOW
} else {
RED
}
}
/// Staleness dot indicator.
fn staleness_indicator(minutes: u64) -> String {
if minutes == u64::MAX {
"● never".to_string()
} else if minutes < 60 {
format!("{minutes}m ago")
} else if minutes < 1440 {
format!("{}h ago", minutes / 60)
} else {
format!("{}d ago", minutes / 1440)
}
}
/// Format relative time for activity feed.
fn format_relative_time(minutes: u64) -> String {
if minutes == 0 {
"just now".to_string()
} else if minutes < 60 {
format!("{minutes}m ago")
} else if minutes < 1440 {
format!("{}h ago", minutes / 60)
} else {
format!("{}d ago", minutes / 1440)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::state::dashboard::{DashboardData, EntityCounts, ProjectSyncInfo};
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_state() -> DashboardState {
let mut state = DashboardState::default();
state.update(DashboardData {
counts: EntityCounts {
issues_open: 42,
issues_total: 100,
mrs_open: 10,
mrs_total: 50,
discussions: 200,
notes_total: 500,
notes_system_pct: 30,
documents: 80,
embeddings: 75,
},
projects: vec![
ProjectSyncInfo {
path: "group/alpha".into(),
minutes_since_sync: 15,
},
ProjectSyncInfo {
path: "group/beta".into(),
minutes_since_sync: 120,
},
],
recent: vec![RecentActivityItem {
entity_type: "issue".into(),
iid: 42,
title: "Fix authentication bug".into(),
state: "opened".into(),
minutes_ago: 5,
}],
last_sync: None,
});
state
}
#[test]
fn test_render_dashboard_wide_no_panic() {
with_frame!(140, 30, |frame| {
let state = sample_state();
let area = Rect::new(0, 0, 140, 30);
render_dashboard(&mut frame, &state, area);
});
}
#[test]
fn test_render_dashboard_medium_no_panic() {
with_frame!(100, 24, |frame| {
let state = sample_state();
let area = Rect::new(0, 0, 100, 24);
render_dashboard(&mut frame, &state, area);
});
}
#[test]
fn test_render_dashboard_narrow_no_panic() {
with_frame!(60, 20, |frame| {
let state = sample_state();
let area = Rect::new(0, 0, 60, 20);
render_dashboard(&mut frame, &state, area);
});
}
#[test]
fn test_render_dashboard_tiny_noop() {
with_frame!(5, 1, |frame| {
let state = DashboardState::default();
let area = Rect::new(0, 0, 5, 1);
render_dashboard(&mut frame, &state, area);
});
}
#[test]
fn test_render_dashboard_empty_state_no_panic() {
with_frame!(120, 24, |frame| {
let state = DashboardState::default();
let area = Rect::new(0, 0, 120, 24);
render_dashboard(&mut frame, &state, area);
});
}
#[test]
fn test_staleness_color_thresholds() {
assert_eq!(staleness_color(0), GREEN);
assert_eq!(staleness_color(59), GREEN);
assert_eq!(staleness_color(60), YELLOW);
assert_eq!(staleness_color(359), YELLOW);
assert_eq!(staleness_color(360), RED);
assert_eq!(staleness_color(u64::MAX), RED);
}
#[test]
fn test_staleness_indicator() {
assert_eq!(staleness_indicator(15), "● 15m ago");
assert_eq!(staleness_indicator(120), "● 2h ago");
assert_eq!(staleness_indicator(2880), "● 2d ago");
assert_eq!(staleness_indicator(u64::MAX), "● never");
}
#[test]
fn test_format_relative_time() {
assert_eq!(format_relative_time(0), "just now");
assert_eq!(format_relative_time(5), "5m ago");
assert_eq!(format_relative_time(90), "1h ago");
assert_eq!(format_relative_time(1500), "1d ago");
}
#[test]
fn test_stat_panel_renders_title() {
with_frame!(40, 10, |frame| {
let counts = EntityCounts {
issues_open: 3,
issues_total: 10,
..Default::default()
};
render_stat_panel(&mut frame, &counts, Rect::new(0, 0, 40, 10));
// Check that 'E' from "Entity Counts" is rendered at x=1, y=0.
let cell = frame.buffer.get(1, 0).unwrap();
assert_eq!(cell.content.as_char(), Some('E'), "Expected 'E' at (1,0)");
});
}
}

View File

@@ -0,0 +1,353 @@
#![allow(dead_code)] // Phase 2: consumed by view/mod.rs screen dispatch
//! Issue list screen view.
//!
//! Composes the reusable [`EntityTable`] and [`FilterBar`] widgets
//! with issue-specific column definitions and [`TableRow`] implementation.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::state::issue_list::{IssueListRow, IssueListState, SortField, SortOrder};
use crate::view::common::entity_table::{
Align, ColumnDef, EntityTableState, TableColors, TableRow, render_entity_table,
};
use crate::view::common::filter_bar::{FilterBarColors, FilterBarState, render_filter_bar};
// ---------------------------------------------------------------------------
// TableRow implementation for IssueListRow
// ---------------------------------------------------------------------------
impl TableRow for IssueListRow {
fn cells(&self, col_count: usize) -> Vec<String> {
let mut cells = Vec::with_capacity(col_count);
// Column order must match ISSUE_COLUMNS definition.
// 0: IID
cells.push(format!("#{}", self.iid));
// 1: Title
cells.push(self.title.clone());
// 2: State
cells.push(self.state.clone());
// 3: Author
cells.push(self.author.clone());
// 4: Labels
cells.push(self.labels.join(", "));
// 5: Project
cells.push(self.project_path.clone());
cells.truncate(col_count);
cells
}
}
// ---------------------------------------------------------------------------
// Column definitions
// ---------------------------------------------------------------------------
/// Column definitions for the issue list table.
const ISSUE_COLUMNS: &[ColumnDef] = &[
ColumnDef {
name: "IID",
min_width: 5,
flex_weight: 0,
priority: 0,
align: Align::Right,
},
ColumnDef {
name: "Title",
min_width: 15,
flex_weight: 4,
priority: 0,
align: Align::Left,
},
ColumnDef {
name: "State",
min_width: 7,
flex_weight: 0,
priority: 0,
align: Align::Left,
},
ColumnDef {
name: "Author",
min_width: 8,
flex_weight: 1,
priority: 1,
align: Align::Left,
},
ColumnDef {
name: "Labels",
min_width: 10,
flex_weight: 2,
priority: 2,
align: Align::Left,
},
ColumnDef {
name: "Project",
min_width: 12,
flex_weight: 1,
priority: 3,
align: Align::Left,
},
];
// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
fn table_colors() -> TableColors {
TableColors {
header_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
header_bg: PackedRgba::rgb(0x34, 0x34, 0x31),
row_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
row_alt_bg: PackedRgba::rgb(0x1C, 0x1B, 0x1A),
selected_fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
selected_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
sort_indicator: PackedRgba::rgb(0x87, 0x96, 0x6B),
border: PackedRgba::rgb(0x40, 0x40, 0x3C),
}
}
fn filter_colors() -> FilterBarColors {
FilterBarColors {
input_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
input_bg: PackedRgba::rgb(0x28, 0x28, 0x24),
cursor_fg: PackedRgba::rgb(0x00, 0x00, 0x00),
cursor_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
chip_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
chip_bg: PackedRgba::rgb(0x40, 0x40, 0x3C),
error_fg: PackedRgba::rgb(0xAF, 0x3A, 0x29),
label_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Render the full issue list screen.
///
/// Layout:
/// ```text
/// Row 0: [Filter bar: / filter input_________]
/// Row 1: [chip1] [chip2] (if filter active)
/// Row 2: ─────────────────────────────────────
/// Row 3..N: IID Title State Author ...
/// ───────────────────────────────────────
/// #42 Fix login bug open alice ...
/// #41 Add tests open bob ...
/// Bottom: Showing 42 of 128 issues
/// ```
pub fn render_issue_list(frame: &mut Frame<'_>, state: &IssueListState, area: Rect) {
if area.height < 3 || area.width < 10 {
return;
}
let mut y = area.y;
let max_x = area.x.saturating_add(area.width);
// -- Filter bar ---------------------------------------------------------
let filter_area = Rect::new(area.x, y, area.width, 2.min(area.height));
let fb_state = FilterBarState {
input: state.filter_input.clone(),
cursor: state.filter_input.len(),
focused: state.filter_focused,
tokens: crate::filter_dsl::parse_filter_tokens(&state.filter_input),
unknown_fields: Vec::new(),
};
let filter_rows = render_filter_bar(frame, &fb_state, filter_area, &filter_colors());
y = y.saturating_add(filter_rows);
// -- Status line (total count) ------------------------------------------
let remaining_height = area.height.saturating_sub(y - area.y);
if remaining_height < 2 {
return;
}
// Reserve bottom row for status.
let table_height = remaining_height.saturating_sub(1);
let status_y = y.saturating_add(table_height);
// -- Entity table -------------------------------------------------------
let sort_col = match state.sort_field {
SortField::UpdatedAt => 0, // Map to IID column (closest visual proxy)
SortField::Iid => 0,
SortField::Title => 1,
SortField::State => 2,
SortField::Author => 3,
};
let mut table_state = EntityTableState {
selected: state.selected_index,
scroll_offset: state.scroll_offset,
sort_column: sort_col,
sort_ascending: matches!(state.sort_order, SortOrder::Asc),
};
let table_area = Rect::new(area.x, y, area.width, table_height);
render_entity_table(
frame,
&state.rows,
ISSUE_COLUMNS,
&mut table_state,
table_area,
&table_colors(),
);
// -- Bottom status ------------------------------------------------------
if status_y < area.y.saturating_add(area.height) {
render_status_line(frame, state, area.x, status_y, max_x);
}
}
/// Render the bottom status line showing row count and pagination info.
fn render_status_line(frame: &mut Frame<'_>, state: &IssueListState, x: u16, y: u16, max_x: u16) {
let muted = Cell {
fg: PackedRgba::rgb(0x87, 0x87, 0x80),
..Cell::default()
};
let status = if state.rows.is_empty() {
"No issues found".to_string()
} else {
let showing = state.rows.len();
let total = state.total_count;
if state.next_cursor.is_some() {
format!("Showing {showing} of {total} issues (more available)")
} else {
format!("Showing {showing} of {total} issues")
}
};
frame.print_text_clipped(x, y, &status, muted, max_x);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_state(row_count: usize) -> IssueListState {
let rows: Vec<IssueListRow> = (0..row_count)
.map(|i| IssueListRow {
project_path: "group/project".into(),
iid: (i + 1) as i64,
title: format!("Issue {}", i + 1),
state: if i % 2 == 0 { "opened" } else { "closed" }.into(),
author: "taylor".into(),
labels: if i == 0 {
vec!["bug".into(), "critical".into()]
} else {
vec![]
},
updated_at: 1_700_000_000_000 - (i as i64 * 60_000),
})
.collect();
IssueListState {
total_count: row_count as u64,
rows,
..Default::default()
}
}
#[test]
fn test_render_issue_list_no_panic() {
with_frame!(120, 30, |frame| {
let state = sample_state(10);
render_issue_list(&mut frame, &state, Rect::new(0, 0, 120, 30));
});
}
#[test]
fn test_render_issue_list_empty_no_panic() {
with_frame!(80, 20, |frame| {
let state = IssueListState::default();
render_issue_list(&mut frame, &state, Rect::new(0, 0, 80, 20));
});
}
#[test]
fn test_render_issue_list_tiny_noop() {
with_frame!(5, 2, |frame| {
let state = sample_state(5);
render_issue_list(&mut frame, &state, Rect::new(0, 0, 5, 2));
// Should not panic with too-small area.
});
}
#[test]
fn test_render_issue_list_narrow_no_panic() {
with_frame!(40, 15, |frame| {
let state = sample_state(5);
render_issue_list(&mut frame, &state, Rect::new(0, 0, 40, 15));
});
}
#[test]
fn test_render_issue_list_with_filter_no_panic() {
with_frame!(100, 25, |frame| {
let mut state = sample_state(5);
state.filter_input = "state:opened".into();
state.filter_focused = true;
render_issue_list(&mut frame, &state, Rect::new(0, 0, 100, 25));
});
}
#[test]
fn test_issue_list_row_cells() {
let row = IssueListRow {
project_path: "group/proj".into(),
iid: 42,
title: "Fix bug".into(),
state: "opened".into(),
author: "alice".into(),
labels: vec!["bug".into(), "urgent".into()],
updated_at: 1_700_000_000_000,
};
let cells = row.cells(6);
assert_eq!(cells[0], "#42");
assert_eq!(cells[1], "Fix bug");
assert_eq!(cells[2], "opened");
assert_eq!(cells[3], "alice");
assert_eq!(cells[4], "bug, urgent");
assert_eq!(cells[5], "group/proj");
}
#[test]
fn test_issue_list_row_cells_truncated() {
let row = IssueListRow {
project_path: "g/p".into(),
iid: 1,
title: "t".into(),
state: "opened".into(),
author: "a".into(),
labels: vec![],
updated_at: 0,
};
// Request fewer columns than available.
let cells = row.cells(3);
assert_eq!(cells.len(), 3);
}
#[test]
fn test_column_count() {
assert_eq!(ISSUE_COLUMNS.len(), 6);
}
}

View File

@@ -7,16 +7,23 @@
//! bar, and optional overlays (help, error toast).
pub mod common;
pub mod dashboard;
pub mod issue_list;
pub mod mr_list;
use ftui::layout::{Constraint, Flex};
use ftui::render::cell::PackedRgba;
use ftui::render::frame::Frame;
use crate::app::LoreApp;
use crate::message::Screen;
use common::{
render_breadcrumb, render_error_toast, render_help_overlay, render_loading, render_status_bar,
};
use dashboard::render_dashboard;
use issue_list::render_issue_list;
use mr_list::render_mr_list;
// ---------------------------------------------------------------------------
// Colors (hardcoded Flexoki palette — will use Theme in Phase 2)
@@ -79,12 +86,14 @@ pub fn render_screen(frame: &mut Frame<'_>, app: &LoreApp) {
// tick=0 placeholder — animation wired up when Msg::Tick increments a counter.
render_loading(frame, content_area, load_state, TEXT, TEXT_MUTED, 0);
// Per-screen content dispatch (Phase 2+).
// match screen {
// Screen::Dashboard => ...,
// Screen::IssueList => ...,
// ...
// }
// Per-screen content dispatch (other screens wired in later phases).
if screen == &Screen::Dashboard {
render_dashboard(frame, &app.state.dashboard, content_area);
} else if screen == &Screen::IssueList {
render_issue_list(frame, &app.state.issue_list, content_area);
} else if screen == &Screen::MrList {
render_mr_list(frame, &app.state.mr_list, content_area);
}
// --- Status bar ---
render_status_bar(

View File

@@ -0,0 +1,390 @@
#![allow(dead_code)] // Phase 2: consumed by view/mod.rs screen dispatch
//! MR list screen view.
//!
//! Composes the reusable [`EntityTable`] and [`FilterBar`] widgets
//! with MR-specific column definitions and [`TableRow`] implementation.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::state::mr_list::{MrListRow, MrListState, MrSortField, MrSortOrder};
use crate::view::common::entity_table::{
Align, ColumnDef, EntityTableState, TableColors, TableRow, render_entity_table,
};
use crate::view::common::filter_bar::{FilterBarColors, FilterBarState, render_filter_bar};
// ---------------------------------------------------------------------------
// TableRow implementation for MrListRow
// ---------------------------------------------------------------------------
impl TableRow for MrListRow {
fn cells(&self, col_count: usize) -> Vec<String> {
let mut cells = Vec::with_capacity(col_count);
// Column order must match MR_COLUMNS definition.
// 0: IID (with draft indicator)
let iid_text = if self.draft {
format!("!{} [WIP]", self.iid)
} else {
format!("!{}", self.iid)
};
cells.push(iid_text);
// 1: Title
cells.push(self.title.clone());
// 2: State
cells.push(self.state.clone());
// 3: Author
cells.push(self.author.clone());
// 4: Target Branch
cells.push(self.target_branch.clone());
// 5: Labels
cells.push(self.labels.join(", "));
// 6: Project
cells.push(self.project_path.clone());
cells.truncate(col_count);
cells
}
}
// ---------------------------------------------------------------------------
// Column definitions
// ---------------------------------------------------------------------------
/// Column definitions for the MR list table.
const MR_COLUMNS: &[ColumnDef] = &[
ColumnDef {
name: "IID",
min_width: 6,
flex_weight: 0,
priority: 0,
align: Align::Right,
},
ColumnDef {
name: "Title",
min_width: 15,
flex_weight: 4,
priority: 0,
align: Align::Left,
},
ColumnDef {
name: "State",
min_width: 7,
flex_weight: 0,
priority: 0,
align: Align::Left,
},
ColumnDef {
name: "Author",
min_width: 8,
flex_weight: 1,
priority: 1,
align: Align::Left,
},
ColumnDef {
name: "Target",
min_width: 8,
flex_weight: 1,
priority: 1,
align: Align::Left,
},
ColumnDef {
name: "Labels",
min_width: 10,
flex_weight: 2,
priority: 2,
align: Align::Left,
},
ColumnDef {
name: "Project",
min_width: 12,
flex_weight: 1,
priority: 3,
align: Align::Left,
},
];
// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
fn table_colors() -> TableColors {
TableColors {
header_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
header_bg: PackedRgba::rgb(0x34, 0x34, 0x31),
row_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
row_alt_bg: PackedRgba::rgb(0x1C, 0x1B, 0x1A),
selected_fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
selected_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
sort_indicator: PackedRgba::rgb(0x87, 0x96, 0x6B),
border: PackedRgba::rgb(0x40, 0x40, 0x3C),
}
}
fn filter_colors() -> FilterBarColors {
FilterBarColors {
input_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
input_bg: PackedRgba::rgb(0x28, 0x28, 0x24),
cursor_fg: PackedRgba::rgb(0x00, 0x00, 0x00),
cursor_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
chip_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
chip_bg: PackedRgba::rgb(0x40, 0x40, 0x3C),
error_fg: PackedRgba::rgb(0xAF, 0x3A, 0x29),
label_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Render the full MR list screen.
///
/// Layout:
/// ```text
/// Row 0: [Filter bar: / filter input_________]
/// Row 1: [chip1] [chip2] (if filter active)
/// Row 2: -----------------------------------------
/// Row 3..N: IID Title State Author ...
/// -----------------------------------------
/// !42 Fix pipeline opened alice ...
/// !41 Add CI config merged bob ...
/// Bottom: Showing 42 of 128 merge requests
/// ```
pub fn render_mr_list(frame: &mut Frame<'_>, state: &MrListState, area: Rect) {
if area.height < 3 || area.width < 10 {
return;
}
let mut y = area.y;
let max_x = area.x.saturating_add(area.width);
// -- Filter bar ---------------------------------------------------------
let filter_area = Rect::new(area.x, y, area.width, 2.min(area.height));
let fb_state = FilterBarState {
input: state.filter_input.clone(),
cursor: state.filter_input.len(),
focused: state.filter_focused,
tokens: crate::filter_dsl::parse_filter_tokens(&state.filter_input),
unknown_fields: Vec::new(),
};
let filter_rows = render_filter_bar(frame, &fb_state, filter_area, &filter_colors());
y = y.saturating_add(filter_rows);
// -- Status line (total count) ------------------------------------------
let remaining_height = area.height.saturating_sub(y - area.y);
if remaining_height < 2 {
return;
}
// Reserve bottom row for status.
let table_height = remaining_height.saturating_sub(1);
let status_y = y.saturating_add(table_height);
// -- Entity table -------------------------------------------------------
let sort_col = match state.sort_field {
MrSortField::UpdatedAt | MrSortField::Iid => 0,
MrSortField::Title => 1,
MrSortField::State => 2,
MrSortField::Author => 3,
MrSortField::TargetBranch => 4,
};
let mut table_state = EntityTableState {
selected: state.selected_index,
scroll_offset: state.scroll_offset,
sort_column: sort_col,
sort_ascending: matches!(state.sort_order, MrSortOrder::Asc),
};
let table_area = Rect::new(area.x, y, area.width, table_height);
render_entity_table(
frame,
&state.rows,
MR_COLUMNS,
&mut table_state,
table_area,
&table_colors(),
);
// -- Bottom status ------------------------------------------------------
if status_y < area.y.saturating_add(area.height) {
render_status_line(frame, state, area.x, status_y, max_x);
}
}
/// Render the bottom status line showing row count and pagination info.
fn render_status_line(frame: &mut Frame<'_>, state: &MrListState, x: u16, y: u16, max_x: u16) {
let muted = Cell {
fg: PackedRgba::rgb(0x87, 0x87, 0x80),
..Cell::default()
};
let status = if state.rows.is_empty() {
"No merge requests found".to_string()
} else {
let showing = state.rows.len();
let total = state.total_count;
if state.next_cursor.is_some() {
format!("Showing {showing} of {total} merge requests (more available)")
} else {
format!("Showing {showing} of {total} merge requests")
}
};
frame.print_text_clipped(x, y, &status, muted, max_x);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_state(row_count: usize) -> MrListState {
let rows: Vec<MrListRow> = (0..row_count)
.map(|i| MrListRow {
project_path: "group/project".into(),
iid: (i + 1) as i64,
title: format!("MR {}", i + 1),
state: if i % 2 == 0 { "opened" } else { "merged" }.into(),
author: "taylor".into(),
target_branch: "main".into(),
labels: if i == 0 {
vec!["backend".into(), "urgent".into()]
} else {
vec![]
},
updated_at: 1_700_000_000_000 - (i as i64 * 60_000),
draft: i % 3 == 0,
})
.collect();
MrListState {
total_count: row_count as u64,
rows,
..Default::default()
}
}
#[test]
fn test_render_mr_list_no_panic() {
with_frame!(120, 30, |frame| {
let state = sample_state(10);
render_mr_list(&mut frame, &state, Rect::new(0, 0, 120, 30));
});
}
#[test]
fn test_render_mr_list_empty_no_panic() {
with_frame!(80, 20, |frame| {
let state = MrListState::default();
render_mr_list(&mut frame, &state, Rect::new(0, 0, 80, 20));
});
}
#[test]
fn test_render_mr_list_tiny_noop() {
with_frame!(5, 2, |frame| {
let state = sample_state(5);
render_mr_list(&mut frame, &state, Rect::new(0, 0, 5, 2));
});
}
#[test]
fn test_render_mr_list_narrow_no_panic() {
with_frame!(40, 15, |frame| {
let state = sample_state(5);
render_mr_list(&mut frame, &state, Rect::new(0, 0, 40, 15));
});
}
#[test]
fn test_render_mr_list_with_filter_no_panic() {
with_frame!(100, 25, |frame| {
let mut state = sample_state(5);
state.filter_input = "state:opened".into();
state.filter_focused = true;
render_mr_list(&mut frame, &state, Rect::new(0, 0, 100, 25));
});
}
#[test]
fn test_mr_list_row_cells() {
let row = MrListRow {
project_path: "group/proj".into(),
iid: 42,
title: "Fix pipeline".into(),
state: "opened".into(),
author: "alice".into(),
target_branch: "main".into(),
labels: vec!["backend".into(), "urgent".into()],
updated_at: 1_700_000_000_000,
draft: false,
};
let cells = row.cells(7);
assert_eq!(cells[0], "!42");
assert_eq!(cells[1], "Fix pipeline");
assert_eq!(cells[2], "opened");
assert_eq!(cells[3], "alice");
assert_eq!(cells[4], "main");
assert_eq!(cells[5], "backend, urgent");
assert_eq!(cells[6], "group/proj");
}
#[test]
fn test_mr_list_row_cells_draft() {
let row = MrListRow {
project_path: "g/p".into(),
iid: 7,
title: "WIP MR".into(),
state: "opened".into(),
author: "bob".into(),
target_branch: "develop".into(),
labels: vec![],
updated_at: 0,
draft: true,
};
let cells = row.cells(7);
assert_eq!(cells[0], "!7 [WIP]");
}
#[test]
fn test_mr_list_row_cells_truncated() {
let row = MrListRow {
project_path: "g/p".into(),
iid: 1,
title: "t".into(),
state: "opened".into(),
author: "a".into(),
target_branch: "main".into(),
labels: vec![],
updated_at: 0,
draft: false,
};
let cells = row.cells(3);
assert_eq!(cells.len(), 3);
}
#[test]
fn test_column_count() {
assert_eq!(MR_COLUMNS.len(), 7);
}
}