use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use crate::model::TaskState;
use crate::tui::app::{App, DepPopupEntry};
use crate::util::unicode;
use super::truncate_with_ellipsis;
pub fn render_dep_popup(frame: &mut Frame, app: &App, area: Rect) {
let dp = match &app.dep_popup {
Some(dp) => dp,
None => return,
};
let bg = app.theme.background;
let bright = app.theme.text_bright;
let highlight = app.theme.highlight;
let dim = app.theme.dim;
let red = app.theme.red;
let sel_bg = app.theme.selection_bg;
let target_w = (area.width as f32 * 0.8) as u16;
let inner_w = target_w.clamp(40, 100).min(area.width.saturating_sub(2)) as usize;
let popup_w = (inner_w as u16) + 2;
let usable_w = inner_w.saturating_sub(1);
let mut lines: Vec<Line> = Vec::new();
let mut in_blocking_section = false;
lines.push(Line::from(Span::styled(
" ".repeat(inner_w),
Style::default().bg(bg),
)));
for (entry_idx, entry) in dp.entries.iter().enumerate() {
match entry {
DepPopupEntry::SectionHeader { label } => {
if in_blocking_section {
} else if *label == "Blocking" {
in_blocking_section = true;
lines.push(Line::from(Span::styled(
" ".repeat(inner_w),
Style::default().bg(bg),
)));
}
let header_style = Style::default()
.fg(bright)
.bg(bg)
.add_modifier(Modifier::BOLD);
let mut spans = vec![Span::styled(format!(" {}", label), header_style)];
let used = 2 + label.len();
if used < inner_w {
spans.push(Span::styled(
" ".repeat(inner_w - used),
Style::default().bg(bg),
));
}
lines.push(Line::from(spans));
}
DepPopupEntry::Nothing => {
let nothing_style = Style::default().fg(dim).bg(bg);
let text = " (nothing)";
let mut spans = vec![Span::styled(text.to_string(), nothing_style)];
let used = text.len();
if used < inner_w {
spans.push(Span::styled(
" ".repeat(inner_w - used),
Style::default().bg(bg),
));
}
lines.push(Line::from(spans));
}
DepPopupEntry::Task {
task_id,
title,
state,
track_id,
depth,
has_children,
is_expanded,
is_circular,
is_dangling,
is_upstream: _,
} => {
let is_selected = entry_idx == dp.cursor;
let row_bg = if is_selected { sel_bg } else { bg };
let row_pad = Style::default().bg(row_bg);
let mut spans: Vec<Span> = Vec::new();
let indent = 4 + depth * 2;
let indent_str = " ".repeat(indent);
spans.push(Span::styled(indent_str.clone(), row_pad));
if *is_circular {
let circ_style = Style::default().fg(dim).bg(row_bg);
spans.push(Span::styled("\u{21BB} ", circ_style));
spans.push(Span::styled(task_id.to_string(), circ_style));
spans.push(Span::styled(" (circular)", circ_style));
pad_to_width(&mut spans, inner_w, row_pad);
lines.push(Line::from(spans));
continue;
}
if *is_dangling {
let dang_style = Style::default().fg(red).bg(row_bg);
spans.push(Span::styled("[?] ", dang_style));
let id_style = if is_selected {
Style::default()
.fg(bright)
.bg(row_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(bright).bg(row_bg)
};
spans.push(Span::styled(task_id.to_string(), id_style));
let not_found_style = Style::default().fg(dim).bg(row_bg);
spans.push(Span::styled(" (not found)", not_found_style));
pad_to_width(&mut spans, inner_w, row_pad);
lines.push(Line::from(spans));
continue;
}
let task_state = state.unwrap_or(TaskState::Todo);
let is_done = task_state == TaskState::Done;
if *has_children {
let arrow = if *is_expanded {
"\u{25BC} "
} else {
"\u{25B6} "
};
let arrow_style = Style::default().fg(dim).bg(row_bg);
spans.push(Span::styled(arrow, arrow_style));
} else {
spans.push(Span::styled(" ", row_pad));
}
let checkbox_char = task_state.checkbox_char();
let state_color = app.theme.state_color(task_state);
let cb_style = if is_done {
Style::default().fg(dim).bg(row_bg)
} else {
Style::default().fg(state_color).bg(row_bg)
};
spans.push(Span::styled(format!("[{}] ", checkbox_char), cb_style));
let id_style = if is_done {
Style::default().fg(dim).bg(row_bg)
} else if is_selected {
Style::default()
.fg(bright)
.bg(row_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(bright).bg(row_bg)
};
spans.push(Span::styled(format!("{} ", task_id), id_style));
let fixed_left = indent + 2 + 4 + task_id.len() + 2;
let track_name = track_id
.as_ref()
.map(|tid| app.track_name(tid))
.unwrap_or("");
let right_part_len = track_name.len() + 2; let title_max = usable_w
.saturating_sub(fixed_left)
.saturating_sub(right_part_len);
let display_title = truncate_with_ellipsis(title, title_max);
let title_style = if is_done {
Style::default().fg(dim).bg(row_bg)
} else if is_selected {
Style::default()
.fg(bright)
.bg(row_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(bright).bg(row_bg)
};
spans.push(Span::styled(display_title.clone(), title_style));
let title_display_len = unicode::display_width(&display_title);
let used_so_far = fixed_left + title_display_len;
let target_end = usable_w.saturating_sub(right_part_len);
if used_so_far < target_end {
spans.push(Span::styled(" ".repeat(target_end - used_so_far), row_pad));
}
let is_same_track = track_id.as_deref() == Some(&dp.root_track_id);
let track_style = if is_same_track || is_done {
Style::default().fg(dim).bg(row_bg)
} else {
Style::default().fg(bright).bg(row_bg)
};
spans.push(Span::styled(format!(" {}", track_name), track_style));
pad_to_width(&mut spans, inner_w, row_pad);
lines.push(Line::from(spans));
}
}
}
lines.push(Line::from(Span::styled(
" ".repeat(inner_w),
Style::default().bg(bg),
)));
let hint_style = Style::default().fg(dim).bg(bg);
let hint = "\u{2190}\u{2192} expand Enter jump Esc close";
let hint_len = unicode::display_width(hint);
let hint_pad = inner_w.saturating_sub(hint_len);
let left_pad = hint_pad / 2;
let right_pad = hint_pad - left_pad;
lines.push(Line::from(vec![
Span::styled(" ".repeat(left_pad), Style::default().bg(bg)),
Span::styled(hint, hint_style),
Span::styled(" ".repeat(right_pad), Style::default().bg(bg)),
]));
let max_h = ((area.height as f32) * 0.7) as u16;
let content_h = lines.len() as u16;
let popup_h = (content_h + 2)
.min(max_h)
.min(area.height.saturating_sub(2));
let x = area.x + area.width.saturating_sub(popup_w) / 2;
let y = area.y + area.height.saturating_sub(popup_h) / 2;
let popup_area = Rect::new(x, y, popup_w, popup_h);
frame.render_widget(Clear, popup_area);
let title = format!(" Dependencies: {} ", dp.root_task_id);
let title_style = Style::default()
.fg(highlight)
.bg(bg)
.add_modifier(Modifier::BOLD);
let block = Block::default()
.title(Span::styled(title, title_style))
.borders(Borders::ALL)
.border_style(Style::default().fg(dim).bg(bg))
.style(Style::default().bg(bg));
let inner_h = popup_h.saturating_sub(2) as usize;
let scroll = dp.scroll_offset;
let paragraph = Paragraph::new(lines)
.block(block)
.scroll((scroll as u16, 0))
.style(Style::default().bg(bg));
frame.render_widget(paragraph, popup_area);
if scroll > 0 {
let up_area = Rect::new(popup_area.x + popup_w - 2, popup_area.y, 1, 1);
frame.render_widget(
Paragraph::new(Span::styled("\u{25B2}", Style::default().fg(dim).bg(bg))),
up_area,
);
}
if content_h > inner_h as u16 && scroll + inner_h < content_h as usize {
let down_area = Rect::new(popup_area.x + popup_w - 2, popup_area.y + popup_h - 1, 1, 1);
frame.render_widget(
Paragraph::new(Span::styled("\u{25BC}", Style::default().fg(dim).bg(bg))),
down_area,
);
}
}
fn pad_to_width<'a>(spans: &mut Vec<Span<'a>>, target_width: usize, pad_style: Style) {
let total_used: usize = spans
.iter()
.map(|s| unicode::display_width(&s.content))
.sum();
if total_used < target_width {
spans.push(Span::styled(
" ".repeat(target_width - total_used),
pad_style,
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::app::{DepPopupEntry, DepPopupState};
use crate::tui::render::test_helpers::*;
use insta::assert_snapshot;
use std::collections::{HashMap, HashSet};
#[test]
fn dep_popup_visible() {
let mut app = app_with_track(SIMPLE_TRACK_MD);
app.dep_popup = Some(DepPopupState {
root_task_id: "T-1".into(),
root_track_id: "test".into(),
entries: vec![
DepPopupEntry::SectionHeader {
label: "Blocked by",
},
DepPopupEntry::Nothing,
DepPopupEntry::SectionHeader { label: "Blocking" },
DepPopupEntry::Task {
task_id: "T-2".into(),
title: "Second task".into(),
state: Some(crate::model::TaskState::Active),
track_id: Some("test".into()),
depth: 0,
has_children: false,
is_expanded: false,
is_circular: false,
is_dangling: false,
is_upstream: false,
},
],
cursor: 3,
scroll_offset: 0,
expanded: HashSet::new(),
visited: HashSet::new(),
inverse_deps: HashMap::new(),
});
let output = render_to_string(TERM_W, TERM_H, |frame, area| {
render_dep_popup(frame, &app, area);
});
assert_snapshot!(output);
}
}