Phase C4 executor 의 마지막 `fix(test): clippy + fmt fixes` commit 이 test file 부분만 fmt 적용. workspace 전체 fmt 누락 발견 → cargo fmt --all 적용. 모든 import alphabetical reorder + line wrapping 정합. 추가 untracked artifact 동시 commit: - docs/superpowers/specs/2026-05-28-v0.20-ingest-log-spec.md (491 line, ACCEPT) - docs/superpowers/plans/2026-05-28-v0.20-ingest-log-plan.md (616 line, ACCEPT) workspace test: 1370 passed / 0 failed / 50 ignored, ingest_log_smoke green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
594 lines
21 KiB
Rust
594 lines
21 KiB
Rust
//! Library pane — list + filter + key dispatch.
|
|
//!
|
|
//! State / render / key handler are kept in one module so the slot
|
|
//! pattern (p9-2/3/4 in their own modules) has a clear template to
|
|
//! follow. The renderer is `Frame`-typed — ratatui 0.28 dropped the
|
|
//! `B: Backend` generic from `Frame` (it's bound at `Terminal` init),
|
|
//! so the spec's `render_library<B: Backend>` literal is collapsed
|
|
//! here. Logged in HOTFIXES.
|
|
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
use kebab_core::{DocFilter, DocSummary, Lang};
|
|
use ratatui::Frame;
|
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
|
|
|
|
use crate::app::{App, KeyOutcome, Pane};
|
|
use crate::input::{display_width, truncate_to_display_width};
|
|
|
|
/// Width (in display columns) of the `tags` column in the doc-list
|
|
/// row. Used twice — truncate input + pad calculation — so a const
|
|
/// keeps them in sync.
|
|
const TAGS_COL_W: usize = 12;
|
|
|
|
/// Internal state owned by `LibraryState`. Public-by-crate so
|
|
/// `handle_key_library` can mutate it without crossing the
|
|
/// `pub`-visibility boundary `LibraryState` exposes.
|
|
pub(crate) struct LibraryStateInner {
|
|
pub docs: Vec<DocSummary>,
|
|
pub list_state: ListState,
|
|
pub filter: DocFilter,
|
|
/// Edit overlay for the filter (toggled by `f`). `Some` while
|
|
/// the user is editing tags / lang fields.
|
|
pub filter_edit: Option<FilterEdit>,
|
|
/// True after `App::new` and again after every filter refresh,
|
|
/// flipped to false once the run loop services the refresh.
|
|
pub needs_refresh: bool,
|
|
/// True while the run loop is awaiting `kebab-app::list_docs_with_config`
|
|
/// — drives the "loading…" header span. Synchronous in v1
|
|
/// (acceptable hang per spec).
|
|
pub loading: bool,
|
|
/// `g` waiting for the second `g` (vim-style `gg` → top).
|
|
pub pending_g: bool,
|
|
}
|
|
|
|
impl Default for LibraryStateInner {
|
|
fn default() -> Self {
|
|
let mut list_state = ListState::default();
|
|
list_state.select(None);
|
|
Self {
|
|
docs: Vec::new(),
|
|
list_state,
|
|
filter: DocFilter::default(),
|
|
filter_edit: None,
|
|
needs_refresh: true,
|
|
loading: false,
|
|
pending_g: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Filter edit overlay state. `f` toggles in/out of edit mode;
|
|
/// while editing, `tab` cycles between fields and `Enter` commits.
|
|
pub(crate) struct FilterEdit {
|
|
pub field: FilterField,
|
|
pub tags_buf: crate::input::InputBuffer,
|
|
pub lang_buf: crate::input::InputBuffer,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum FilterField {
|
|
Tags,
|
|
Lang,
|
|
}
|
|
|
|
impl FilterEdit {
|
|
/// Borrow the buffer for the currently-focused field. Centralizes
|
|
/// the `match edit.field` pick so the key-handler arms (Backspace
|
|
/// / arrows / Delete / typed Char) don't each re-spell the same
|
|
/// 2-arm dispatch.
|
|
fn active_buf_mut(&mut self) -> &mut crate::input::InputBuffer {
|
|
match self.field {
|
|
FilterField::Tags => &mut self.tags_buf,
|
|
FilterField::Lang => &mut self.lang_buf,
|
|
}
|
|
}
|
|
|
|
pub fn from_filter(filter: &DocFilter) -> Self {
|
|
let mut tags_buf = crate::input::InputBuffer::new();
|
|
tags_buf.push_str(&filter.tags_any.join(","));
|
|
let mut lang_buf = crate::input::InputBuffer::new();
|
|
if let Some(lang) = filter.lang.as_ref() {
|
|
lang_buf.push_str(&lang.0);
|
|
}
|
|
Self {
|
|
field: FilterField::Tags,
|
|
tags_buf,
|
|
lang_buf,
|
|
}
|
|
}
|
|
|
|
pub fn commit_into(&self, filter: &mut DocFilter) {
|
|
filter.tags_any = self
|
|
.tags_buf
|
|
.as_str()
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.map(str::to_string)
|
|
.collect();
|
|
let trimmed = self.lang_buf.as_str().trim();
|
|
filter.lang = if trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(Lang(trimmed.to_string()))
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Render the Library pane. `area` is the full body region;
|
|
/// header / footer are owned by the run loop.
|
|
pub fn render_library(f: &mut Frame, area: Rect, state: &App) {
|
|
let layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(filter_overlay_height(state)),
|
|
Constraint::Min(1),
|
|
])
|
|
.split(area);
|
|
|
|
if let Some(edit) = &state.library.inner.filter_edit {
|
|
render_filter_overlay(f, layout[0], edit, &state.theme);
|
|
}
|
|
render_doc_list(f, layout[1], state);
|
|
}
|
|
|
|
fn filter_overlay_height(state: &App) -> u16 {
|
|
if state.library.inner.filter_edit.is_some() {
|
|
4
|
|
} else {
|
|
0
|
|
}
|
|
}
|
|
|
|
/// Single source of truth for the filter overlay row labels — used
|
|
/// both by `line_with_focus` (display) and the cursor-placement
|
|
/// `display_width(...)` math below. Editing one without the other
|
|
/// would silently miscolumn the caret.
|
|
const LABEL_TAGS: &str = "tags_any (csv): ";
|
|
const LABEL_LANG: &str = "lang: ";
|
|
|
|
fn render_filter_overlay(
|
|
f: &mut Frame,
|
|
area: Rect,
|
|
edit: &FilterEdit,
|
|
theme: &crate::theme::Theme,
|
|
) {
|
|
let block = Block::default()
|
|
.title("Filter (Tab=cycle field, Enter=apply, Esc=cancel)")
|
|
.borders(Borders::ALL);
|
|
let inner = block.inner(area);
|
|
f.render_widget(block, area);
|
|
|
|
let lines = vec![
|
|
line_with_focus(
|
|
LABEL_TAGS,
|
|
edit.tags_buf.as_str(),
|
|
edit.field == FilterField::Tags,
|
|
theme,
|
|
),
|
|
line_with_focus(
|
|
LABEL_LANG,
|
|
edit.lang_buf.as_str(),
|
|
edit.field == FilterField::Lang,
|
|
theme,
|
|
),
|
|
];
|
|
let para = Paragraph::new(lines);
|
|
f.render_widget(para, inner);
|
|
|
|
// p9-fb-10: ratatui calls show_cursor + MoveTo whenever
|
|
// cursor_position is Some (our case here). When a render fn
|
|
// omits set_cursor_position (Library/Inspect main view), ratatui
|
|
// calls hide_cursor instead. So this single call positions the
|
|
// caret on the focused field of the filter overlay.
|
|
// place_cursor_x sums in usize (avoiding u16 wrap) and clamps to
|
|
// the right edge of the inner area.
|
|
let (label, focused_buf, row_offset) = match edit.field {
|
|
FilterField::Tags => (LABEL_TAGS, &edit.tags_buf, 0u16),
|
|
FilterField::Lang => (LABEL_LANG, &edit.lang_buf, 1u16),
|
|
};
|
|
let label_w = display_width(label);
|
|
let cursor_x =
|
|
crate::input::place_cursor_x(inner.x, inner.width, label_w, focused_buf.cursor_col());
|
|
f.set_cursor_position((cursor_x, inner.y + row_offset));
|
|
}
|
|
|
|
fn line_with_focus<'a>(
|
|
label: &'a str,
|
|
value: &'a str,
|
|
focused: bool,
|
|
theme: &crate::theme::Theme,
|
|
) -> Line<'a> {
|
|
let style = if focused {
|
|
theme.style(crate::theme::Role::Selected)
|
|
} else {
|
|
theme.style(crate::theme::Role::Body)
|
|
};
|
|
Line::from(vec![Span::raw(label), Span::styled(value, style)])
|
|
}
|
|
|
|
fn render_doc_list(f: &mut Frame, area: Rect, state: &App) {
|
|
let inner = &state.library.inner;
|
|
let header_text = if inner.loading {
|
|
"Library — loading…"
|
|
} else if inner.docs.is_empty() {
|
|
"Library — no docs (run `kebab ingest` first, then press F5 or re-open)"
|
|
} else {
|
|
"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() {
|
|
return;
|
|
}
|
|
|
|
// 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<ListItem> = inner
|
|
.docs
|
|
.iter()
|
|
.map(|d| ListItem::new(format_doc_row(d, title_w)))
|
|
.collect();
|
|
|
|
let list = List::new(items)
|
|
.highlight_style(state.theme.style(crate::theme::Role::Selected))
|
|
.highlight_symbol("> ");
|
|
|
|
let mut list_state = inner.list_state.clone();
|
|
f.render_stateful_widget(list, list_area, &mut list_state);
|
|
}
|
|
|
|
/// p9-fb-24: render the column-label row that sits directly above
|
|
/// the doc list. Uses the same width math as `format_doc_row` so
|
|
/// the labels line up with their data columns regardless of Hangul
|
|
/// / CJK width drift.
|
|
///
|
|
/// Layout: `TITLE<title_pad> TAGS<tags_pad> UPDATED CHUNKS`.
|
|
/// The title column width matches `area.width.saturating_sub(40).max(20)`
|
|
/// — the same calculation `render_doc_list` uses for `title_w`.
|
|
pub(crate) fn format_doc_header(title_w: usize) -> Line<'static> {
|
|
let title_label = "TITLE";
|
|
let tags_label = "TAGS";
|
|
let title_pad = title_w.saturating_sub(display_width(title_label));
|
|
let tags_pad = TAGS_COL_W.saturating_sub(display_width(tags_label));
|
|
let text = format!(
|
|
"{title_label}{:title_pad$} {tags_label}{:tags_pad$} {updated:<10} {chunks}",
|
|
"",
|
|
"",
|
|
title_label = title_label,
|
|
tags_label = tags_label,
|
|
updated = "UPDATED",
|
|
chunks = "CHUNKS",
|
|
title_pad = title_pad,
|
|
tags_pad = tags_pad,
|
|
);
|
|
Line::from(text)
|
|
}
|
|
|
|
/// Format a `DocSummary` row using display-width-aware truncation
|
|
/// and padding. Korean / wide chars contribute 2 columns each.
|
|
pub(crate) fn format_doc_row(d: &DocSummary, title_w: usize) -> String {
|
|
let title = truncate_to_display_width(&d.title, title_w);
|
|
let tags = if d.tags.is_empty() {
|
|
"-".to_string()
|
|
} else {
|
|
d.tags.join(",")
|
|
};
|
|
let tags = truncate_to_display_width(&tags, TAGS_COL_W);
|
|
let updated = d
|
|
.updated_at
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.unwrap_or_else(|_| "?".to_string());
|
|
let updated_short = updated.split('T').next().unwrap_or("?");
|
|
// std::fmt's `<width$>` form pads by **char count**, which
|
|
// overshoots when the value contains wide chars (each Hangul
|
|
// adds 2 cols but counts as 1 char → padding is half-short and
|
|
// downstream columns drift). Compute the pad spaces ourselves
|
|
// from `display_width`, then concatenate — the truncate above
|
|
// already guarantees `display_width(title) <= title_w`.
|
|
let title_pad = title_w.saturating_sub(display_width(&title));
|
|
let tags_pad = TAGS_COL_W.saturating_sub(display_width(&tags));
|
|
format!(
|
|
"{title}{:title_pad$} {tags}{:tags_pad$} {updated_short:<10} {chunk_count}",
|
|
"",
|
|
"",
|
|
title = title,
|
|
tags = tags,
|
|
updated_short = updated_short,
|
|
chunk_count = d.chunk_count,
|
|
title_pad = title_pad,
|
|
tags_pad = tags_pad,
|
|
)
|
|
}
|
|
|
|
/// Library pane key dispatch. Mutates `App.library.inner`; never
|
|
/// touches another pane's state (parallel-safety contract).
|
|
pub fn handle_key_library(state: &mut App, key: KeyEvent) -> KeyOutcome {
|
|
if state.error_overlay.is_some() {
|
|
// Any key dismisses the popup.
|
|
state.error_overlay = None;
|
|
return KeyOutcome::Continue;
|
|
}
|
|
|
|
if state.library.inner.filter_edit.is_some() {
|
|
return handle_filter_edit_key(state, key);
|
|
}
|
|
|
|
// p9-fb-04: Esc / Ctrl-C while ingest is in flight flips the
|
|
// worker's cancel token (instead of triggering the quit path).
|
|
// Done BEFORE the `inner` borrow so we can re-borrow `state`.
|
|
let is_cancel_chord = match (key.code, key.modifiers) {
|
|
(KeyCode::Esc, _) => true,
|
|
(KeyCode::Char('c'), m) => m.contains(KeyModifiers::CONTROL),
|
|
_ => false,
|
|
};
|
|
if is_cancel_chord && crate::ingest_progress::cancel_running_ingest(state) {
|
|
return KeyOutcome::Continue;
|
|
}
|
|
|
|
let inner = &mut state.library.inner;
|
|
let pending_g = std::mem::take(&mut inner.pending_g);
|
|
|
|
match (key.code, key.modifiers) {
|
|
(KeyCode::Char('q') | KeyCode::Esc, _) => {
|
|
state.should_quit = true;
|
|
KeyOutcome::Quit
|
|
}
|
|
(KeyCode::Char('j') | KeyCode::Down, _) => {
|
|
move_selection(inner, 1);
|
|
KeyOutcome::Continue
|
|
}
|
|
(KeyCode::Char('k') | KeyCode::Up, _) => {
|
|
move_selection(inner, -1);
|
|
KeyOutcome::Continue
|
|
}
|
|
(KeyCode::Char('g'), m) if !m.contains(KeyModifiers::SHIFT) => {
|
|
if pending_g {
|
|
set_selection(inner, 0);
|
|
KeyOutcome::Continue
|
|
} else {
|
|
inner.pending_g = true;
|
|
KeyOutcome::Continue
|
|
}
|
|
}
|
|
(KeyCode::Char('G'), _) => {
|
|
let last = inner.docs.len().saturating_sub(1);
|
|
set_selection(inner, last);
|
|
KeyOutcome::Continue
|
|
}
|
|
(KeyCode::Char('f'), _) => {
|
|
inner.filter_edit = Some(FilterEdit::from_filter(&inner.filter));
|
|
KeyOutcome::Continue
|
|
}
|
|
(KeyCode::Char('r'), _) => {
|
|
// p9-fb-03: trigger background ingest. The `inner` mutable
|
|
// borrow above is not used in this arm, so NLL releases it
|
|
// before we re-borrow `state` for `start_ingest`. Errors
|
|
// (e.g. "ingest already running") surface via the error
|
|
// overlay.
|
|
if let Err(e) = crate::ingest_progress::start_ingest(state) {
|
|
state.error_overlay = Some(crate::ErrorOverlay::from_anyhow(&e));
|
|
}
|
|
KeyOutcome::Continue
|
|
}
|
|
(KeyCode::Char('/'), _) => KeyOutcome::SwitchPane(Pane::Search),
|
|
(KeyCode::Char('?'), _) => KeyOutcome::SwitchPane(Pane::Ask),
|
|
(KeyCode::Enter, _) => {
|
|
if inner.docs.is_empty() {
|
|
KeyOutcome::Continue
|
|
} else {
|
|
let idx = inner.list_state.selected().unwrap_or(0);
|
|
// Capture doc_id and exit the `inner` borrow scope
|
|
// before re-borrowing `state` for `enter_inspect`.
|
|
let doc_id = inner.docs[idx].doc_id.clone();
|
|
// NLL releases the `inner` borrow at last use above;
|
|
// we can re-borrow `state` mutably for the inspect-side
|
|
// mutation below.
|
|
let target = crate::app::InspectTarget::Doc(doc_id);
|
|
crate::inspect::enter_inspect(state, target, Pane::Library);
|
|
KeyOutcome::SwitchPane(Pane::Inspect)
|
|
}
|
|
}
|
|
_ => KeyOutcome::Continue,
|
|
}
|
|
}
|
|
|
|
fn handle_filter_edit_key(state: &mut App, key: KeyEvent) -> KeyOutcome {
|
|
let Some(edit) = state.library.inner.filter_edit.as_mut() else {
|
|
return KeyOutcome::Continue;
|
|
};
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
state.library.inner.filter_edit = None;
|
|
KeyOutcome::Continue
|
|
}
|
|
KeyCode::Tab => {
|
|
edit.field = match edit.field {
|
|
FilterField::Tags => FilterField::Lang,
|
|
FilterField::Lang => FilterField::Tags,
|
|
};
|
|
KeyOutcome::Continue
|
|
}
|
|
KeyCode::Enter => {
|
|
let edit = state.library.inner.filter_edit.take().unwrap();
|
|
edit.commit_into(&mut state.library.inner.filter);
|
|
state.library.inner.needs_refresh = true;
|
|
KeyOutcome::Refresh
|
|
}
|
|
KeyCode::Backspace => {
|
|
edit.active_buf_mut().pop_char();
|
|
KeyOutcome::Continue
|
|
}
|
|
// p9-fb-22: cursor navigation + Delete inside the active filter
|
|
// field. Tab still cycles between Tags / Lang fields; arrows
|
|
// only move within the focused buffer.
|
|
KeyCode::Left => {
|
|
edit.active_buf_mut().move_left();
|
|
KeyOutcome::Continue
|
|
}
|
|
KeyCode::Right => {
|
|
edit.active_buf_mut().move_right();
|
|
KeyOutcome::Continue
|
|
}
|
|
KeyCode::Home => {
|
|
edit.active_buf_mut().move_home();
|
|
KeyOutcome::Continue
|
|
}
|
|
KeyCode::End => {
|
|
edit.active_buf_mut().move_end();
|
|
KeyOutcome::Continue
|
|
}
|
|
KeyCode::Delete => {
|
|
edit.active_buf_mut().delete_after();
|
|
KeyOutcome::Continue
|
|
}
|
|
KeyCode::Char(c) => {
|
|
edit.active_buf_mut().push_char(c);
|
|
KeyOutcome::Continue
|
|
}
|
|
_ => KeyOutcome::Continue,
|
|
}
|
|
}
|
|
|
|
fn move_selection(inner: &mut LibraryStateInner, delta: i32) {
|
|
if inner.docs.is_empty() {
|
|
return;
|
|
}
|
|
let current = inner.list_state.selected().unwrap_or(0) as i32;
|
|
let last = (inner.docs.len() as i32) - 1;
|
|
let next = (current + delta).clamp(0, last);
|
|
inner.list_state.select(Some(next as usize));
|
|
}
|
|
|
|
fn set_selection(inner: &mut LibraryStateInner, idx: usize) {
|
|
if inner.docs.is_empty() {
|
|
inner.list_state.select(None);
|
|
} else {
|
|
let clamped = idx.min(inner.docs.len() - 1);
|
|
inner.list_state.select(Some(clamped));
|
|
}
|
|
}
|
|
|
|
/// Run-loop hook: refresh `docs` from the facade. Public-by-crate
|
|
/// because the run loop owns the call site.
|
|
pub(crate) fn refresh_docs(state: &mut App) -> anyhow::Result<()> {
|
|
state.library.inner.loading = true;
|
|
let result =
|
|
kebab_app::list_docs_with_config(state.config.clone(), state.library.inner.filter.clone());
|
|
state.library.inner.loading = false;
|
|
match result {
|
|
Ok(docs) => {
|
|
let prior = state.library.inner.list_state.selected();
|
|
state.library.inner.docs = docs;
|
|
// Clamp selection.
|
|
let len = state.library.inner.docs.len();
|
|
if len == 0 {
|
|
state.library.inner.list_state.select(None);
|
|
} else {
|
|
let next = prior.map_or(0, |p| p.min(len - 1));
|
|
state.library.inner.list_state.select(Some(next));
|
|
}
|
|
state.library.inner.needs_refresh = false;
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
state.library.inner.needs_refresh = false;
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use kebab_core::{
|
|
ChunkerVersion, DocSummary, DocumentId, Lang, ParserVersion, SourceType, TrustLevel,
|
|
WorkspacePath,
|
|
};
|
|
use time::OffsetDateTime;
|
|
|
|
fn doc(title: &str, tags: &[&str]) -> DocSummary {
|
|
DocSummary {
|
|
doc_id: DocumentId("a".repeat(32)),
|
|
doc_path: WorkspacePath::new("x.md".into()).unwrap(),
|
|
title: title.into(),
|
|
lang: Lang("en".into()),
|
|
tags: tags.iter().map(|s| (*s).into()).collect(),
|
|
trust_level: TrustLevel::Primary,
|
|
source_type: SourceType::Note,
|
|
byte_len: 1,
|
|
chunk_count: 1,
|
|
created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
|
|
updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
|
|
parser_version: ParserVersion("p".into()),
|
|
chunker_version: ChunkerVersion("c".into()),
|
|
}
|
|
}
|
|
|
|
/// p9-fb-10: format_doc_row pads by display width (not char
|
|
/// count) so wide-char titles don't shift downstream columns.
|
|
/// Regression pin — `<title_w$>` (std::fmt char-count form)
|
|
/// would fail this for any Hangul title.
|
|
#[test]
|
|
fn format_doc_row_pads_by_display_width_for_hangul_title() {
|
|
let row = format_doc_row(&doc("러스트로 만드는 KB", &["rust"]), 30);
|
|
// Expected layout (display cols):
|
|
// title 30 + " "(2) + tags 12 + " "(2) + date 10 + " "(2) + chunk
|
|
// chunk = "1" → 1 col. Total = 30+2+12+2+10+2+1 = 59.
|
|
assert_eq!(
|
|
display_width(&row),
|
|
59,
|
|
"row must align to display columns, not char count: {row:?}"
|
|
);
|
|
}
|
|
|
|
/// p9-fb-10: Hangul tag also pads by display width.
|
|
#[test]
|
|
fn format_doc_row_pads_by_display_width_for_hangul_tag() {
|
|
let row = format_doc_row(&doc("ascii", &["한글"]), 20);
|
|
// title 20 + " " + tags 12 + " " + date 10 + " " + "1" = 49
|
|
assert_eq!(display_width(&row), 49, "row: {row:?}");
|
|
}
|
|
|
|
/// p9-fb-24: column header row uses the same width math as
|
|
/// `format_doc_row` so labels line up with their data columns.
|
|
/// The TITLE label sits in the title column, TAGS sits in the
|
|
/// 12-col TAGS column, UPDATED in the 10-col date column, and
|
|
/// CHUNKS at the trailing position.
|
|
#[test]
|
|
fn format_doc_header_aligns_with_format_doc_row() {
|
|
let title_w = 30;
|
|
let header = format_doc_header(title_w);
|
|
let header_text: String = header.spans.iter().map(|sp| sp.content.as_ref()).collect();
|
|
assert!(header_text.contains("TITLE"), "header has TITLE label");
|
|
assert!(header_text.contains("TAGS"), "header has TAGS label");
|
|
assert!(header_text.contains("UPDATED"), "header has UPDATED label");
|
|
assert!(header_text.contains("CHUNKS"), "header has CHUNKS label");
|
|
let row = format_doc_row(&doc("ascii-title", &["rust"]), title_w);
|
|
let tags_start_in_row = row.find("rust").expect("row has tags");
|
|
let tags_start_in_header = header_text.find("TAGS").expect("header has TAGS");
|
|
assert!(
|
|
tags_start_in_header <= tags_start_in_row,
|
|
"TAGS header drifted past row tags: header={tags_start_in_header} row={tags_start_in_row}"
|
|
);
|
|
}
|
|
}
|