pub mod help_popup;
pub mod inspector;
pub mod table;
pub mod theme;
pub mod tracker;
use crate::app::{
ActivePane, App, InputMode, InspectorView, LogTimeField, SortColumn, SortOrder, TrackerView,
};
use help_popup::render_help_popup;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
Frame,
};
fn pane_border_style(is_active: bool) -> Style {
if is_active {
Style::default().fg(Color::Cyan)
} else {
Style::default()
}
}
pub fn render_ui(f: &mut Frame, app: &mut App) {
app.spinner_frame = app.spinner_frame.wrapping_add(1);
let chunks = Layout::default()
.constraints([Constraint::Min(3), Constraint::Length(3)])
.split(f.area());
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(65), Constraint::Percentage(35)])
.split(chunks[0]);
let table = table::render_table(app, main_chunks[0]);
f.render_stateful_widget(table, main_chunks[0], &mut app.table_state);
let has_ticket = app
.table_state
.selected()
.and_then(|i| app.mrs.get(i))
.and_then(|mr| mr.linked_ticket.as_ref())
.is_some();
let right_chunks = if has_ticket {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(67), Constraint::Percentage(33)])
.split(main_chunks[1])
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(100)])
.split(main_chunks[1])
};
let inspector_area = right_chunks[0];
let tracker_area = if has_ticket {
Some(right_chunks[1])
} else {
None
};
let inspector_is_active = app.active_pane == ActivePane::Inspector;
let inspector_title = match (inspector_is_active, app.inspector_view) {
(true, InspectorView::MrInfo) => " MR Inspector [FOCUS] │ [P]: Pipelines ",
(false, InspectorView::MrInfo) => " MR Inspector │ [P]: Pipelines ",
(true, InspectorView::Pipelines) => " Pipelines [FOCUS] │ [P]: MR Info ",
(false, InspectorView::Pipelines) => " Pipelines │ [P]: MR Info ",
};
let inspector_block = Block::default()
.borders(Borders::ALL)
.border_style(pane_border_style(inspector_is_active))
.title(inspector_title);
let inspector_render: Option<(ratatui::text::Text<'static>, u16)> =
app.table_state.selected().and_then(|i| {
let mr = app.visible_mrs().nth(i)?;
let text = match app.inspector_view {
InspectorView::MrInfo => inspector::render_safe_inspector_text(mr, &app.config),
InspectorView::Pipelines => inspector::render_pipelines_text(mr),
};
let line_count = text.lines.len() as u16;
Some((text, line_count))
});
match inspector_render {
Some((rendered_text, line_count)) => {
app.inspector_content_lines = line_count;
app.inspector_pane_height = inspector_area.height.saturating_sub(2);
let inspector_paragraph = Paragraph::new(rendered_text)
.block(inspector_block)
.wrap(Wrap { trim: false })
.scroll((app.inspector_scroll, 0));
f.render_widget(inspector_paragraph, inspector_area);
}
None if app.table_state.selected().is_some() => {
f.render_widget(
Paragraph::new("Selected metadata unavailable.").block(inspector_block),
inspector_area,
);
}
None => {
f.render_widget(
Paragraph::new(
"Select an active Merge Request row to display side inspector panels context.",
)
.block(inspector_block)
.dark_gray(),
inspector_area,
);
}
}
if let Some(area) = tracker_area {
let tracker_render: Option<(ratatui::text::Text<'static>, u16, bool)> =
app.table_state.selected().and_then(|i| {
let mr = app.visible_mrs().nth(i)?;
let tracker_is_active = app.active_pane == ActivePane::Tracker;
let text = match app.tracker_view {
TrackerView::TicketInfo => tracker::render_ticket_info(mr, &app.tracker_colors),
TrackerView::TimeLog => tracker::render_time_log(mr, &app.time_entries),
};
let line_count = text.lines.len() as u16;
Some((text, line_count, tracker_is_active))
});
if let Some((rendered_text, line_count, tracker_is_active)) = tracker_render {
let tracker_title = match (tracker_is_active, app.tracker_view) {
(true, TrackerView::TicketInfo) => {
" Tracker [FOCUS] │ [P]: Time Log │ [L]: Log Time │ [T]: Open URL "
}
(false, TrackerView::TicketInfo) => " Tracker │ [T]: Focus ",
(true, TrackerView::TimeLog) => {
" Time Log [FOCUS] │ [P]: Ticket Info │ [L]: Log Time "
}
(false, TrackerView::TimeLog) => " Time Log │ [T]: Focus ",
};
let tracker_block = Block::default()
.borders(Borders::ALL)
.border_style(pane_border_style(tracker_is_active))
.title(tracker_title);
app.tracker_content_lines = line_count;
app.tracker_pane_height = area.height.saturating_sub(2);
let tracker_paragraph = Paragraph::new(rendered_text)
.block(tracker_block)
.wrap(Wrap { trim: false })
.scroll((app.tracker_scroll, 0));
f.render_widget(tracker_paragraph, area);
}
}
let sort_status = match (app.sort_column, app.sort_order) {
(SortColumn::UpdatedAt, SortOrder::Ascending) => "Sort: Updated ▲",
(SortColumn::UpdatedAt, SortOrder::Descending) => "Sort: Updated ▼",
(SortColumn::Id, SortOrder::Ascending) => "Sort: ID ▲",
(SortColumn::Id, SortOrder::Descending) => "Sort: ID ▼",
(SortColumn::Milestone, SortOrder::Ascending) => "Sort: Milestone ▲",
(SortColumn::Milestone, SortOrder::Descending) => "Sort: Milestone ▼",
(SortColumn::Title, SortOrder::Ascending) => "Sort: Title ▲",
(SortColumn::Title, SortOrder::Descending) => "Sort: Title ▼",
};
let pane_hint = match app.active_pane {
ActivePane::Dashboard => "Pane: Dashboard",
ActivePane::Inspector => "Pane: Inspector",
ActivePane::Tracker => "Pane: Tracker",
};
let (input_title, input_border_style) = match app.input_mode {
InputMode::Editing if !app.milestone_suggestions.is_empty() => (
" MILESTONE │ [↑/↓ Tab]: Navigate │ [Enter]: Bulk-add MRs │ [Esc]: Close ".to_string(),
Style::default().fg(Color::Yellow),
),
InputMode::Editing => (
" INSERT │ MR ID, branch name, or @milestone │ [Enter]: Confirm │ [Esc]: Cancel ".to_string(),
Style::default().fg(Color::Yellow),
),
InputMode::ColumnPicker => (
" COLUMNS │ [↑/↓]: Navigate │ [Space]: Toggle │ [Esc]: Close & Save ".to_string(),
Style::default().fg(Color::Cyan),
),
InputMode::Normal if app.quit_confirm => (
" Quit? Press [Esc] or [y] to confirm, any other key to cancel ".to_string(),
Style::default().fg(Color::Red),
),
InputMode::Normal => (
format!(
" [i] or [/]: Insert mode │ [Tab]: {} │ [S/s]: {} │ [F]: Filter │ [Space]: Flag │ [C]: Columns │ [▲/▼]: Scroll │ [O]: Open │ [R]: Refresh │ [Del]: Delete │ [?]: Help │ [Esc]: Quit ",
pane_hint, sort_status
),
Style::default(),
),
InputMode::FilterPicker => (
" FILTER │ [↑/↓]: Navigate │ [Enter]: Apply │ [Esc]: Cancel ".to_string(),
Style::default().fg(Color::Green),
),
InputMode::LogTime => (
" LOG TIME │ [Tab]: Next field │ [Enter]: Submit │ [Esc]: Cancel ".to_string(),
Style::default().fg(Color::Magenta),
),
InputMode::Help => (
" HELP │ [any key]: Close ".to_string(),
Style::default().fg(Color::Cyan),
),
};
let input_box = Paragraph::new(app.input.as_str()).block(
Block::default()
.borders(Borders::ALL)
.border_style(input_border_style)
.title(input_title),
);
f.render_widget(input_box, chunks[1]);
if app.input_mode == InputMode::ColumnPicker {
render_column_picker(f, app, f.area());
}
if app.input_mode == InputMode::FilterPicker {
render_filter_picker(f, app, f.area());
}
if app.input_mode == InputMode::Editing && !app.milestone_suggestions.is_empty() {
render_milestone_autocomplete(f, app, chunks[1]);
}
if app.input_mode == InputMode::LogTime {
render_log_time_popup(f, app, f.area());
}
if app.input_mode == InputMode::Help {
render_help_popup(f, app);
}
}
fn render_log_time_popup(f: &mut Frame, app: &App, area: Rect) {
use ratatui::widgets::List;
let ticket_label = app
.table_state
.selected()
.and_then(|i| app.visible_mrs().nth(i))
.and_then(|mr| mr.linked_ticket.as_ref())
.map(|t| format!(" ⏱ Log Time — #{} ", t.id))
.unwrap_or_else(|| " ⏱ Log Time ".to_string());
let popup_width: u16 = 60;
let activity_rows = (app.activities.len() as u16).clamp(2, 6);
let popup_height: u16 = 3 + activity_rows + 3 + 2 + 2;
let popup_x = area.x + area.width.saturating_sub(popup_width) / 2;
let popup_y = area.y + area.height.saturating_sub(popup_height) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
f.render_widget(Clear, popup_area);
let outer_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta))
.title(Span::styled(
ticket_label,
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
));
f.render_widget(outer_block, popup_area);
let inner = Rect::new(
popup_area.x + 1,
popup_area.y + 1,
popup_area.width.saturating_sub(2),
popup_area.height.saturating_sub(2),
);
let zones = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Length(activity_rows), Constraint::Length(3), Constraint::Min(1), ])
.split(inner);
let field_style = |focused: bool| -> Style {
if focused {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(theme::MUTED_HINT)
}
};
let duration_focused = app.log_time_form.focused_field == LogTimeField::Duration;
let duration_block = Block::default()
.borders(Borders::ALL)
.border_style(field_style(duration_focused))
.title(Span::styled(
" Duration (e.g. 1h30, 90m, 1.5h) ",
Style::default().fg(Color::White),
));
let duration_widget = Paragraph::new(app.log_time_form.duration_input.as_str())
.block(duration_block)
.style(Style::default().fg(Color::White));
f.render_widget(duration_widget, zones[0]);
let activity_focused = app.log_time_form.focused_field == LogTimeField::Activity;
let activity_block = Block::default()
.borders(Borders::ALL)
.border_style(field_style(activity_focused))
.title(Span::styled(
" Activity [↑/↓] ",
Style::default().fg(Color::White),
));
if app.activities.is_empty() {
let loading = Paragraph::new("Loading activities…")
.block(activity_block)
.style(Style::default().fg(Color::DarkGray));
f.render_widget(loading, zones[1]);
} else {
let cursor = app.log_time_form.selected_activity_idx;
let visible = activity_rows as usize;
let scroll_offset = if cursor >= visible {
cursor + 1 - visible
} else {
0
};
let items: Vec<ListItem> = app
.activities
.iter()
.enumerate()
.skip(scroll_offset)
.take(visible)
.map(|(i, act)| {
let selected = i == cursor;
let style = if selected {
Style::default()
.fg(Color::Black)
.bg(Color::Magenta)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
ListItem::new(Line::from(Span::styled(format!(" {} ", act.name), style)))
})
.collect();
let list = List::new(items).block(activity_block);
let mut list_state = ratatui::widgets::ListState::default();
list_state.select(Some(cursor.saturating_sub(scroll_offset)));
f.render_stateful_widget(list, zones[1], &mut list_state);
}
let comment_focused = app.log_time_form.focused_field == LogTimeField::Comment;
let comment_block = Block::default()
.borders(Borders::ALL)
.border_style(field_style(comment_focused))
.title(Span::styled(
" Comment (optional) ",
Style::default().fg(Color::White),
));
let comment_widget = Paragraph::new(app.log_time_form.comment_input.as_str())
.block(comment_block)
.style(Style::default().fg(Color::White));
f.render_widget(comment_widget, zones[2]);
let bottom_line = if let Some(err) = &app.log_time_form.error {
Line::from(vec![
Span::styled(
" ✘ ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled(err.clone(), Style::default().fg(Color::Red)),
])
} else if app.log_time_form.submitting {
Line::from(vec![Span::styled(
" ⟳ Submitting…",
Style::default().fg(Color::Yellow),
)])
} else {
Line::from(vec![
Span::styled(" [Tab] ", Style::default().fg(theme::MUTED_HINT)),
Span::styled("Next field ", Style::default().fg(theme::MUTED_HINT)),
Span::styled("[Enter] ", Style::default().fg(theme::MUTED_HINT)),
Span::styled("Submit ", Style::default().fg(theme::MUTED_HINT)),
Span::styled("[Esc] ", Style::default().fg(theme::MUTED_HINT)),
Span::styled("Cancel", Style::default().fg(theme::MUTED_HINT)),
])
};
f.render_widget(Paragraph::new(bottom_line), zones[3]);
}
fn render_milestone_autocomplete(f: &mut Frame, app: &App, input_area: Rect) {
let suggestions = &app.milestone_suggestions;
if suggestions.is_empty() {
return;
}
let max_visible: u16 = 8;
let visible_count = (suggestions.len() as u16).min(max_visible);
let popup_height = visible_count + 2;
let popup_width = (input_area.width / 2).max(40);
let popup_x = input_area.x;
let popup_y = input_area.y.saturating_sub(popup_height);
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
f.render_widget(Clear, popup_area);
let cursor = app.milestone_suggestion_cursor;
let scroll_offset = if cursor >= max_visible as usize {
cursor + 1 - max_visible as usize
} else {
0
};
let items: Vec<ListItem> = suggestions
.iter()
.enumerate()
.skip(scroll_offset)
.take(max_visible as usize)
.map(|(i, title)| {
let is_selected = i == cursor;
let style = if is_selected {
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
ListItem::new(Line::from(Span::styled(format!(" {} ", title), style)))
})
.collect();
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Milestones │ [↑/↓ Tab]: Navigate │ [Enter]: Select "),
);
let mut list_state = ListState::default();
list_state.select(Some(cursor.saturating_sub(scroll_offset)));
f.render_stateful_widget(list, popup_area, &mut list_state);
}
fn render_filter_picker(f: &mut Frame, app: &App, area: Rect) {
let cursor = app.filter_picker.cursor;
let needs_text_input = app
.filter_defs
.get(cursor)
.map(|d| d.needs_text_input)
.unwrap_or(false);
let list_height = app.filter_defs.len() as u16;
let input_extra: u16 = if needs_text_input { 3 } else { 0 };
let popup_height = list_height + 2 + input_extra;
let popup_width: u16 = 48;
let popup_x = area.x + area.width.saturating_sub(popup_width) / 2;
let popup_y = area.y + area.height.saturating_sub(popup_height) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
f.render_widget(Clear, popup_area);
let outer_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green))
.title(Span::styled(
" Filter ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
));
f.render_widget(outer_block, popup_area);
let inner = Rect::new(
popup_area.x + 1,
popup_area.y + 1,
popup_area.width.saturating_sub(2),
popup_area.height.saturating_sub(2),
);
let zones = if needs_text_input {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(3)])
.split(inner)
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1)])
.split(inner)
};
let has_any_linked_ticket = app.mrs.iter().any(|mr| mr.linked_ticket.is_some());
let has_any_pipeline = app.mrs.iter().any(|mr| !mr.pipelines.is_empty());
let items: Vec<ListItem> = app
.filter_defs
.iter()
.enumerate()
.map(|(i, def)| {
let is_active = i == cursor;
let is_current = i == app.active_filter.index;
let is_na = match def.id {
"has_linked_ticket" => !has_any_linked_ticket,
"ci_failing" => !has_any_pipeline,
_ => false,
};
let prefix = if is_current { "● " } else { " " };
let display_label = if is_na {
format!("{}{} (n/a)", prefix, def.label)
} else {
format!("{}{}", prefix, def.label)
};
let style = if is_na {
Style::default().fg(Color::Rgb(90, 90, 90))
} else if is_active {
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
} else if is_current {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
ListItem::new(Line::from(Span::styled(display_label, style)))
})
.collect();
let list = List::new(items);
let mut list_state = ListState::default();
list_state.select(Some(cursor));
f.render_stateful_widget(list, zones[0], &mut list_state);
if needs_text_input {
let field_label = app
.filter_defs
.get(cursor)
.map(|d| d.active_label)
.unwrap_or("Query");
let input_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(Span::styled(
format!(" {} ", field_label),
Style::default().fg(Color::White),
));
let input_widget = Paragraph::new(app.filter_picker.input.as_str())
.block(input_block)
.style(Style::default().fg(Color::White));
f.render_widget(input_widget, zones[1]);
}
}
fn render_column_picker(f: &mut Frame, app: &App, area: Rect) {
let has_tracker = app.tracker.is_some();
let entries: Vec<(&str, bool)> = app
.column_defs
.iter()
.filter(|c| {
c.requires_feature
.map(|f| f == "tracker" && has_tracker)
.unwrap_or(true)
})
.map(|c| (c.label, app.config.visible_columns.is_visible(c.id)))
.collect();
let popup_width: u16 = 36;
let popup_height: u16 = entries.len() as u16 + 2;
let popup_x = area.x + area.width.saturating_sub(popup_width) / 2;
let popup_y = area.y + area.height.saturating_sub(popup_height) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
f.render_widget(Clear, popup_area);
let items: Vec<ListItem> = entries
.iter()
.enumerate()
.map(|(i, (label, enabled))| {
let checkbox = if *enabled { "☑" } else { "☐" };
let is_selected = i == app.column_picker_cursor;
let style = if is_selected {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
ListItem::new(Line::from(vec![
Span::styled(format!(" {} ", checkbox), style),
Span::styled(label.to_string(), style),
]))
})
.collect();
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(" Columns "),
);
let mut list_state = ListState::default();
list_state.select(Some(app.column_picker_cursor));
f.render_stateful_widget(list, popup_area, &mut list_state);
}