#[allow(clippy::wildcard_imports)]
use super::*;
mod board_view;
mod capacity;
mod dialogs;
mod forms;
mod help;
mod inputs;
mod layout;
mod repos;
mod stats;
#[allow(clippy::wildcard_imports)]
use board_view::*;
#[allow(clippy::wildcard_imports)]
use dialogs::*;
#[allow(clippy::wildcard_imports)]
use forms::*;
#[allow(clippy::wildcard_imports)]
use help::*;
#[allow(clippy::wildcard_imports)]
use inputs::*;
#[allow(clippy::wildcard_imports)]
use layout::*;
#[allow(clippy::wildcard_imports)]
use repos::*;
#[allow(clippy::wildcard_imports)]
use stats::*;
pub(in crate::app) use capacity::{
capacity_cell, capacity_detail, capacity_fields, capacity_row, issue_estimate_line,
};
pub(in crate::app) use dialogs::{confirm_delete_buttons, confirm_delete_prompt};
pub(in crate::app) use layout::{filter_band_height, open_in_browser};
pub(in crate::app) use repos::run_child_tix;
pub(in crate::app) use stats::{HealthRow, feed_minutes};
pub(in crate::app) const HINT_HEIGHT: u16 = 1;
pub(in crate::app) const MENTION_HEIGHT: u16 = 1;
pub(in crate::app) fn mention_row_height(app: &App) -> u16 {
if app.one_shot || app.unread_mentions == 0 {
0
} else {
MENTION_HEIGHT
}
}
pub(in crate::app) const TAB_HEIGHT: u16 = 2;
pub(in crate::app) const FILTER_HEIGHT: u16 = 1;
pub(in crate::app) const HOVER_HEIGHT: u16 = 1;
pub(in crate::app) const RENDER_FALLBACK_WIDTH: u16 = 80;
pub(in crate::app) const RENDER_FALLBACK_HEIGHT: u16 = 24;
pub(in crate::app) const RENDER_BOTTOM_MARGIN: u16 = 4;
#[allow(clippy::too_many_lines)]
pub fn render(app: &App, f: &mut ratatui::Frame, area: Rect) {
app.frame_hit_regions.borrow_mut().clear();
let content = if !app.one_shot && app.nav_display != NavDisplay::Off {
let nav_width = app.nav_width();
if area.width > nav_width {
let nav_area = Rect {
width: nav_width,
..area
};
crate::ui::nav::render(app, f, nav_area);
Rect {
x: area.x + nav_width,
width: area.width - nav_width,
..area
}
} else {
area
}
} else {
area
};
app.frame_size.set(content);
let area = content;
match &app.mode {
Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
Mode::BulkModal => {
render_board(app, f, area, commandbar::MODE_BOARD);
if let Some(modal) = app.bulk.as_ref() {
crate::ui::bulk::render(f, area, modal);
}
}
Mode::TriageInbox => {
if let Some(state) = app.triage.as_ref() {
crate::ui::triage::render(app, f, area, state);
} else {
render_board(app, f, area, commandbar::MODE_BOARD);
}
}
Mode::Cycle => {
if let Some(state) = app.cycle_state.as_ref() {
let groups = app.cycle_groups();
crate::ui::cycle::render(app, f, area, state, &groups);
} else {
render_board(app, f, area, commandbar::MODE_BOARD);
}
}
Mode::Repos => render_repos(app, f, area),
Mode::Stats => render_stats(app, f, area),
Mode::HealthForm => {
render_stats(app, f, area);
render_health_form(app, f, area);
}
Mode::TimeForm => {
render_stats(app, f, area);
render_time_form(app, f, area);
}
Mode::TimeFilterForm => {
render_stats(app, f, area);
render_time_filter_form(app, f, area);
}
Mode::WipOverride { .. } => {
render_board(app, f, area, commandbar::MODE_BOARD);
crate::ui::wip_override::render(app, f, area);
}
Mode::MentionNotice => {
render_board(app, f, area, commandbar::MODE_BOARD);
crate::ui::mentions::render_notice(app, f, area);
}
Mode::Mentions => {
render_board(app, f, area, commandbar::MODE_BOARD);
crate::ui::mentions::render_view(app, f, area);
}
Mode::RecurringWarning => {
render_board(app, f, area, commandbar::MODE_BOARD);
crate::ui::recurring_warning::render(app, f, area);
}
Mode::Schedule => crate::ui::schedule::render(app, f, area),
Mode::Library => crate::ui::library::render(app, f, area),
Mode::Portfolio => crate::ui::portfolio::render(app, f, area),
Mode::RolloverDialog => {
render_board(app, f, area, commandbar::MODE_BOARD);
if let Some(dialog) = app.rollover_dialog.as_ref() {
let rect = crate::ui::modal::modal_rect(area);
f.render_widget(Clear, rect);
let mut lines = Vec::new();
lines.push(Line::from(Span::styled(
"Rollover needed",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)));
for (from, to, count) in &dialog.plans {
lines.push(Line::from(format!(
"{count} issue(s): \"{from}\" -> \"{to}\""
)));
}
lines.push(Line::from(""));
lines.push(Line::from(format!(
"{}: Do rollover | {}: Toggle choice | {}: Ignore",
app.bindings.label(
InputContext::Rollover,
crate::keybindings::TuiAction::Submit
),
app.bindings.label(
InputContext::Rollover,
crate::keybindings::TuiAction::Previous
),
app.bindings.label(
InputContext::Rollover,
crate::keybindings::TuiAction::Cancel
),
)));
lines.push(Line::from(match dialog.choice {
crate::ui::dialog::DialogChoice::DoRollover => "> Do rollover | Ignore",
crate::ui::dialog::DialogChoice::Ignore => "Do rollover | > Ignore",
}));
let para = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.title("Rollover needed")
.style(Style::default().fg(Color::Yellow)),
);
f.render_widget(para, rect);
}
}
Mode::Detail => render_detail(app, f, area, commandbar::MODE_DETAIL),
Mode::Form => {
if let Some(fr) = &app.form {
form::render_with_bindings(fr, f, area, &app.bindings, InputContext::Form);
if let Some(row) = form::field_row_rect(area, form::FormFocus::Template) {
app.record_region(row, Action::TemplateOpen);
}
if let Some(picker) = &app.form_picker {
render_form_picker(app, picker, f, area, &app.bindings);
} else {
form::place_caret(fr, f, area);
}
}
}
Mode::ConfirmDelete => {
if area.height < 2 {
return;
}
board::render(
&app.board,
f,
area,
&None,
app.drag_preview_col,
board::BoardDisplay::from(app.active_board_config()),
&app.theme,
Some(&app.wip),
app.capacity_summaries(),
app.marked_numbers(),
);
let title = app.confirm_delete_target.as_deref().unwrap_or("?");
let prompt_area = confirm_delete_prompt(area);
let prompt = Paragraph::new(vec![
Line::from(Span::styled(
format!("Delete '{title}'? [y/N]"),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(" [Yes] [No] "),
])
.block(
Block::default()
.borders(Borders::ALL)
.title("Confirm")
.style(Style::default().fg(Color::Red)),
);
f.render_widget(prompt, prompt_area);
let (yes, no) = confirm_delete_buttons(prompt_area);
app.record_region(yes, Action::ConfirmDeleteYes);
app.record_region(no, Action::ConfirmDeleteNo);
}
Mode::ConfirmArchive { filenames, restore } => {
if area.height < 2 {
return;
}
board::render(
&app.board,
f,
area,
&None,
app.drag_preview_col,
board::BoardDisplay::from(app.active_board_config()),
&app.theme,
Some(&app.wip),
app.capacity_summaries(),
app.marked_numbers(),
);
let verb = if *restore { "Restore" } else { "Archive" };
let subject = if filenames.len() == 1 {
format!("'{}'", filenames[0])
} else {
format!("{} issues", filenames.len())
};
let prompt_area = confirm_delete_prompt(area);
let prompt = Paragraph::new(vec![Line::from(Span::styled(
format!("{verb} {subject}? [y/N]"),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
))])
.block(
Block::default()
.borders(Borders::ALL)
.title("Confirm")
.style(Style::default().fg(Color::Yellow)),
);
f.render_widget(prompt, prompt_area);
}
Mode::ConfirmBlockedMove { warning, .. } => {
if area.height < 3 {
return;
}
board::render(
&app.board,
f,
area,
&None,
app.drag_preview_col,
board::BoardDisplay::from(app.active_board_config()),
&app.theme,
Some(&app.wip),
app.capacity_summaries(),
app.marked_numbers(),
);
let prompt_area = Rect {
y: area.y + area.height - 2,
height: 2,
..area
};
let prompt = Paragraph::new(vec![
Line::from(Span::styled(
format!("{warning} — move anyway? [y/N]"),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)),
Line::from(" The decision is recorded in the issue's History."),
])
.block(
Block::default()
.borders(Borders::ALL)
.title("Blocked move")
.style(Style::default().fg(Color::Red)),
);
f.render_widget(prompt, prompt_area);
}
Mode::MoveIntoDone { buffer, .. } => {
if area.height < 3 {
return;
}
let prompt_area = Rect {
y: area.y + area.height - 1,
height: 1,
..area
};
let block_area = Rect {
height: area.height - 1,
..area
};
board::render(
&app.board,
f,
block_area,
&app.status_msg,
app.drag_preview_col,
board::BoardDisplay::from(app.active_board_config()),
&app.theme,
Some(&app.wip),
app.capacity_summaries(),
app.marked_numbers(),
);
let prompt_text = format!("Commit hash: {buffer}_");
let prompt = Paragraph::new(Line::from(Span::styled(
prompt_text,
Style::default().fg(Color::Yellow),
)));
f.render_widget(prompt, prompt_area);
}
Mode::TransitionReason { buffer, reason, .. } => {
if area.height < 3 {
return;
}
let block_area = Rect {
height: area.height - 1,
..area
};
board::render(
&app.board,
f,
block_area,
&app.status_msg,
app.drag_preview_col,
board::BoardDisplay::from(app.active_board_config()),
&app.theme,
Some(&app.wip),
app.capacity_summaries(),
app.marked_numbers(),
);
let prompt_area = Rect {
y: area.y + area.height - 1,
height: 1,
..area
};
let prompt = Paragraph::new(Line::from(Span::styled(
format!("Reason (required): {buffer}_"),
Style::default().fg(Color::Yellow),
)));
f.render_widget(prompt, prompt_area);
if area.height >= 5 {
let notice = Rect {
y: area.y,
height: 3,
..area
};
let text = Paragraph::new(vec![
Line::from(Span::styled(
"Blocked by the transition policy",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)),
Line::from(reason.clone()),
])
.block(
Block::default()
.borders(Borders::ALL)
.title("Override required"),
);
f.render_widget(text, notice);
}
}
Mode::Help { return_to } => render_help(app, f, area, return_to),
Mode::Tips { return_to } => render_tips(app, f, area, return_to),
Mode::ActionMenu { return_to } => render_action_menu(app, f, area, return_to),
Mode::Filter { buffer } => render_board_input(app, f, area, "Search", buffer),
Mode::FilterModal => {
render_board(app, f, area, commandbar::MODE_BOARD);
if let Some(m) = app.filter_modal.as_ref() {
m.render_with_bindings(f, area, &app.bindings, InputContext::FilterModal);
let rect = crate::ui::modal::modal_rect(area);
for (row, field) in FilterField::ALL.iter().enumerate() {
let hit = Rect {
x: rect.x + 1,
y: rect.y + 1 + m.header_height() + row as u16,
width: rect.width.saturating_sub(2),
height: 1,
};
app.record_region(hit, Action::FilterModalFocus(*field));
}
if let Some(picker) = app.filter_picker.as_ref() {
render_filter_picker(app, f, area, picker);
}
}
}
Mode::Views => {
render_board(app, f, area, commandbar::MODE_BOARD);
if let Some(m) = app.views.as_ref() {
let context = if m.is_prompting() {
InputContext::ViewsPrompt
} else {
InputContext::Views
};
m.render_with_bindings(f, area, &app.bindings, context);
}
}
Mode::Settings => {
let (cycles, milestones) = app.planning_rows();
let viewer = app.viewer_label();
crate::ui::settings::render_config(
app,
f,
area,
crate::ui::settings::DashboardView {
name: app.active_board_name(),
board: app.active_board_config(),
editor: app.store.config.editor.as_deref(),
viewer: &viewer,
},
&cycles,
&milestones,
);
}
Mode::PlanningManagement => {
render_board(app, f, area, commandbar::MODE_BOARD);
if let Some(m) = app.planning.as_ref() {
let (cycles, milestones) = app.planning_rows();
m.render_with_bindings(
f,
area,
&cycles,
&milestones,
&app.bindings,
InputContext::PlanningManagement,
);
}
}
Mode::PlanningForm => {
render_config_or_board(app, f, area);
if let Some(fm) = app.planning_form.as_ref() {
fm.render_with_bindings(f, area, &app.bindings, InputContext::PlanningForm);
}
}
Mode::DatePicker { return_to } => {
render_config_or_board(app, f, area);
match return_to.as_ref() {
Mode::Form => {
if let Some(fr) = app.form.as_ref() {
form::render_with_bindings(fr, f, area, &app.bindings, InputContext::Form);
}
}
Mode::PlanningForm => {
if let Some(fm) = app.planning_form.as_ref() {
fm.render_with_bindings(f, area, &app.bindings, InputContext::PlanningForm);
}
}
Mode::FilterModal => {
if let Some(m) = app.filter_modal.as_ref() {
m.render_with_bindings(f, area, &app.bindings, InputContext::FilterModal);
}
}
_ => {}
}
if let Some(picker) = app.date_picker.as_ref() {
picker.render_with_bindings(f, area, &app.bindings, InputContext::DatePicker);
let rect = picker.popup_rect(area);
let days_y = rect.y + 3;
let weeks = rect.height.saturating_sub(6);
for row in 0..weeks {
for col in 0..7u16 {
let x = rect.x + 1 + col * 3;
let y = days_y + row;
if let Some(date) = picker.date_at(area, x + 1, y) {
let hit = Rect {
x,
y,
width: 3,
height: 1,
};
app.record_region(
hit,
Action::DatePickerHit(DatePickerHit::Day(
crate::ui::date_picker::format_date(date),
)),
);
}
}
}
let header_y = rect.y + 1;
let header_w = rect.width.saturating_sub(2);
let split = header_w / 2;
if split > 0 {
app.record_region(
Rect {
x: rect.x + 1,
y: header_y,
width: split,
height: 1,
},
Action::DatePickerHit(DatePickerHit::PrevMonth),
);
app.record_region(
Rect {
x: rect.x + 1 + split,
y: header_y,
width: header_w - split,
height: 1,
},
Action::DatePickerHit(DatePickerHit::NextMonth),
);
}
app.record_region(
Rect {
x: rect.x,
y: rect.y + rect.height.saturating_sub(1),
width: rect.width,
height: 1,
},
Action::DatePickerHit(DatePickerHit::Submit),
);
}
}
Mode::Comment { buffer } => render_detail_input(app, f, area, "Comment", buffer),
Mode::Asset { buffer } => render_detail_input(app, f, area, "Asset", buffer),
Mode::ParentPrompt { buffer } => {
render_detail_input(app, f, area, "Parent (blank clears)", buffer)
}
Mode::EditRepair {
filename,
message,
key,
problems,
temp,
discard_armed,
..
} => render_edit_repair(
app,
f,
area,
filename,
message,
key.as_deref(),
problems,
temp.as_deref(),
*discard_armed,
),
Mode::EditConflict {
filename,
temp,
disk_path,
} => render_edit_conflict(app, f, area, filename, temp, disk_path),
Mode::LinkCreate { buffer, step, .. } | Mode::LinkEdit { buffer, step, .. } => {
let label = match (step, matches!(app.mode, Mode::LinkEdit { .. })) {
(LinkStep::Target, false) => "Link to issue",
(LinkStep::Target, true) => "Re-target to issue (blank keeps)",
(LinkStep::Kind, false) => "Kind (required)",
(LinkStep::Kind, true) => "Kind (required)",
};
render_detail_input(app, f, area, label, buffer)
}
Mode::ConfirmRemoveRelationship { .. } => {
if let Some(ref d) = app.detail {
detail::render(d, f, area, &app.theme);
}
render_input_line(
f,
Rect {
y: area.y + area.height.saturating_sub(1),
height: 1,
..area
},
"Remove relationship (y to confirm)",
"",
);
}
}
render_action_feedback(app, f, area);
}
fn render_action_feedback(app: &App, f: &mut ratatui::Frame, area: Rect) {
let Some(feedback) = app.action_feedback.as_ref() else {
return;
};
if area.height == 0 || area.width == 0 {
return;
}
let height = area.height.min(3);
let rect = Rect {
y: area.y + area.height - height,
height,
..area
};
let paragraph = Paragraph::new(vec![
Line::from(Span::styled(
feedback.reason_line(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
feedback.remedy_line(),
Style::default().fg(Color::White),
)),
])
.style(Style::default().bg(Color::DarkGray))
.block(Block::default().borders(Borders::TOP));
f.render_widget(paragraph, rect);
}
pub(in crate::app) fn theme_for(config: &Config) -> crate::ui::color::Theme {
let tag_colors = crate::tags::TagStore::new(config.tags_dir())
.colors()
.unwrap_or_default();
let mut type_colors = std::collections::HashMap::new();
for type_name in config.types() {
if let Some(color) = config
.type_color(&type_name)
.and_then(|name| crate::ui::color::parse(&name))
{
type_colors.insert(type_name.to_ascii_lowercase(), color);
}
}
let mut milestone_colors = std::collections::HashMap::new();
let planner = Planner::from_config(config.clone(), std::path::PathBuf::from("."));
for milestone in planner.list_milestones().unwrap_or_default() {
if let Some(color) = milestone.color.as_deref().and_then(crate::ui::color::parse) {
milestone_colors.insert(milestone.name.to_ascii_lowercase(), color);
}
}
crate::ui::color::Theme::new(tag_colors, type_colors, milestone_colors)
}
pub(in crate::app) fn template_default_lines(
defaults: &crate::templates::registry::TemplateDefaults,
) -> Vec<(String, String)> {
let scalars: [(&str, Option<&String>); 11] = [
("title", defaults.title.as_ref()),
("status", defaults.status.as_ref()),
("priority", defaults.priority.as_ref()),
("assignee", defaults.assignee.as_ref()),
("estimate", defaults.estimate.as_ref()),
("reviewer", defaults.reviewer.as_ref()),
("start_date", defaults.start_date.as_ref()),
("due_date", defaults.due_date.as_ref()),
("parent", defaults.parent.as_ref()),
("type", defaults.type_.as_ref()),
("body", defaults.body.as_ref()),
];
let lists: [(&str, &Vec<String>); 5] = [
("labels", &defaults.labels),
("files", &defaults.files),
("cycles", &defaults.cycles),
("milestones", &defaults.milestones),
("related_issues", &defaults.related_issues),
];
let mut out: Vec<(String, String)> = scalars
.into_iter()
.filter_map(|(key, value)| value.map(|value| (key.to_string(), value.clone())))
.collect();
out.extend(lists.into_iter().filter_map(|(key, values)| {
if values.is_empty() {
None
} else {
Some((key.to_string(), values.join(", ")))
}
}));
out
}
pub(in crate::app) fn template_subtitle(template: &crate::templates::registry::Template) -> String {
let defaults = template_default_lines(&template.defaults).len();
let mut out = template.scope.to_string();
if let Some(name) = template.name.as_deref()
&& !name.is_empty()
{
out.push_str(&format!(" · {name}"));
}
out.push_str(&format!(
" · {defaults} default{} · {} prompt{}",
if defaults == 1 { "" } else { "s" },
template.prompts.len(),
if template.prompts.len() == 1 { "" } else { "s" }
));
out
}
pub(in crate::app) fn plan_lines(plan: &toml::Table) -> Vec<(String, String)> {
plan.iter()
.map(|(key, value)| (format!("plan {key}"), toml_scalar(value)))
.collect()
}
pub(in crate::app) fn toml_scalar(value: &toml::Value) -> String {
match value {
toml::Value::String(text) => text.clone(),
other => other.to_string(),
}
}