1#[allow(clippy::wildcard_imports)]
2use super::*;
3mod board_view;
4mod capacity;
5mod dialogs;
6mod forms;
7mod help;
8mod inputs;
9mod layout;
10mod repos;
11mod stats;
12#[allow(clippy::wildcard_imports)]
13use board_view::*;
14#[allow(clippy::wildcard_imports)]
15use dialogs::*;
16#[allow(clippy::wildcard_imports)]
17use forms::*;
18#[allow(clippy::wildcard_imports)]
19use help::*;
20#[allow(clippy::wildcard_imports)]
21use inputs::*;
22#[allow(clippy::wildcard_imports)]
23use layout::*;
24#[allow(clippy::wildcard_imports)]
25use repos::*;
26#[allow(clippy::wildcard_imports)]
27use stats::*;
28
29pub(in crate::app) use capacity::{
30 capacity_cell, capacity_detail, capacity_fields, capacity_row, issue_estimate_line,
31};
32pub(in crate::app) use dialogs::{confirm_delete_buttons, confirm_delete_prompt};
33pub(in crate::app) use layout::{filter_band_height, open_in_browser};
34pub(in crate::app) use repos::run_child_tix;
35pub(in crate::app) use stats::{HealthRow, feed_minutes};
36
37pub(in crate::app) const HINT_HEIGHT: u16 = 1;
38
39pub(in crate::app) const MENTION_HEIGHT: u16 = 1;
42
43pub(in crate::app) fn mention_row_height(app: &App) -> u16 {
50 if app.one_shot || app.unread_mentions == 0 {
51 0
52 } else {
53 MENTION_HEIGHT
54 }
55}
56pub(in crate::app) const TAB_HEIGHT: u16 = 2;
60pub(in crate::app) const FILTER_HEIGHT: u16 = 1;
63pub(in crate::app) const HOVER_HEIGHT: u16 = 1;
66pub(in crate::app) const RENDER_FALLBACK_WIDTH: u16 = 80;
67pub(in crate::app) const RENDER_FALLBACK_HEIGHT: u16 = 24;
68pub(in crate::app) const RENDER_BOTTOM_MARGIN: u16 = 4;
71#[allow(clippy::too_many_lines)]
72pub fn render(app: &App, f: &mut ratatui::Frame, area: Rect) {
73 app.frame_hit_regions.borrow_mut().clear();
74 let content = if !app.one_shot && app.nav_display != NavDisplay::Off {
80 let nav_width = app.nav_width();
81 if area.width > nav_width {
82 let nav_area = Rect {
83 width: nav_width,
84 ..area
85 };
86 crate::ui::nav::render(app, f, nav_area);
87 Rect {
88 x: area.x + nav_width,
89 width: area.width - nav_width,
90 ..area
91 }
92 } else {
93 area
94 }
95 } else {
96 area
97 };
98 app.frame_size.set(content);
99 let area = content;
102 match &app.mode {
103 Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
104 Mode::BulkModal => {
105 render_board(app, f, area, commandbar::MODE_BOARD);
108 if let Some(modal) = app.bulk.as_ref() {
109 crate::ui::bulk::render(f, area, modal);
110 }
111 }
112 Mode::TriageInbox => {
113 if let Some(state) = app.triage.as_ref() {
117 crate::ui::triage::render(app, f, area, state);
118 } else {
119 render_board(app, f, area, commandbar::MODE_BOARD);
120 }
121 }
122 Mode::Cycle => {
123 if let Some(state) = app.cycle_state.as_ref() {
127 let groups = app.cycle_groups();
128 crate::ui::cycle::render(app, f, area, state, &groups);
129 } else {
130 render_board(app, f, area, commandbar::MODE_BOARD);
131 }
132 }
133 Mode::Repos => render_repos(app, f, area),
134 Mode::Stats => render_stats(app, f, area),
135 Mode::HealthForm => {
136 render_stats(app, f, area);
139 render_health_form(app, f, area);
140 }
141 Mode::TimeForm => {
142 render_stats(app, f, area);
145 render_time_form(app, f, area);
146 }
147 Mode::TimeFilterForm => {
148 render_stats(app, f, area);
149 render_time_filter_form(app, f, area);
150 }
151 Mode::WipOverride { .. } => {
152 render_board(app, f, area, commandbar::MODE_BOARD);
155 crate::ui::wip_override::render(app, f, area);
156 }
157 Mode::MentionNotice => {
158 render_board(app, f, area, commandbar::MODE_BOARD);
160 crate::ui::mentions::render_notice(app, f, area);
161 }
162 Mode::Mentions => {
163 render_board(app, f, area, commandbar::MODE_BOARD);
164 crate::ui::mentions::render_view(app, f, area);
165 }
166 Mode::RecurringWarning => {
167 render_board(app, f, area, commandbar::MODE_BOARD);
170 crate::ui::recurring_warning::render(app, f, area);
171 }
172 Mode::Schedule => crate::ui::schedule::render(app, f, area),
173 Mode::Library => crate::ui::library::render(app, f, area),
174 Mode::Portfolio => crate::ui::portfolio::render(app, f, area),
175 Mode::RolloverDialog => {
176 render_board(app, f, area, commandbar::MODE_BOARD);
177 if let Some(dialog) = app.rollover_dialog.as_ref() {
178 let rect = crate::ui::modal::modal_rect(area);
179 f.render_widget(Clear, rect);
180 let mut lines = Vec::new();
181 lines.push(Line::from(Span::styled(
182 "Rollover needed",
183 Style::default()
184 .fg(Color::Yellow)
185 .add_modifier(Modifier::BOLD),
186 )));
187 for (from, to, count) in &dialog.plans {
188 lines.push(Line::from(format!(
189 "{count} issue(s): \"{from}\" -> \"{to}\""
190 )));
191 }
192 lines.push(Line::from(""));
193 lines.push(Line::from(format!(
194 "{}: Do rollover | {}: Toggle choice | {}: Ignore",
195 app.bindings.label(
196 InputContext::Rollover,
197 crate::keybindings::TuiAction::Submit
198 ),
199 app.bindings.label(
200 InputContext::Rollover,
201 crate::keybindings::TuiAction::Previous
202 ),
203 app.bindings.label(
204 InputContext::Rollover,
205 crate::keybindings::TuiAction::Cancel
206 ),
207 )));
208 lines.push(Line::from(match dialog.choice {
209 crate::ui::dialog::DialogChoice::DoRollover => "> Do rollover | Ignore",
210 crate::ui::dialog::DialogChoice::Ignore => "Do rollover | > Ignore",
211 }));
212 let para = Paragraph::new(lines).block(
213 Block::default()
214 .borders(Borders::ALL)
215 .title("Rollover needed")
216 .style(Style::default().fg(Color::Yellow)),
217 );
218 f.render_widget(para, rect);
219 }
220 }
221 Mode::Detail => render_detail(app, f, area, commandbar::MODE_DETAIL),
222 Mode::Form => {
223 if let Some(fr) = &app.form {
227 form::render_with_bindings(fr, f, area, &app.bindings, InputContext::Form);
228 if let Some(row) = form::field_row_rect(area, form::FormFocus::Template) {
232 app.record_region(row, Action::TemplateOpen);
233 }
234 if let Some(picker) = &app.form_picker {
235 render_form_picker(app, picker, f, area, &app.bindings);
236 } else {
237 form::place_caret(fr, f, area);
241 }
242 }
243 }
244 Mode::ConfirmDelete => {
245 if area.height < 2 {
246 return;
247 }
248 board::render(
249 &app.board,
250 f,
251 area,
252 &None,
253 app.drag_preview_col,
254 board::BoardDisplay::from(app.active_board_config()),
255 &app.theme,
256 Some(&app.wip),
257 app.capacity_summaries(),
258 app.marked_numbers(),
259 );
260 let title = app.confirm_delete_target.as_deref().unwrap_or("?");
261 let prompt_area = confirm_delete_prompt(area);
262 let prompt = Paragraph::new(vec![
263 Line::from(Span::styled(
264 format!("Delete '{title}'? [y/N]"),
265 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
266 )),
267 Line::from(""),
268 Line::from(" [Yes] [No] "),
269 ])
270 .block(
271 Block::default()
272 .borders(Borders::ALL)
273 .title("Confirm")
274 .style(Style::default().fg(Color::Red)),
275 );
276 f.render_widget(prompt, prompt_area);
277 let (yes, no) = confirm_delete_buttons(prompt_area);
280 app.record_region(yes, Action::ConfirmDeleteYes);
281 app.record_region(no, Action::ConfirmDeleteNo);
282 }
283 Mode::ConfirmArchive { filenames, restore } => {
284 if area.height < 2 {
285 return;
286 }
287 board::render(
288 &app.board,
289 f,
290 area,
291 &None,
292 app.drag_preview_col,
293 board::BoardDisplay::from(app.active_board_config()),
294 &app.theme,
295 Some(&app.wip),
296 app.capacity_summaries(),
297 app.marked_numbers(),
298 );
299 let verb = if *restore { "Restore" } else { "Archive" };
300 let subject = if filenames.len() == 1 {
301 format!("'{}'", filenames[0])
302 } else {
303 format!("{} issues", filenames.len())
304 };
305 let prompt_area = confirm_delete_prompt(area);
306 let prompt = Paragraph::new(vec![Line::from(Span::styled(
307 format!("{verb} {subject}? [y/N]"),
308 Style::default()
309 .fg(Color::Yellow)
310 .add_modifier(Modifier::BOLD),
311 ))])
312 .block(
313 Block::default()
314 .borders(Borders::ALL)
315 .title("Confirm")
316 .style(Style::default().fg(Color::Yellow)),
317 );
318 f.render_widget(prompt, prompt_area);
319 }
320 Mode::ConfirmBlockedMove { warning, .. } => {
321 if area.height < 3 {
322 return;
323 }
324 board::render(
325 &app.board,
326 f,
327 area,
328 &None,
329 app.drag_preview_col,
330 board::BoardDisplay::from(app.active_board_config()),
331 &app.theme,
332 Some(&app.wip),
333 app.capacity_summaries(),
334 app.marked_numbers(),
335 );
336 let prompt_area = Rect {
337 y: area.y + area.height - 2,
338 height: 2,
339 ..area
340 };
341 let prompt = Paragraph::new(vec![
344 Line::from(Span::styled(
345 format!("{warning} — move anyway? [y/N]"),
346 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
347 )),
348 Line::from(" The decision is recorded in the issue's History."),
349 ])
350 .block(
351 Block::default()
352 .borders(Borders::ALL)
353 .title("Blocked move")
354 .style(Style::default().fg(Color::Red)),
355 );
356 f.render_widget(prompt, prompt_area);
357 }
358 Mode::MoveIntoDone { buffer, .. } => {
359 if area.height < 3 {
360 return;
361 }
362 let prompt_area = Rect {
363 y: area.y + area.height - 1,
364 height: 1,
365 ..area
366 };
367 let block_area = Rect {
368 height: area.height - 1,
369 ..area
370 };
371 board::render(
372 &app.board,
373 f,
374 block_area,
375 &app.status_msg,
376 app.drag_preview_col,
377 board::BoardDisplay::from(app.active_board_config()),
378 &app.theme,
379 Some(&app.wip),
380 app.capacity_summaries(),
381 app.marked_numbers(),
382 );
383 let prompt_text = format!("Commit hash: {buffer}_");
384 let prompt = Paragraph::new(Line::from(Span::styled(
385 prompt_text,
386 Style::default().fg(Color::Yellow),
387 )));
388 f.render_widget(prompt, prompt_area);
389 }
390 Mode::TransitionReason { buffer, reason, .. } => {
395 if area.height < 3 {
396 return;
397 }
398 let block_area = Rect {
399 height: area.height - 1,
400 ..area
401 };
402 board::render(
403 &app.board,
404 f,
405 block_area,
406 &app.status_msg,
407 app.drag_preview_col,
408 board::BoardDisplay::from(app.active_board_config()),
409 &app.theme,
410 Some(&app.wip),
411 app.capacity_summaries(),
412 app.marked_numbers(),
413 );
414 let prompt_area = Rect {
415 y: area.y + area.height - 1,
416 height: 1,
417 ..area
418 };
419 let prompt = Paragraph::new(Line::from(Span::styled(
420 format!("Reason (required): {buffer}_"),
421 Style::default().fg(Color::Yellow),
422 )));
423 f.render_widget(prompt, prompt_area);
424 if area.height >= 5 {
429 let notice = Rect {
430 y: area.y,
431 height: 3,
432 ..area
433 };
434 let text = Paragraph::new(vec![
435 Line::from(Span::styled(
436 "Blocked by the transition policy",
437 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
438 )),
439 Line::from(reason.clone()),
440 ])
441 .block(
442 Block::default()
443 .borders(Borders::ALL)
444 .title("Override required"),
445 );
446 f.render_widget(text, notice);
447 }
448 }
449 Mode::Help { return_to } => render_help(app, f, area, return_to),
450 Mode::Tips { return_to } => render_tips(app, f, area, return_to),
451 Mode::ActionMenu { return_to } => render_action_menu(app, f, area, return_to),
452 Mode::Filter { buffer } => render_board_input(app, f, area, "Search", buffer),
453 Mode::FilterModal => {
454 render_board(app, f, area, commandbar::MODE_BOARD);
455 if let Some(m) = app.filter_modal.as_ref() {
456 m.render_with_bindings(f, area, &app.bindings, InputContext::FilterModal);
457 let rect = crate::ui::modal::modal_rect(area);
463 for (row, field) in FilterField::ALL.iter().enumerate() {
464 let hit = Rect {
465 x: rect.x + 1,
466 y: rect.y + 1 + m.header_height() + row as u16,
467 width: rect.width.saturating_sub(2),
468 height: 1,
469 };
470 app.record_region(hit, Action::FilterModalFocus(*field));
471 }
472 if let Some(picker) = app.filter_picker.as_ref() {
473 render_filter_picker(app, f, area, picker);
474 }
475 }
476 }
477 Mode::Views => {
478 render_board(app, f, area, commandbar::MODE_BOARD);
481 if let Some(m) = app.views.as_ref() {
482 let context = if m.is_prompting() {
483 InputContext::ViewsPrompt
484 } else {
485 InputContext::Views
486 };
487 m.render_with_bindings(f, area, &app.bindings, context);
488 }
489 }
490 Mode::Settings => {
491 let (cycles, milestones) = app.planning_rows();
496 let viewer = app.viewer_label();
497 crate::ui::settings::render_config(
498 app,
499 f,
500 area,
501 crate::ui::settings::DashboardView {
502 name: app.active_board_name(),
503 board: app.active_board_config(),
504 editor: app.store.config.editor.as_deref(),
505 viewer: &viewer,
506 },
507 &cycles,
508 &milestones,
509 );
510 }
511 Mode::PlanningManagement => {
512 render_board(app, f, area, commandbar::MODE_BOARD);
513 if let Some(m) = app.planning.as_ref() {
514 let (cycles, milestones) = app.planning_rows();
515 m.render_with_bindings(
516 f,
517 area,
518 &cycles,
519 &milestones,
520 &app.bindings,
521 InputContext::PlanningManagement,
522 );
523 }
524 }
525 Mode::PlanningForm => {
526 render_config_or_board(app, f, area);
527 if let Some(fm) = app.planning_form.as_ref() {
528 fm.render_with_bindings(f, area, &app.bindings, InputContext::PlanningForm);
529 }
530 }
531 Mode::DatePicker { return_to } => {
532 render_config_or_board(app, f, area);
533 match return_to.as_ref() {
534 Mode::Form => {
535 if let Some(fr) = app.form.as_ref() {
538 form::render_with_bindings(fr, f, area, &app.bindings, InputContext::Form);
539 }
540 }
541 Mode::PlanningForm => {
542 if let Some(fm) = app.planning_form.as_ref() {
543 fm.render_with_bindings(f, area, &app.bindings, InputContext::PlanningForm);
544 }
545 }
546 Mode::FilterModal => {
547 if let Some(m) = app.filter_modal.as_ref() {
548 m.render_with_bindings(f, area, &app.bindings, InputContext::FilterModal);
549 }
550 }
551 _ => {}
552 }
553 if let Some(picker) = app.date_picker.as_ref() {
554 picker.render_with_bindings(f, area, &app.bindings, InputContext::DatePicker);
555 let rect = picker.popup_rect(area);
558 let days_y = rect.y + 3;
559 let weeks = rect.height.saturating_sub(6);
560 for row in 0..weeks {
561 for col in 0..7u16 {
562 let x = rect.x + 1 + col * 3;
563 let y = days_y + row;
564 if let Some(date) = picker.date_at(area, x + 1, y) {
565 let hit = Rect {
566 x,
567 y,
568 width: 3,
569 height: 1,
570 };
571 app.record_region(
572 hit,
573 Action::DatePickerHit(DatePickerHit::Day(
574 crate::ui::date_picker::format_date(date),
575 )),
576 );
577 }
578 }
579 }
580 let header_y = rect.y + 1;
581 let header_w = rect.width.saturating_sub(2);
582 let split = header_w / 2;
583 if split > 0 {
584 app.record_region(
585 Rect {
586 x: rect.x + 1,
587 y: header_y,
588 width: split,
589 height: 1,
590 },
591 Action::DatePickerHit(DatePickerHit::PrevMonth),
592 );
593 app.record_region(
594 Rect {
595 x: rect.x + 1 + split,
596 y: header_y,
597 width: header_w - split,
598 height: 1,
599 },
600 Action::DatePickerHit(DatePickerHit::NextMonth),
601 );
602 }
603 app.record_region(
604 Rect {
605 x: rect.x,
606 y: rect.y + rect.height.saturating_sub(1),
607 width: rect.width,
608 height: 1,
609 },
610 Action::DatePickerHit(DatePickerHit::Submit),
611 );
612 }
613 }
614 Mode::Comment { buffer } => render_detail_input(app, f, area, "Comment", buffer),
615 Mode::Asset { buffer } => render_detail_input(app, f, area, "Asset", buffer),
616 Mode::ParentPrompt { buffer } => {
619 render_detail_input(app, f, area, "Parent (blank clears)", buffer)
620 }
621 Mode::EditRepair {
622 filename,
623 message,
624 key,
625 problems,
626 temp,
627 discard_armed,
628 ..
629 } => render_edit_repair(
630 app,
631 f,
632 area,
633 filename,
634 message,
635 key.as_deref(),
636 problems,
637 temp.as_deref(),
638 *discard_armed,
639 ),
640 Mode::EditConflict {
644 filename,
645 temp,
646 disk_path,
647 } => render_edit_conflict(app, f, area, filename, temp, disk_path),
648 Mode::LinkCreate { buffer, step, .. } | Mode::LinkEdit { buffer, step, .. } => {
652 let label = match (step, matches!(app.mode, Mode::LinkEdit { .. })) {
653 (LinkStep::Target, false) => "Link to issue",
654 (LinkStep::Target, true) => "Re-target to issue (blank keeps)",
655 (LinkStep::Kind, false) => "Kind (required)",
656 (LinkStep::Kind, true) => "Kind (required)",
657 };
658 render_detail_input(app, f, area, label, buffer)
659 }
660 Mode::ConfirmRemoveRelationship { .. } => {
661 if let Some(ref d) = app.detail {
662 detail::render(d, f, area, &app.theme);
663 }
664 render_input_line(
665 f,
666 Rect {
667 y: area.y + area.height.saturating_sub(1),
668 height: 1,
669 ..area
670 },
671 "Remove relationship (y to confirm)",
672 "",
673 );
674 }
675 }
676 render_action_feedback(app, f, area);
677}
678
679fn render_action_feedback(app: &App, f: &mut ratatui::Frame, area: Rect) {
685 let Some(feedback) = app.action_feedback.as_ref() else {
686 return;
687 };
688 if area.height == 0 || area.width == 0 {
689 return;
690 }
691 let height = area.height.min(3);
694 let rect = Rect {
695 y: area.y + area.height - height,
696 height,
697 ..area
698 };
699 let paragraph = Paragraph::new(vec![
700 Line::from(Span::styled(
701 feedback.reason_line(),
702 Style::default()
703 .fg(Color::Yellow)
704 .add_modifier(Modifier::BOLD),
705 )),
706 Line::from(Span::styled(
707 feedback.remedy_line(),
708 Style::default().fg(Color::White),
709 )),
710 ])
711 .style(Style::default().bg(Color::DarkGray))
712 .block(Block::default().borders(Borders::TOP));
713 f.render_widget(paragraph, rect);
714}
715
716pub(in crate::app) fn theme_for(config: &Config) -> crate::ui::color::Theme {
723 let tag_colors = crate::tags::TagStore::new(config.tags_dir())
724 .colors()
725 .unwrap_or_default();
726 let mut type_colors = std::collections::HashMap::new();
727 for type_name in config.types() {
728 if let Some(color) = config
729 .type_color(&type_name)
730 .and_then(|name| crate::ui::color::parse(&name))
731 {
732 type_colors.insert(type_name.to_ascii_lowercase(), color);
733 }
734 }
735 let mut milestone_colors = std::collections::HashMap::new();
736 let planner = Planner::from_config(config.clone(), std::path::PathBuf::from("."));
737 for milestone in planner.list_milestones().unwrap_or_default() {
738 if let Some(color) = milestone.color.as_deref().and_then(crate::ui::color::parse) {
739 milestone_colors.insert(milestone.name.to_ascii_lowercase(), color);
740 }
741 }
742 crate::ui::color::Theme::new(tag_colors, type_colors, milestone_colors)
743}
744
745pub(in crate::app) fn template_default_lines(
749 defaults: &crate::templates::registry::TemplateDefaults,
750) -> Vec<(String, String)> {
751 let scalars: [(&str, Option<&String>); 11] = [
752 ("title", defaults.title.as_ref()),
753 ("status", defaults.status.as_ref()),
754 ("priority", defaults.priority.as_ref()),
755 ("assignee", defaults.assignee.as_ref()),
756 ("estimate", defaults.estimate.as_ref()),
757 ("reviewer", defaults.reviewer.as_ref()),
758 ("start_date", defaults.start_date.as_ref()),
759 ("due_date", defaults.due_date.as_ref()),
760 ("parent", defaults.parent.as_ref()),
761 ("type", defaults.type_.as_ref()),
762 ("body", defaults.body.as_ref()),
765 ];
766 let lists: [(&str, &Vec<String>); 5] = [
767 ("labels", &defaults.labels),
768 ("files", &defaults.files),
769 ("cycles", &defaults.cycles),
770 ("milestones", &defaults.milestones),
771 ("related_issues", &defaults.related_issues),
772 ];
773 let mut out: Vec<(String, String)> = scalars
774 .into_iter()
775 .filter_map(|(key, value)| value.map(|value| (key.to_string(), value.clone())))
776 .collect();
777 out.extend(lists.into_iter().filter_map(|(key, values)| {
778 if values.is_empty() {
779 None
780 } else {
781 Some((key.to_string(), values.join(", ")))
782 }
783 }));
784 out
785}
786
787pub(in crate::app) fn template_subtitle(template: &crate::templates::registry::Template) -> String {
790 let defaults = template_default_lines(&template.defaults).len();
791 let mut out = template.scope.to_string();
792 if let Some(name) = template.name.as_deref()
793 && !name.is_empty()
794 {
795 out.push_str(&format!(" · {name}"));
796 }
797 out.push_str(&format!(
798 " · {defaults} default{} · {} prompt{}",
799 if defaults == 1 { "" } else { "s" },
800 template.prompts.len(),
801 if template.prompts.len() == 1 { "" } else { "s" }
802 ));
803 out
804}
805
806pub(in crate::app) fn plan_lines(plan: &toml::Table) -> Vec<(String, String)> {
811 plan.iter()
812 .map(|(key, value)| (format!("plan {key}"), toml_scalar(value)))
813 .collect()
814}
815
816pub(in crate::app) fn toml_scalar(value: &toml::Value) -> String {
819 match value {
820 toml::Value::String(text) => text.clone(),
821 other => other.to_string(),
822 }
823}