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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,146 changes: 587 additions & 559 deletions rust/leanspec-cli/src/commands/tui/app.rs

Large diffs are not rendered by default.

52 changes: 26 additions & 26 deletions rust/leanspec-cli/src/commands/tui/board.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Board view widget — specs grouped by status.
//! Board view widget — specs grouped by the schema's `status` semantic field.

use ratatui::{
buffer::Buffer,
Expand All @@ -9,7 +9,7 @@ use ratatui::{
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Widget},
};

use leanspec_core::SpecInfo;
use leanspec_core::model::SpecDoc;

use super::app::{App, FocusPane, PrimaryView};
use super::theme;
Expand All @@ -22,7 +22,11 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) {
theme::border_unfocused_style()
};

let filter_indicator = if !app.filter.is_empty() { " [F]" } else { "" };
let filter_indicator = if !app.filter.is_empty(&app.schema) {
" [F]"
} else {
""
};
let board_title = format!(" Board [{}]{} ", app.sort_option.label(), filter_indicator);

let block = Block::default()
Expand All @@ -33,11 +37,13 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) {
block.render(area, buf);

let mut lines: Vec<Line> = Vec::new();
let status_key = app.status_key.as_deref().unwrap_or("status");
let priority_key = app.priority_key.as_deref();

for (gi, group) in app.board_groups.iter().enumerate() {
// Group header with collapse indicator
let header_style = theme::status_style(&group.status).add_modifier(Modifier::BOLD);
let symbol = theme::status_symbol(&group.status);
let header_color = theme::field_style(&group.value, status_key, &app.schema);
let header_style = header_color.add_modifier(Modifier::BOLD);
let symbol = theme::field_symbol(&group.value, status_key, &app.schema);
let collapse_indicator = if group.collapsed { "▶" } else { "▼" };
let collapsed_label = if group.collapsed { " [collapsed]" } else { "" };

Expand All @@ -63,9 +69,8 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) {
]));

if !group.collapsed {
// Items in group
for (ii, &spec_idx) in group.indices.iter().enumerate() {
let spec = &app.specs[spec_idx];
let doc = &app.specs[spec_idx];
let is_current = gi == app.board_group_idx && ii == app.board_item_idx;

let style = if is_current && is_focused {
Expand All @@ -76,17 +81,16 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) {
Style::default()
};

let pri = theme::priority_symbol(spec.frontmatter.priority.as_ref());
let dep_count = app
.dep_graph
.get_complete_graph(&spec.path)
.map_or(0, |g| g.depends_on.len());
let line = format_spec_line(pri, spec, dep_count);
let pri_sym = priority_key
.and_then(|k| doc.field_str(k).map(|v| (k, v)))
.map(|(k, v)| theme::field_symbol(v, k, &app.schema).to_string())
.unwrap_or_else(|| " ".to_string());
let dep_count = app.deps.dep_count(&doc.id);
let line = format_spec_line(&pri_sym, doc, dep_count);
lines.push(Line::styled(line, style));
}
}

// Blank line between groups
lines.push(Line::from(""));
}

Expand All @@ -97,12 +101,10 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) {
let total_lines = lines.len();
let viewport_height = inner.height as usize;

// Compute scroll offset to keep the selected item visible
let scroll = app.board_scroll(viewport_height);
let paragraph = Paragraph::new(lines).scroll((scroll as u16, 0));
paragraph.render(inner, buf);

// Scrollbar — only render if content exceeds viewport
if total_lines > viewport_height {
let mut scrollbar_state = ScrollbarState::new(total_lines)
.position(scroll)
Expand All @@ -114,19 +116,19 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) {
}
}

