use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::ops::track_ops::task_counts;
use crate::tui::app::{App, EditTarget, Mode};
use crate::util::unicode;
use super::push_highlighted_spans;
const COL_W: usize = 5;
const HEADERS: [&str; 5] = ["todo", "act", "blk", "done", "park"];
const CHECKBOXES: [&str; 5] = ["[ ]", "[>]", "[-]", "[x]", "[~]"];
pub fn render_tracks_view(frame: &mut Frame, app: &mut App, area: Rect) {
let mut lines: Vec<Line> = Vec::new();
let cursor = app.tracks_cursor;
let mut active_tracks = Vec::new();
let mut shelved_tracks = Vec::new();
let mut archived_tracks = Vec::new();
for tc in &app.project.config.tracks {
match tc.state.as_str() {
"active" => active_tracks.push(tc),
"shelved" => shelved_tracks.push(tc),
"archived" => archived_tracks.push(tc),
_ => {}
}
}
let cc_focus = app.project.config.agent.cc_focus.as_deref();
let search_re = app.active_search_re();
let total_tracks = active_tracks.len() + shelved_tracks.len() + archived_tracks.len();
let num_width = if total_tracks >= 10 { 2 } else { 1 };
let computed_name_len = app
.project
.config
.tracks
.iter()
.map(|tc| unicode::display_width(&tc.name))
.max()
.unwrap_or(10);
let max_name_len = computed_name_len.max(app.tracks_name_col_min);
app.tracks_name_col_min = max_name_len;
let max_id_len = app
.project
.config
.tracks
.iter()
.map(|tc| {
app.project
.config
.ids
.prefixes
.get(&tc.id)
.map(|p| unicode::display_width(p))
.unwrap_or_else(|| unicode::display_width(&tc.id))
})
.max()
.unwrap_or(2);
let name_col = 1 + num_width + 2 + max_name_len + 2 + max_id_len;
lines.push(Line::from(Span::styled(
format!(" Project: {}", app.project.config.project.name),
Style::default()
.fg(app.theme.text)
.bg(app.theme.background)
.add_modifier(Modifier::BOLD),
)));
lines.push(render_col_names(app, name_col, max_id_len));
let mut flat_idx = 0usize;
let editing_track_id = match (&app.mode, &app.edit_target) {
(Mode::Edit, Some(EditTarget::ExistingTrackName { track_id, .. })) => {
Some(track_id.clone())
}
_ => None,
};
let is_new_track_edit = matches!(
(&app.mode, &app.edit_target),
(Mode::Edit, Some(EditTarget::NewTrackName))
);
let editing_prefix_track_id = match (&app.mode, &app.edit_target) {
(Mode::Edit, Some(EditTarget::ExistingPrefix { track_id, .. })) => Some(track_id.clone()),
_ => None,
};
if !active_tracks.is_empty() || is_new_track_edit {
lines.push(render_section_row(app, "Active", name_col, false));
for (track_i, tc) in active_tracks.iter().enumerate() {
if is_new_track_edit && flat_idx == cursor && track_i == cursor {
lines.push(render_new_track_edit_row(
app,
flat_idx + 1,
num_width,
area.width,
));
flat_idx += 1;
}
let is_cursor = flat_idx == cursor;
let is_flash = app.is_track_flashing(&tc.id);
if editing_track_id.as_deref() == Some(&tc.id) && is_cursor {
lines.push(render_edit_row(
app,
tc,
flat_idx + 1,
num_width,
max_name_len,
max_id_len,
cc_focus,
area.width,
));
} else {
lines.push(render_track_row(
app,
tc,
flat_idx + 1,
num_width,
max_name_len,
max_id_len,
is_cursor,
is_flash,
cc_focus,
area.width,
search_re.as_ref(),
));
}
if editing_prefix_track_id.as_deref() == Some(&tc.id) && is_cursor {
lines.push(render_prefix_edit_row(app, num_width, area.width));
}
flat_idx += 1;
}
if is_new_track_edit && flat_idx == cursor {
lines.push(render_new_track_edit_row(
app,
flat_idx + 1,
num_width,
area.width,
));
flat_idx += 1;
}
lines.push(Line::from(""));
}
if !shelved_tracks.is_empty() {
lines.push(render_section_row(app, "Shelved", name_col, false));
for tc in &shelved_tracks {
let is_cursor = flat_idx == cursor;
let is_flash = app.is_track_flashing(&tc.id);
if editing_track_id.as_deref() == Some(&tc.id) && is_cursor {
lines.push(render_edit_row(
app,
tc,
flat_idx + 1,
num_width,
max_name_len,
max_id_len,
cc_focus,
area.width,
));
} else {
lines.push(render_track_row(
app,
tc,
flat_idx + 1,
num_width,
max_name_len,
max_id_len,
is_cursor,
is_flash,
cc_focus,
area.width,
search_re.as_ref(),
));
}
if editing_prefix_track_id.as_deref() == Some(&tc.id) && is_cursor {
lines.push(render_prefix_edit_row(app, num_width, area.width));
}
flat_idx += 1;
}
lines.push(Line::from(""));
}
if !archived_tracks.is_empty() {
lines.push(render_section_row(app, "Archived", name_col, true));
for tc in &archived_tracks {
let is_cursor = flat_idx == cursor;
let is_flash = app.is_track_flashing(&tc.id);
lines.push(render_track_row(
app,
tc,
flat_idx + 1,
num_width,
max_name_len,
max_id_len,
is_cursor,
is_flash,
cc_focus,
area.width,
search_re.as_ref(),
));
flat_idx += 1;
}
}
if flat_idx == 0 {
lines.clear();
let bg = app.theme.background;
lines.push(Line::from(vec![
Span::styled(
" No tracks — press ",
Style::default().fg(app.theme.text).bg(bg),
),
Span::styled("a", Style::default().fg(app.theme.highlight).bg(bg)),
Span::styled(" to create one", Style::default().fg(app.theme.text).bg(bg)),
]));
}
let paragraph = Paragraph::new(lines).style(Style::default().bg(app.theme.background));
frame.render_widget(paragraph, area);
}
fn render_col_names<'a>(app: &'a App, name_col: usize, max_id_len: usize) -> Line<'a> {
let bg = app.theme.background;
let header_style = Style::default().fg(app.theme.text).bg(bg);
let dim_style = Style::default().fg(app.theme.dim).bg(bg);
let mut spans: Vec<Span> = Vec::new();
let pre_id_col = name_col - max_id_len;
spans.push(Span::styled(
" ".repeat(pre_id_col),
Style::default().bg(bg),
));
spans.push(Span::styled(
format!("{:<width$}", "pfx", width = max_id_len),
dim_style,
));
for header in &HEADERS {
spans.push(Span::styled(
format!("{:>width$}", header, width = COL_W),
header_style,
));
}
Line::from(spans)
}
fn render_section_row<'a>(
app: &'a App,
label: &'static str,
name_col: usize,
is_dim: bool,
) -> Line<'a> {
let bg = app.theme.background;
let label_color = if is_dim {
app.theme.dim
} else {
app.theme.text
};
let label_style = Style::default()
.fg(label_color)
.bg(bg)
.add_modifier(Modifier::BOLD);
let cb_style = Style::default().fg(app.theme.text).bg(bg);
let mut spans: Vec<Span> = Vec::new();
let label_text = format!(" {}", label);
let label_len = unicode::display_width(&label_text);
spans.push(Span::styled(label_text, label_style));
if label_len < name_col {
spans.push(Span::styled(
" ".repeat(name_col - label_len),
Style::default().bg(bg),
));
}
for cb in &CHECKBOXES {
spans.push(Span::styled(
format!("{:>width$}", cb, width = COL_W),
cb_style,
));
}
Line::from(spans)
}
#[allow(clippy::too_many_arguments)]
fn render_track_row<'a>(
app: &'a App,
tc: &crate::model::TrackConfig,
number: usize,
num_width: usize,
max_name_len: usize,
max_id_len: usize,
is_cursor: bool,
is_flash: bool,
cc_focus: Option<&str>,
width: u16,
search_re: Option<®ex::Regex>,
) -> Line<'a> {
let bg = if is_flash {
app.theme.flash_bg
} else if is_cursor {
app.theme.selection_bg
} else {
app.theme.background
};
let stats = app
.project
.tracks
.iter()
.find(|(id, _)| id == &tc.id)
.map(|(_, track)| task_counts(track))
.unwrap_or_default();
let mut spans: Vec<Span> = Vec::new();
if is_cursor || is_flash {
let border_color = if is_flash {
app.theme.yellow
} else {
app.theme.selection_border
};
spans.push(Span::styled(
"\u{258E}",
Style::default().fg(border_color).bg(bg),
));
} else {
spans.push(Span::styled(" ", Style::default().bg(app.theme.background)));
}
let num_str = format!("{:>width$}", number, width = num_width);
spans.push(Span::styled(
num_str,
Style::default().fg(app.theme.dim).bg(bg),
));
spans.push(Span::styled(" ", Style::default().bg(bg)));
let name_style = Style::default().fg(app.theme.text_bright).bg(bg);
let hl_style = Style::default()
.fg(app.theme.search_match_fg)
.bg(app.theme.search_match_bg)
.add_modifier(Modifier::BOLD);
push_highlighted_spans(&mut spans, &tc.name, name_style, hl_style, search_re);
let name_len = unicode::display_width(&tc.name);
if name_len < max_name_len {
spans.push(Span::styled(
" ".repeat(max_name_len - name_len),
Style::default().bg(bg),
));
}
spans.push(Span::styled(" ", Style::default().bg(bg)));
let id_style = Style::default().fg(app.theme.text).bg(bg);
let prefix = app
.project
.config
.ids
.prefixes
.get(&tc.id)
.map(|p| p.to_uppercase())
.unwrap_or_else(|| tc.id.to_uppercase());
let id_text = format!("{:<width$}", prefix, width = max_id_len);
push_highlighted_spans(&mut spans, &id_text, id_style, hl_style, search_re);
let counts = [
stats.todo,
stats.active,
stats.blocked,
stats.done,
stats.parked,
];
let colors = [
app.theme.text, app.theme.highlight, app.theme.red, app.theme.text, app.theme.yellow, ];
for (count, color) in counts.iter().zip(colors.iter()) {
let style = Style::default().fg(*color).bg(bg);
spans.push(Span::styled(
format!("{:>width$}", count, width = COL_W),
style,
));
}
if cc_focus == Some(tc.id.as_str()) {
spans.push(Span::styled(" ", Style::default().bg(bg)));
spans.push(Span::styled(
"\u{2605} cc",
Style::default().fg(app.theme.purple).bg(bg),
));
}
if is_cursor || is_flash {
let content_width: usize = spans
.iter()
.map(|s| unicode::display_width(&s.content))
.sum();
let w = width as usize;
if content_width < w {
spans.push(Span::styled(
" ".repeat(w - content_width),
Style::default().bg(bg),
));
}
}
Line::from(spans)
}
#[allow(clippy::too_many_arguments)]
fn render_edit_row<'a>(
app: &'a App,
tc: &crate::model::TrackConfig,
number: usize,
num_width: usize,
max_name_len: usize,
max_id_len: usize,
cc_focus: Option<&str>,
width: u16,
) -> Line<'a> {
let bg = app.theme.selection_bg;
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(
"\u{258E}",
Style::default().fg(app.theme.selection_border).bg(bg),
));
let num_str = format!("{:>width$}", number, width = num_width);
spans.push(Span::styled(
num_str,
Style::default().fg(app.theme.dim).bg(bg),
));
spans.push(Span::styled(" ", Style::default().bg(bg)));
let cursor_pos = app.edit_cursor;
let buffer = &app.edit_buffer;
let text_style = Style::default().fg(app.theme.text_bright).bg(bg);
let cursor_style = Style::default().fg(app.theme.highlight).bg(bg);
let buf_char_len;
if cursor_pos < buffer.len() {
let before = &buffer[..cursor_pos];
let grapheme = unicode::grapheme_at(buffer, cursor_pos);
let after = &buffer[cursor_pos + grapheme.len()..];
buf_char_len = unicode::display_width(buffer);
spans.push(Span::styled(before.to_string(), text_style));
spans.push(Span::styled(
grapheme.to_string(),
Style::default()
.fg(app.theme.background)
.bg(app.theme.highlight)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(after.to_string(), text_style));
} else {
buf_char_len = unicode::display_width(buffer) + 1; spans.push(Span::styled(buffer.to_string(), text_style));
spans.push(Span::styled("\u{258C}", cursor_style));
}
if buf_char_len < max_name_len {
spans.push(Span::styled(
" ".repeat(max_name_len - buf_char_len),
Style::default().bg(bg),
));
}
spans.push(Span::styled(" ", Style::default().bg(bg)));
let id_style = Style::default().fg(app.theme.text).bg(bg);
let prefix = app
.project
.config
.ids
.prefixes
.get(&tc.id)
.map(|p| p.to_uppercase())
.unwrap_or_else(|| tc.id.to_uppercase());
spans.push(Span::styled(
format!("{:<width$}", prefix, width = max_id_len),
id_style,
));
let stats = app
.project
.tracks
.iter()
.find(|(id, _)| id == &tc.id)
.map(|(_, track)| task_counts(track))
.unwrap_or_default();
let counts = [
stats.todo,
stats.active,
stats.blocked,
stats.done,
stats.parked,
];
let colors = [
app.theme.text,
app.theme.highlight,
app.theme.red,
app.theme.text,
app.theme.yellow,
];
for (count, color) in counts.iter().zip(colors.iter()) {
let style = Style::default().fg(*color).bg(bg);
spans.push(Span::styled(
format!("{:>width$}", count, width = COL_W),
style,
));
}
if cc_focus == Some(tc.id.as_str()) {
spans.push(Span::styled(" ", Style::default().bg(bg)));
spans.push(Span::styled(
"\u{2605} cc",
Style::default().fg(app.theme.purple).bg(bg),
));
}
let content_width: usize = spans
.iter()
.map(|s| unicode::display_width(&s.content))
.sum();
let w = width as usize;
if content_width < w {
spans.push(Span::styled(
" ".repeat(w - content_width),
Style::default().bg(bg),
));
}
Line::from(spans)
}
fn render_new_track_edit_row<'a>(
app: &'a App,
number: usize,
num_width: usize,
width: u16,
) -> Line<'a> {
let bg = app.theme.selection_bg;
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(
"\u{258E}",
Style::default().fg(app.theme.selection_border).bg(bg),
));
let num_str = format!("{:>width$}", number, width = num_width);
spans.push(Span::styled(
num_str,
Style::default().fg(app.theme.dim).bg(bg),
));
spans.push(Span::styled(" ", Style::default().bg(bg)));
let cursor_pos = app.edit_cursor;
let buffer = &app.edit_buffer;
let text_style = Style::default().fg(app.theme.text_bright).bg(bg);
let cursor_style = Style::default().fg(app.theme.highlight).bg(bg);
if cursor_pos < buffer.len() {
let before = &buffer[..cursor_pos];
let grapheme = unicode::grapheme_at(buffer, cursor_pos);
let after = &buffer[cursor_pos + grapheme.len()..];
spans.push(Span::styled(before.to_string(), text_style));
spans.push(Span::styled(
grapheme.to_string(),
Style::default()
.fg(app.theme.background)
.bg(app.theme.highlight)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(after.to_string(), text_style));
} else {
spans.push(Span::styled(buffer.to_string(), text_style));
spans.push(Span::styled("\u{258C}", cursor_style));
}
let content_width: usize = spans
.iter()
.map(|s| unicode::display_width(&s.content))
.sum();
let w = width as usize;
if content_width < w {
spans.push(Span::styled(
" ".repeat(w - content_width),
Style::default().bg(bg),
));
}
Line::from(spans)
}
fn render_prefix_edit_row<'a>(app: &'a App, num_width: usize, width: u16) -> Line<'a> {
let bg = app.theme.selection_bg;
let mut spans: Vec<Span> = Vec::new();
let indent = 1 + num_width + 2;
spans.push(Span::styled(" ".repeat(indent), Style::default().bg(bg)));
spans.push(Span::styled(
"Prefix: ",
Style::default().fg(app.theme.dim).bg(bg),
));
let cursor_pos = app.edit_cursor;
let buffer = &app.edit_buffer;
let text_style = Style::default()
.fg(app.theme.text_bright)
.bg(bg)
.add_modifier(Modifier::BOLD);
let cursor_style = Style::default()
.fg(app.theme.background)
.bg(app.theme.highlight)
.add_modifier(Modifier::BOLD);
let sel_range = app.edit_selection_range();
if let Some((sel_start, sel_end)) = sel_range {
let selection_style = Style::default()
.fg(app.theme.text_bright)
.bg(app.theme.highlight);
let before_sel = &buffer[..sel_start];
let selected = &buffer[sel_start..sel_end];
let after_sel = &buffer[sel_end..];
if !before_sel.is_empty() {
spans.push(Span::styled(before_sel.to_string(), text_style));
}
spans.push(Span::styled(selected.to_string(), selection_style));
if !after_sel.is_empty() {
spans.push(Span::styled(after_sel.to_string(), text_style));
}
if cursor_pos >= buffer.len() {
spans.push(Span::styled(
"\u{258C}",
Style::default().fg(app.theme.highlight).bg(bg),
));
}
} else if cursor_pos < buffer.len() {
let before = &buffer[..cursor_pos];
let grapheme = unicode::grapheme_at(buffer, cursor_pos);
let after = &buffer[cursor_pos + grapheme.len()..];
spans.push(Span::styled(before.to_string(), text_style));
spans.push(Span::styled(grapheme.to_string(), cursor_style));
spans.push(Span::styled(after.to_string(), text_style));
} else {
spans.push(Span::styled(buffer.to_string(), text_style));
spans.push(Span::styled(
"\u{258C}",
Style::default().fg(app.theme.highlight).bg(bg),
));
}
if let Some(ref pr) = app.prefix_rename
&& !pr.validation_error.is_empty()
{
spans.push(Span::styled(" ", Style::default().bg(bg)));
spans.push(Span::styled(
pr.validation_error.clone(),
Style::default().fg(app.theme.red).bg(bg),
));
}
let content_width: usize = spans
.iter()
.map(|s| unicode::display_width(&s.content))
.sum();
let w = width as usize;
if content_width < w {
spans.push(Span::styled(
" ".repeat(w - content_width),
Style::default().bg(bg),
));
}
Line::from(spans)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::render::test_helpers::*;
use insta::assert_snapshot;
#[test]
fn overview_multiple_tracks() {
let mut project = project_with_track(
"alpha",
"Alpha",
"# Alpha\n\n## Backlog\n\n- [ ] `A-1` Task one\n- [>] `A-2` Task two\n\n## Done\n\n- [x] `A-3` Done task\n",
);
let track2 = crate::parse::parse_track(
"# Beta\n\n## Backlog\n\n- [-] `B-1` Blocked task\n\n## Done\n",
);
project.config.tracks.push(crate::model::TrackConfig {
id: "beta".into(),
name: "Beta".into(),
state: "active".into(),
file: "tracks/beta.md".into(),
});
project.tracks.push(("beta".into(), track2));
let mut app = App::new(project);
app.view = crate::tui::app::View::Tracks;
let output = render_to_string(TERM_W, TERM_H, |frame, area| {
render_tracks_view(frame, &mut app, area);
});
assert_snapshot!(output);
}
#[test]
fn overview_single_track() {
let mut app = app_with_track(SIMPLE_TRACK_MD);
app.view = crate::tui::app::View::Tracks;
let output = render_to_string(TERM_W, TERM_H, |frame, area| {
render_tracks_view(frame, &mut app, area);
});
assert_snapshot!(output);
}
#[test]
fn overview_edit_mode() {
let mut app = app_with_track(SIMPLE_TRACK_MD);
app.view = crate::tui::app::View::Tracks;
app.mode = Mode::Edit;
app.edit_target = Some(EditTarget::NewTrackName);
app.edit_buffer = "New Track".into();
let output = render_to_string(TERM_W, TERM_H, |frame, area| {
render_tracks_view(frame, &mut app, area);
});
assert_snapshot!(output);
}
}