pub mod autocomplete;
pub mod board_view;
pub mod command_palette;
pub mod conflict_popup;
pub mod dep_popup;
pub mod detail_view;
pub mod help_overlay;
mod helpers;
pub mod inbox_view;
pub mod prefix_confirm;
pub mod project_picker;
pub mod recent_view;
pub mod recovery_overlay;
pub mod results_overlay;
pub mod search_view;
pub mod status_row;
pub mod tab_bar;
pub mod tag_color_popup;
pub mod track_view;
pub mod tracks_view;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use regex::Regex;
use crate::util::unicode;
use super::app::{App, TriageStep, View};
pub fn render(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let bg_style = Style::default().bg(app.theme.background);
frame.render_widget(Block::default().style(bg_style), area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Min(1), Constraint::Length(1), ])
.split(area);
tab_bar::render_tab_bar(frame, app, chunks[0]);
app.autocomplete_anchor = None;
let view = app.view.clone();
match &view {
View::Track(_) => track_view::render_track_view(frame, app, chunks[1]),
View::Detail { .. } => detail_view::render_detail_view(frame, app, chunks[1]),
View::Board => board_view::render_board_view(frame, app, chunks[1]),
View::Tracks => {
tracks_view::render_tracks_view(frame, app, chunks[1]);
}
View::Inbox => {
inbox_view::render_inbox_view(frame, app, chunks[1]);
}
View::Recent => {
recent_view::render_recent_view(frame, app, chunks[1]);
}
View::Search => {
search_view::render_search_view(frame, app, chunks[1]);
}
}
if app.dep_popup.is_some() {
dep_popup::render_dep_popup(frame, app, chunks[1]);
}
if app.tag_color_popup.is_some() {
tag_color_popup::render_tag_color_popup(frame, app, chunks[1]);
}
if app.prefix_rename.as_ref().is_some_and(|pr| pr.confirming) {
prefix_confirm::render_prefix_confirm(frame, app, chunks[1]);
}
if app.project_picker.is_some() {
project_picker::render_project_picker(frame, app, chunks[1]);
}
if app.show_help {
help_overlay::render_help_overlay(frame, app, frame.area());
}
if app.command_palette.is_some() {
command_palette::render_command_palette(frame, app, chunks[1]);
}
if app.show_recovery_log {
recovery_overlay::render_recovery_overlay(frame, app, frame.area());
}
if app.show_results_overlay {
results_overlay::render_results_overlay(frame, app, frame.area());
}
if app.conflict_text.is_some() {
conflict_popup::render_conflict_popup(frame, app, frame.area());
}
if app.mode == super::app::Mode::Edit
&& matches!(app.edit_target, Some(super::app::EditTarget::JumpTo))
{
let x = chunks[2].x + 7 + unicode::display_width(&app.edit_buffer) as u16;
let y = chunks[2].y;
app.autocomplete_anchor = Some((x, y));
}
if app.autocomplete.is_some() {
autocomplete::render_autocomplete(frame, app, chunks[1]);
}
render_triage_position_popup(frame, app);
status_row::render_status_row(frame, app, chunks[2]);
}
fn render_triage_position_popup(frame: &mut Frame, app: &App) {
let ts = match &app.triage_state {
Some(ts) => ts,
None => return,
};
let track_id = match &ts.step {
TriageStep::SelectPosition { track_id } => track_id,
_ => return,
};
let (anchor_x, anchor_y) = match ts.popup_anchor {
Some(pos) => pos,
None => return,
};
let bg = app.theme.background;
let text_color = app.theme.text;
let bright = app.theme.text_bright;
let dim = app.theme.dim;
let highlight = app.theme.highlight;
let track_name = app.track_name(track_id);
let title = format!(" {} ", track_name);
let cursor = ts.position_cursor;
let selected_style = Style::default()
.fg(highlight)
.bg(bg)
.add_modifier(Modifier::BOLD);
let normal_style = Style::default().fg(text_color).bg(bg);
let hint_style = Style::default().fg(dim).bg(bg);
let options: &[(&str, &str)] = &[
("Top of backlog ", "t"),
("Bottom (default)", "b"),
("Cancel ", ""),
];
let entries: Vec<Line> = options
.iter()
.enumerate()
.map(|(i, (label, key))| {
let is_selected = i as u8 == cursor;
let (indicator, style) = if is_selected {
("▶ ", selected_style)
} else {
(" ", normal_style)
};
let mut spans = vec![Span::styled(indicator, style), Span::styled(*label, style)];
if !key.is_empty() {
spans.push(Span::styled(format!(" {}", key), hint_style));
}
Line::from(spans)
})
.collect();
let popup_w: u16 = 24;
let popup_h: u16 = entries.len() as u16 + 2;
let term_area = frame.area();
let cursor_bottom = anchor_y + 1;
let y = if cursor_bottom + popup_h <= term_area.height {
cursor_bottom
} else {
anchor_y.saturating_sub(popup_h)
};
let text_inset: u16 = 4;
let x = anchor_x
.saturating_sub(text_inset)
.min(term_area.width.saturating_sub(popup_w));
let popup_area = Rect::new(x, y, popup_w, popup_h);
frame.render_widget(Clear, popup_area);
let block = Block::default()
.title(Span::styled(
title,
Style::default()
.fg(bright)
.bg(bg)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(dim).bg(bg))
.style(Style::default().bg(bg));
let paragraph = Paragraph::new(entries)
.block(block)
.style(Style::default().bg(bg));
frame.render_widget(paragraph, popup_area);
}
#[cfg(test)]
pub(crate) mod test_helpers;
pub(super) fn truncate_with_ellipsis(s: &str, max_cells: usize) -> String {
unicode::truncate_to_width(s, max_cells)
}
pub(super) fn push_highlighted_spans<'a>(
spans: &mut Vec<Span<'a>>,
text: &str,
base_style: Style,
highlight_style: Style,
search_re: Option<&Regex>,
) {
let re = match search_re {
Some(r) => r,
None => {
spans.push(Span::styled(text.to_string(), base_style));
return;
}
};
let mut last_end = 0;
let mut has_match = false;
for m in re.find_iter(text) {
has_match = true;
if m.start() > last_end {
spans.push(Span::styled(
text[last_end..m.start()].to_string(),
base_style,
));
}
spans.push(Span::styled(
text[m.start()..m.end()].to_string(),
highlight_style,
));
last_end = m.end();
}
if !has_match {
spans.push(Span::styled(text.to_string(), base_style));
} else if last_end < text.len() {
spans.push(Span::styled(text[last_end..].to_string(), base_style));
}
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
use test_helpers::*;
#[test]
fn full_render_track_view() {
let mut app = app_with_track(SIMPLE_TRACK_MD);
let output = render_to_string(TERM_W, TERM_H, |frame, area| {
render(frame, &mut app);
let _ = area; });
assert_snapshot!(output);
}
#[test]
fn full_render_inbox_view() {
let mut app = app_with_inbox(INBOX_MD);
app.view = View::Inbox;
let output = render_to_string(TERM_W, TERM_H, |frame, _area| {
render(frame, &mut app);
});
assert_snapshot!(output);
}
#[test]
fn truncate_with_ellipsis_short() {
assert_eq!(truncate_with_ellipsis("hello", 10), "hello");
}
#[test]
fn truncate_with_ellipsis_exact() {
assert_eq!(truncate_with_ellipsis("hello", 5), "hello");
}
#[test]
fn truncate_with_ellipsis_long() {
let result = truncate_with_ellipsis("hello world this is long", 10);
assert!(result.len() <= 13); assert!(result.ends_with('…'));
}
#[test]
fn push_highlighted_spans_no_regex() {
let mut spans = Vec::new();
push_highlighted_spans(
&mut spans,
"hello world",
Style::default(),
Style::default(),
None,
);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content.as_ref(), "hello world");
}
#[test]
fn push_highlighted_spans_with_match() {
let re = Regex::new("world").unwrap();
let mut spans = Vec::new();
push_highlighted_spans(
&mut spans,
"hello world!",
Style::default(),
Style::default().add_modifier(Modifier::BOLD),
Some(&re),
);
assert_eq!(spans.len(), 3);
assert_eq!(spans[0].content.as_ref(), "hello ");
assert_eq!(spans[1].content.as_ref(), "world");
assert_eq!(spans[2].content.as_ref(), "!");
}
}