fn format_spec_line(priority: &str, spec: &SpecInfo, dep_count: usize) -> String {
let title = if spec.title.chars().count() > 36 {
let truncated: String = spec.title.chars().take(33).collect();
fn format_spec_line(priority: &str, doc: &SpecDoc, dep_count: usize) -> String {
let title = if doc.title.chars().count() > 36 {
let truncated: String = doc.title.chars().take(33).collect();
format!("{}...", truncated)
} else {
spec.title.clone()
doc.title.clone()
};
let dep_str = if dep_count > 0 {
format!(" deps:{}", dep_count)
} else {
String::new()
};
format!(" {} {} {}{}", priority, spec.path, title, dep_str)
format!(" {} {} {}{}", priority, doc.id, title, dep_str)
}

#[cfg(test)]
Expand All @@ -135,8 +137,6 @@ mod tests {
use ratatui::backend::TestBackend;
use ratatui::Terminal;

use super::super::app::App;

fn buffer_text(buf: &ratatui::buffer::Buffer) -> String {
buf.content().iter().map(|c| c.symbol()).collect()
}
Expand All @@ -145,7 +145,7 @@ mod tests {
fn test_board_renders_group_headers() {
let mut app = App::empty_for_test();
app.board_groups = vec![super::super::app::BoardGroup {
status: leanspec_core::SpecStatus::Draft,
value: "draft".to_string(),
label: "Draft".to_string(),
indices: vec![],
collapsed: false,
Expand All @@ -168,7 +168,7 @@ mod tests {
fn test_board_collapsed_group_shows_indicator() {
let mut app = App::empty_for_test();
app.board_groups = vec![super::super::app::BoardGroup {
status: leanspec_core::SpecStatus::Planned,
value: "planned".to_string(),
label: "Planned".to_string(),
indices: vec![],
collapsed: true,
Expand Down
82 changes: 46 additions & 36 deletions rust/leanspec-cli/src/commands/tui/deps.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Dependency tree widget — upstream and downstream deps for selected spec.
//! Dependency tree widget — upstream and downstream deps for the selected spec.

use ratatui::{
buffer::Buffer,
Expand All @@ -18,69 +18,79 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &App) {
let inner = block.inner(area);
block.render(area, buf);

let Some(spec) = &app.selected_detail else {
let Some(doc) = &app.selected_detail else {
let msg =
Paragraph::new(" Select a spec to view dependencies").style(theme::dimmed_style());
msg.render(inner, buf);
return;
};

let mut lines: Vec<Line> = Vec::new();
let status_key = app.status_key.as_deref();

// Upstream (depends on)
let upstream = app.dep_graph.get_upstream(&spec.path, 3);
let upstream_ids = app
.deps
.depends_on
.get(&doc.id)
.cloned()
.unwrap_or_default();
lines.push(Line::from(Span::styled(
" Upstream (depends on):",
theme::header_style(),
)));
if upstream.is_empty() {
if upstream_ids.is_empty() {
lines.push(Line::styled(" (none)", theme::dimmed_style()));
} else {
for dep in &upstream {
let sym = theme::status_symbol(&dep.frontmatter.status);
let style = theme::status_style(&dep.frontmatter.status);
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(sym, style),
Span::raw(format!(" {} - {}", dep.path, dep.title)),
]));
for dep_id in &upstream_ids {
push_dep_line(&mut lines, dep_id, app, status_key);
}
}

lines.push(Line::from(""));

// Downstream (required by)
let downstream = app.dep_graph.get_downstream(&spec.path, 3);
let downstream_ids = app
.deps
.required_by
.get(&doc.id)
.cloned()
.unwrap_or_default();
lines.push(Line::from(Span::styled(
" Downstream (required by):",
theme::header_style(),
)));
if downstream.is_empty() {
if downstream_ids.is_empty() {
lines.push(Line::styled(" (none)", theme::dimmed_style()));
} else {
for dep in &downstream {
let sym = theme::status_symbol(&dep.frontmatter.status);
let style = theme::status_style(&dep.frontmatter.status);
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(sym, style),
Span::raw(format!(" {} - {}", dep.path, dep.title)),
]));
}
}

// Direct dependencies from frontmatter
if !spec.frontmatter.depends_on.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Direct dependencies (frontmatter):",
theme::header_style(),
)));
for dep_path in &spec.frontmatter.depends_on {
lines.push(Line::from(format!(" -> {}", dep_path)));
for dep_id in &downstream_ids {
push_dep_line(&mut lines, dep_id, app, status_key);
}
}

let paragraph = Paragraph::new(lines);
paragraph.render(inner, buf);
}

fn push_dep_line(lines: &mut Vec<Line>, dep_id: &str, app: &App, status_key: Option<&str>) {
let dep_doc = app.specs.iter().find(|d| d.id == dep_id);
let (sym, style, title) = match dep_doc {
Some(d) => {
let (s, st) = status_key
.and_then(|k| {
d.field_str(k).map(|v| {
(
theme::field_symbol(v, k, &app.schema).to_string(),
theme::field_style(v, k, &app.schema),
)
})
})
.unwrap_or_else(|| (" ".to_string(), theme::dimmed_style()));
(s, st, d.title.clone())
}
None => (" ".to_string(), theme::dimmed_style(), String::new()),
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(sym, style),
Span::raw(format!(" {} - {}", dep_id, title)),
]));
}
Loading
Loading