use super::app::{App, InputMode};
use crate::display::short_id;
use crate::models::Priority;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
Frame,
};
pub fn draw(f: &mut Frame, app: &App) {
let size = f.area();
let vertical_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), Constraint::Length(2), ])
.split(size);
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(40), Constraint::Percentage(60), ])
.split(vertical_chunks[0]);
draw_project_tree(f, app, main_chunks[0]);
draw_detail(f, app, main_chunks[1]);
match &app.ui.input_mode {
InputMode::Input {
prompt,
buffer,
cursor_pos,
..
} => {
draw_input_line(f, prompt, buffer, *cursor_pos, vertical_chunks[1]);
}
_ => {
draw_footer(f, app, vertical_chunks[1]);
}
}
if matches!(app.ui.input_mode, InputMode::Help) {
draw_help_overlay(f, app);
}
}
pub(super) fn status_color_problem(status: &crate::models::ProblemStatus) -> Color {
use crate::models::ProblemStatus;
match status {
ProblemStatus::Solved => Color::Green,
ProblemStatus::InProgress => Color::Yellow,
ProblemStatus::Dissolved => Color::DarkGray,
ProblemStatus::Open => Color::White,
}
}
pub(super) fn status_color_solution(status: &crate::models::SolutionStatus) -> Color {
use crate::models::SolutionStatus;
match status {
SolutionStatus::Approved => Color::Green,
SolutionStatus::Withdrawn => Color::Red,
SolutionStatus::Submitted => Color::Yellow,
SolutionStatus::Proposed => Color::Cyan,
}
}
pub(super) fn status_color_critique(status: &crate::models::CritiqueStatus) -> Color {
use crate::models::CritiqueStatus;
match status {
CritiqueStatus::Addressed | CritiqueStatus::Dismissed => Color::Green,
CritiqueStatus::Valid => Color::Red,
CritiqueStatus::Open => Color::Yellow,
}
}
pub(super) fn status_color_milestone(status: &crate::models::MilestoneStatus) -> Color {
use crate::models::MilestoneStatus;
match status {
MilestoneStatus::Completed => Color::Green,
MilestoneStatus::Active => Color::Yellow,
MilestoneStatus::Cancelled => Color::Red,
MilestoneStatus::Planning => Color::Cyan,
}
}
pub(super) fn severity_color(severity: &crate::models::CritiqueSeverity) -> Color {
use crate::models::CritiqueSeverity;
match severity {
CritiqueSeverity::Critical => Color::Red,
CritiqueSeverity::High => Color::Yellow,
CritiqueSeverity::Medium => Color::White,
CritiqueSeverity::Low => Color::DarkGray,
}
}
pub(super) fn tier_color_for_rank(rank: usize, total: usize) -> Color {
if total == 0 {
return Color::DarkGray;
}
let third = total.div_ceil(3);
if rank <= third {
Color::Green
} else if rank <= third * 2 {
Color::Yellow
} else {
Color::Red
}
}
pub(super) fn priority_prefix(priority: &Priority) -> &'static str {
match priority {
Priority::Critical => "🔴 ",
Priority::High => "🟡 ",
Priority::Medium | Priority::Low => "",
}
}
pub(super) fn priority_color(priority: &Priority) -> Color {
match priority {
Priority::Critical => Color::Red,
Priority::High => Color::Yellow,
Priority::Medium => Color::White,
Priority::Low => Color::DarkGray,
}
}
pub(super) fn confidence_color(confidence: &crate::models::Confidence) -> Color {
use crate::models::Confidence;
match confidence {
Confidence::Red => Color::Red,
Confidence::Amber => Color::Yellow,
Confidence::Green => Color::Green,
Confidence::Unknown => Color::DarkGray,
}
}
fn draw_project_tree(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
use super::tree::TreeNode;
let mut display_items: Vec<_> = if app.ui.filter_actions_only {
super::filter_tree_to_actions(&app.cache.tree_items)
} else {
app.cache.tree_items.clone()
};
if let Some((drill_ms, _, _)) = app.ui.tier_drill.last() {
let drilled_problem_ids: std::collections::HashSet<&str> = app
.data
.problems
.iter()
.filter(|p| p.milestone_id.as_deref() == Some(drill_ms.as_str()))
.map(|p| p.id.as_str())
.collect();
display_items.retain(|item| match &item.node {
TreeNode::Milestone { id, .. } => id == drill_ms,
TreeNode::Problem { id, .. } => drilled_problem_ids.contains(id.as_str()),
TreeNode::Solution { id, .. } => {
app.data
.solutions
.iter()
.find(|s| s.id == *id)
.map(|s| drilled_problem_ids.contains(s.problem_id.as_str()))
.unwrap_or(false)
}
TreeNode::Critique { id, .. } => {
app.data
.critiques
.iter()
.find(|c| c.id == *id)
.and_then(|c| app.data.solutions.iter().find(|s| s.id == c.solution_id))
.map(|s| drilled_problem_ids.contains(s.problem_id.as_str()))
.unwrap_or(false)
}
TreeNode::TierSeparator { .. } => true, TreeNode::ProjectRoot { .. } | TreeNode::Backlog { .. } => false,
});
}
let border_color = if app.ui.focused_pane == super::app::FocusedPane::Tree {
Color::Cyan
} else {
Color::DarkGray
};
let border_style = Style::default().fg(border_color);
let title: String = if !app.ui.tier_drill.is_empty() {
let (_, start, end) = app.ui.tier_drill.last().unwrap();
let depth = app.ui.tier_drill.len();
format!(
"Tier Drill [{} deep] items {}-{} [S+\u{2190} to zoom out]",
depth,
start + 1,
end
)
} else if app.ui.filter_actions_only {
"Project Tree [Actions]".to_string()
} else {
"Project Tree".to_string()
};
let cursor_id = app
.cache
.tree_items
.get(app.ui.tree_index)
.map(|i| i.node.id().to_string());
let items: Vec<ListItem> = display_items
.iter()
.map(|item| {
let is_selected = app.ui.selected_ids.contains(item.node.id());
let is_cursor = cursor_id.as_deref() == Some(item.node.id());
let indent = " ".repeat(item.depth);
let action_sym = item.action_symbol.as_deref().unwrap_or("");
let (label, color, dim) = match &item.node {
TreeNode::ProjectRoot { .. } => (format!("{}Root", indent), Color::White, false),
TreeNode::Milestone { title, .. } => {
(format!("{}{}", indent, title), Color::White, false)
}
TreeNode::Backlog { .. } => (format!("{}Backlog", indent), Color::DarkGray, false),
TreeNode::Problem {
title,
status,
assignee,
..
} => {
let assignee_suffix = assignee
.as_deref()
.map(|a| {
let name = a.split('<').next().unwrap_or(a).trim();
let name = name.char_indices().nth(12).map_or(name, |(i, _)| &name[..i]);
format!(" @{}", name)
})
.unwrap_or_default();
(
format!("{}{}{}{}", indent, action_sym, title, assignee_suffix),
status_color_problem(status),
false,
)
}
TreeNode::Solution {
title,
status,
assignee,
..
} => {
let assignee_suffix = assignee
.as_deref()
.map(|a| {
let name = a.split('<').next().unwrap_or(a).trim();
let name = name.char_indices().nth(12).map_or(name, |(i, _)| &name[..i]);
format!(" @{}", name)
})
.unwrap_or_default();
(
format!("{}{}{}{}", indent, action_sym, title, assignee_suffix),
status_color_solution(status),
false,
)
}
TreeNode::Critique {
title,
status,
severity,
..
} => (
format!("{}{}{} [{}]", indent, action_sym, title, severity),
status_color_critique(status),
false,
),
TreeNode::TierSeparator { label } => {
(format!("{}{}", indent, label), Color::DarkGray, true)
}
};
let style = if dim {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(color)
};
let style = if is_selected {
style.add_modifier(Modifier::BOLD)
} else {
style
};
let gutter = if is_cursor && is_selected {
">✓"
} else if is_cursor {
"> "
} else if is_selected {
" ✓"
} else {
" "
};
let gutter_style = if is_cursor {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else if is_selected {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let mut spans = vec![Span::styled(gutter, gutter_style)];
if let TreeNode::Problem {
rank: Some(r),
problem_count,
..
} = &item.node
{
let tier_color = tier_color_for_rank(*r, *problem_count);
spans.push(Span::styled(
format!("#{} ", r),
Style::default().fg(tier_color),
));
}
spans.push(Span::styled(label, style));
if let TreeNode::Problem {
votes, confidence, ..
} = &item.node
{
let rag_color = match confidence {
crate::models::Confidence::Red => Some(Color::Red),
crate::models::Confidence::Amber => Some(Color::Yellow),
crate::models::Confidence::Green => Some(Color::Green),
crate::models::Confidence::Unknown => None,
};
if let Some(c) = rag_color {
spans.push(Span::styled(" ●", Style::default().fg(c)));
}
if *votes > 0 {
spans.push(Span::styled(
format!(" {}", "▲".repeat((*votes).min(10) as usize)),
Style::default().fg(Color::Green),
));
} else if *votes < 0 {
spans.push(Span::styled(
format!(" {}", "▼".repeat((votes.unsigned_abs()).min(10) as usize)),
Style::default().fg(Color::Red),
));
}
}
let is_section_header = matches!(
&item.node,
TreeNode::Milestone { .. } | TreeNode::Backlog { .. }
);
if is_section_header {
let rule = Line::from(Span::styled(
"─".repeat(80),
Style::default().fg(Color::DarkGray),
));
ListItem::new(vec![rule, Line::from(spans)])
} else {
ListItem::new(Line::from(spans))
}
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(border_style),
)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let selected_id = app
.cache
.tree_items
.get(app.ui.tree_index)
.map(|i| i.node.id());
let display_index =
selected_id.and_then(|id| display_items.iter().position(|i| i.node.id() == id));
let mut state = ListState::default();
if let Some(idx) = display_index {
state.select(Some(idx));
} else if !display_items.is_empty() {
state.select(Some(0));
}
f.render_stateful_widget(list, area, &mut state);
}
fn draw_detail(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let show_related =
app.ui.show_related && (!app.ui.related_items.is_empty() || app.ui.related_rx.is_some());
let (detail_area, related_area) = if show_related {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(5), Constraint::Length(7), ])
.split(area);
(chunks[0], Some(chunks[1]))
} else {
(area, None)
};
let lines = app.cache.selected_detail.to_styled_lines();
let border_color = if app.ui.focused_pane == super::app::FocusedPane::Detail {
Color::Cyan
} else {
app.cache.selected_detail.border_color()
};
let title = app.cache.selected_detail.block_title();
let text: Vec<Line> = lines
.into_iter()
.skip(app.ui.detail_scroll as usize)
.collect();
let detail = Paragraph::new(text)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(detail, detail_area);
if let Some(related_area) = related_area {
draw_related_panel(f, app, related_area);
}
}
fn draw_related_panel(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let is_loading = app.ui.related_rx.is_some();
let items: Vec<ListItem> = if is_loading && app.ui.related_items.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
"Loading...",
Style::default().fg(Color::DarkGray),
)))]
} else {
app.ui
.related_items
.iter()
.enumerate()
.map(|(i, r)| {
let style = if i == app.ui.related_selected {
Style::default().bg(Color::DarkGray)
} else {
Style::default()
};
let type_char = r.entity_type.chars().next().unwrap_or('?');
ListItem::new(Line::from(Span::styled(
format!(
"{}/{} [{:.2}] {}",
type_char,
short_id(&r.entity_id),
r.similarity,
r.title
),
style,
)))
})
.collect()
};
let title = if is_loading {
"Related [loading...] [R to toggle]"
} else {
"Related [R to toggle]"
};
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(list, area);
}
fn draw_input_line(f: &mut Frame, prompt: &str, buffer: &str, cursor_pos: usize, area: Rect) {
let input_area = Rect::new(area.x, area.y, area.width, 1);
let prompt_span = Span::styled(prompt, Style::default().fg(Color::Yellow));
let char_count = buffer.chars().count();
let clamped_char = cursor_pos.min(char_count);
let byte_pos = buffer
.char_indices()
.nth(clamped_char)
.map_or(buffer.len(), |(i, _)| i);
let before_cursor = &buffer[..byte_pos];
let (cursor_char, after_cursor) = if clamped_char < char_count {
let ch = buffer[byte_pos..].chars().next().unwrap();
let next_byte = byte_pos + ch.len_utf8();
(&buffer[byte_pos..next_byte], &buffer[next_byte..])
} else {
("█", "")
};
let before_span = Span::styled(
before_cursor,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
);
let cursor_span = Span::styled(
cursor_char,
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let after_span = Span::styled(
after_cursor,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
);
let line = Line::from(vec![prompt_span, before_span, cursor_span, after_span]);
let input = Paragraph::new(line);
f.render_widget(input, input_area);
let hint =
Paragraph::new("[Enter] submit | [Esc] cancel").style(Style::default().fg(Color::DarkGray));
let hint_area = Rect::new(area.x, area.y + 1, area.width, 1);
f.render_widget(hint, hint_area);
}
fn draw_footer(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1)])
.split(area);
let selection_info = if !app.ui.selected_ids.is_empty() {
format!("[{} selected] ", app.ui.selected_ids.len())
} else {
String::new()
};
let context_text = if let Some((msg, _)) = &app.ui.flash_message {
msg.clone()
} else if let Some(ref filter) = app.ui.search_filter {
format!("{}[/{}] {}", selection_info, filter, app.context_hints())
} else {
format!("{}{}", selection_info, app.context_hints())
};
let context_style = if app.ui.flash_message.is_some() {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Yellow)
};
let context = Paragraph::new(context_text).style(context_style);
f.render_widget(context, chunks[0]);
let global_text = if app.ui.focused_pane == super::app::FocusedPane::Detail {
"j/k scroll | b/Space page | g/G top/bot | Tab\u{2192}tree | ? help | q quit"
} else {
"j/k up/down | h/l collapse/expand | Space select | Tab\u{2192}detail | ? help | q quit"
};
let global = Paragraph::new(global_text).style(Style::default().fg(Color::DarkGray));
f.render_widget(global, chunks[1]);
}
fn draw_help_overlay(f: &mut Frame, app: &App) {
let area = f.area();
let popup_width = 46u16.min(area.width);
let popup_height = 30u16.min(area.height);
let popup_x = area.width.saturating_sub(popup_width) / 2;
let popup_y = area.height.saturating_sub(popup_height) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
let mut lines = vec![
Line::from(""),
Line::from(Span::styled(
" Tree Pane",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(" j/k ↑/↓ Move selection"),
Line::from(" h/l ←/→ Collapse/Expand"),
Line::from(" Tab Switch to detail pane"),
Line::from(" / Search/filter tree"),
Line::from(" f Toggle filter (full/actions)"),
Line::from(" S+K/\u{2191} Assign to top tier"),
Line::from(" S+J/\u{2193} Assign to bottom tier"),
Line::from(" C+K/\u{2191} Bubble up one position"),
Line::from(" C+J/\u{2193} Bubble down one position"),
Line::from(" S+L/\u{2192} Drill into tier"),
Line::from(" S+H/\u{2190} Drill out"),
Line::from(" +/- Vote (pins to top/bottom zone)"),
Line::from(" r Toggle personal/global"),
Line::from(" C-z Undo tier/vote change"),
Line::from(" R Toggle related"),
Line::from(""),
Line::from(Span::styled(
" Detail Pane",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(" j/k ↑/↓ Scroll"),
Line::from(" b/Space Page up/down"),
Line::from(" g/G Top/bottom"),
Line::from(" Tab/Esc Back to tree"),
Line::from(""),
Line::from(Span::styled(
" Selection",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(" Space Toggle select + move down"),
Line::from(" Ctrl+A Select all / deselect all"),
Line::from(" Esc Clear selection"),
Line::from(""),
];
let action_lines = get_context_actions(app);
lines.extend(action_lines);
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Press any key to close",
Style::default().fg(Color::DarkGray),
)));
f.render_widget(Clear, popup_area);
let help = Paragraph::new(lines).block(
Block::default()
.title(" Help ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
);
f.render_widget(help, popup_area);
}
fn get_context_actions(app: &App) -> Vec<Line<'static>> {
use super::next_actions::EntityType;
use super::tree::TreeNode;
let mut lines = vec![Line::from(Span::styled(
" Actions",
Style::default().add_modifier(Modifier::BOLD),
))];
let entity_type = app
.cache
.tree_items
.get(app.ui.tree_index)
.and_then(|item| match &item.node {
TreeNode::Problem { .. } => Some(EntityType::Problem),
TreeNode::Solution { .. } => Some(EntityType::Solution),
TreeNode::Critique { .. } => Some(EntityType::Critique),
TreeNode::Milestone { .. } => Some(EntityType::Milestone),
TreeNode::ProjectRoot { .. }
| TreeNode::Backlog { .. }
| TreeNode::TierSeparator { .. } => None,
});
match entity_type {
Some(EntityType::Problem) => {
lines.push(Line::from(" n New solution"));
lines.push(Line::from(" c Cycle confidence (RAG)"));
lines.push(Line::from(" s Mark solved"));
lines.push(Line::from(" d Dissolve (with reason)"));
lines.push(Line::from(" o Reopen"));
lines.push(Line::from(" A Assign to me"));
lines.push(Line::from(" m Move to milestone"));
lines.push(Line::from(" e Edit title"));
lines.push(Line::from(" t Edit tags"));
lines.push(Line::from(" E Edit in $EDITOR"));
lines.push(Line::from(" x Delete"));
}
Some(EntityType::Solution) => {
lines.push(Line::from(" n New critique"));
lines.push(Line::from(" u Submit for review"));
lines.push(Line::from(" a Approve"));
lines.push(Line::from(" d Withdraw"));
lines.push(Line::from(" A Assign to me"));
lines.push(Line::from(" g Go to change"));
lines.push(Line::from(" e Edit title"));
lines.push(Line::from(" t Edit tags"));
lines.push(Line::from(" E Edit in $EDITOR"));
lines.push(Line::from(" x Delete"));
}
Some(EntityType::Critique) => {
lines.push(Line::from(" a Address"));
lines.push(Line::from(" d Dismiss"));
lines.push(Line::from(" v Validate"));
lines.push(Line::from(" e Edit title"));
lines.push(Line::from(" E Edit in $EDITOR"));
lines.push(Line::from(" x Delete"));
}
Some(EntityType::Milestone) => {
lines.push(Line::from(" n New problem"));
lines.push(Line::from(" s Mark completed"));
lines.push(Line::from(" d Cancel"));
lines.push(Line::from(" o Activate"));
lines.push(Line::from(" A Assign to me"));
lines.push(Line::from(" e Edit title"));
lines.push(Line::from(" E Edit in $EDITOR"));
lines.push(Line::from(" x Delete"));
}
None => {
lines.push(Line::from(" n New (milestone/problem)"));
}
}
lines
}