diff --git a/crates/kebab-tui/src/library.rs b/crates/kebab-tui/src/library.rs index a0a6fc8..4332d04 100644 --- a/crates/kebab-tui/src/library.rs +++ b/crates/kebab-tui/src/library.rs @@ -199,13 +199,29 @@ fn render_doc_list(f: &mut Frame, area: Rect, state: &App) { "Library" }; let block = Block::default().title(header_text).borders(Borders::ALL); + let block_inner = block.inner(area); + f.render_widget(block, area); if inner.docs.is_empty() { - f.render_widget(block, area); return; } - let title_w = (area.width as usize).saturating_sub(40).max(20); + // p9-fb-24: split the inner area into a 1-row column header on top + // and the doc list below. Header reuses the same width math as + // `format_doc_row` so labels line up with their data columns. + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(0)]) + .split(block_inner); + let header_area = layout[0]; + let list_area = layout[1]; + + let title_w = (list_area.width as usize).saturating_sub(40).max(20); + + let header_para = Paragraph::new(format_doc_header(title_w)) + .style(state.theme.style(crate::theme::Role::Heading)); + f.render_widget(header_para, header_area); + let items: Vec = inner .docs .iter() @@ -213,12 +229,11 @@ fn render_doc_list(f: &mut Frame, area: Rect, state: &App) { .collect(); let list = List::new(items) - .block(block) .highlight_style(state.theme.style(crate::theme::Role::Selected)) .highlight_symbol("> "); let mut list_state = inner.list_state.clone(); - f.render_stateful_widget(list, area, &mut list_state); + f.render_stateful_widget(list, list_area, &mut list_state); } /// p9-fb-24: render the column-label row that sits directly above @@ -229,9 +244,6 @@ fn render_doc_list(f: &mut Frame, area: Rect, state: &App) { /// Layout: `TITLE TAGS UPDATED CHUNKS`. /// The title column width matches `area.width.saturating_sub(40).max(20)` /// — the same calculation `render_doc_list` uses for `title_w`. -/// -/// Task 5 wires it into render_doc_list. -#[allow(dead_code)] pub(crate) fn format_doc_header(title_w: usize) -> Line<'static> { let title_label = "TITLE"; let tags_label = "TAGS"; diff --git a/crates/kebab-tui/tests/library.rs b/crates/kebab-tui/tests/library.rs index 1cbfff3..44f0dba 100644 --- a/crates/kebab-tui/tests/library.rs +++ b/crates/kebab-tui/tests/library.rs @@ -286,6 +286,52 @@ fn filter_overlay_render_places_cursor_on_focused_field() { ); } +/// p9-fb-24: rendered Library pane shows the column header row above +/// the data rows. Header is in `Role::Heading` style; data rows in +/// the `Role::Body` / `Role::Selected` defaults. +#[test] +fn library_renders_column_header_row() { + let docs = vec![ + make_doc("notes/alpha.md", "doc-alpha", vec!["rust"]), + make_doc("notes/beta.md", "doc-beta", vec!["docs"]), + make_doc("notes/gamma.md", "doc-gamma", vec![]), + ]; + let app = app_with_docs(docs); + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + let area = Rect::new(0, 0, 80, 20); + render_library(f, area, &app); + }) + .unwrap(); + let buffer = terminal.backend().buffer().clone(); + let rendered: String = (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!( + rendered.contains("TITLE") + && rendered.contains("TAGS") + && rendered.contains("UPDATED") + && rendered.contains("CHUNKS"), + "header row labels not visible in:\n{rendered}" + ); + let title_line_idx = rendered + .lines() + .position(|line| line.contains("TITLE")) + .expect("TITLE header should be present"); + let lines_after = rendered.lines().skip(title_line_idx + 1).collect::>(); + assert!( + lines_after.iter().any(|line| line.contains("doc-")), + "no data rows after header:\n{rendered}" + ); +} + /// p9-fb-10: Library renders Hangul / CJK titles without overflowing /// the title column. Smoke pin — render with a mixed Korean fixture /// and confirm no panic + the truncated width fits the column.