Skip to main content

tix/app/render/
mod.rs

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
39/// The height of the unread-mentions indicator row (OpenSpec change
40/// `issue-mentions`), reserved only when there is something to say.
41pub(in crate::app) const MENTION_HEIGHT: u16 = 1;
42
43/// The height the unread-mentions indicator row occupies in `app`'s frame: one row
44/// when the local user has unread mentions, none otherwise.
45///
46/// One helper because two places must agree: `render_board` reserves the row and
47/// `board_mouse_area` hit-tests around it, and a hit test that disagreed with the
48/// layout would select the wrong card — the same reason `filter_band_height` exists.
49pub(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}
56/// Two rows: one for the tab labels, one for the tab bar's bottom border
57/// (`BoardTabs` renders a `Block` with `Borders::BOTTOM`, which consumes the
58/// last row). With height 1 the labels would never render.
59pub(in crate::app) const TAB_HEIGHT: u16 = 2;
60/// Single-row active-filter indicator shown directly beneath the tab bar
61/// (board-filtering FR-01); zero rows when no filter is active (D-05).
62pub(in crate::app) const FILTER_HEIGHT: u16 = 1;
63/// Full-width row directly beneath the tab bar showing the currently hovered
64/// issue's title (hovered-issue title line FR).
65pub(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;
68/// Spare rows kept blank below a one-shot render so the board fits on screen
69/// instead of consuming the full terminal height.
70pub(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    // The vertical mode navigation takes the left column, and the active mode's
75    // content is everything to its right (tui-mode-navigation). `frame_size`
76    // tracks the *content* rect so board/tab hit-testing stays aligned with what
77    // was drawn. One-shot `tix render` never shows the navigation, so its
78    // contract is unchanged.
79    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    // Every arm below draws into the content rect, so the geometry it records for
100    // mouse hit-testing matches `frame_size`.
101    let area = content;
102    match &app.mode {
103        Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
104        Mode::BulkModal => {
105            // The board renders underneath, as it does under every other modal, so
106            // the user can see the cards they marked and the columns they are in.
107            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            // The inbox is the mode's content, not a card floating over the
114            // board (tui-mode-navigation): the vertical navigation highlights
115            // Triage and the mode occupies the whole content area.
116            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            // The cycle listing is the mode's content (tui-cycle-mode): the
124            // vertical navigation highlights Cycle and the mode owns the area,
125            // as the triage inbox does.
126            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            // The page renders underneath, as the board does under every other modal,
137            // so the row the form is about stays on screen while its fields are typed.
138            render_stats(app, f, area);
139            render_health_form(app, f, area);
140        }
141        Mode::TimeForm => {
142            // The Time tab renders underneath, so the entry rows a correction or a
143            // deletion is about stay on screen while the form's fields are typed.
144            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            // The board renders underneath, so the card being refused is still on
153            // screen while its reason is typed.
154            render_board(app, f, area, commandbar::MODE_BOARD);
155            crate::ui::wip_override::render(app, f, area);
156        }
157        Mode::MentionNotice => {
158            // The board renders underneath, as it does under every other modal.
159            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            // The board renders underneath, as it does under every other modal, so
168            // the cards the warning is about are still on screen.
169            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            // The form is the New Issue **mode's content**, not a modal floating
224            // over the board (tui-new-issue-mode): it fills the area beside the
225            // navigation, exactly as Triage and Cycle do.
226            if let Some(fr) = &app.form {
227                form::render_with_bindings(fr, f, area, &app.bindings, InputContext::Form);
228                // Record the Template row so a click opens the picker. The row rect
229                // comes from `form`, the same arithmetic the renderer and the caret
230                // use, rather than a second copy of "border plus ring index".
231                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                    // With nothing drawn over the form, the form owns the caret
238                    // (#1486). Under a picker the overlay owns it instead, and a
239                    // caret on the row behind it would point at the wrong surface.
240                    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            // Record the Yes / No button cells as click regions (D6): the run
278            // loop maps them to the same `y` / cancel branches as the keys.
279            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            // The warning is the shared text, so this prompt and the CLI's
342            // `tix move` refusal cannot describe the same situation differently.
343            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        // WP05: the reasoned-override prompt. `reason` is the **shared** service
391        // diagnostic, rendered verbatim, so this prompt and the CLI's refusal
392        // describe the same situation in the same words
393        // (`contracts/surface-parity.md` §2 — parity includes the message).
394        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            // The refusal is shown above the board rather than in the one-line
425            // prompt, because it is a full sentence with a backtick-quoted column
426            // and a command spelling, and truncating it would leave the user with a
427            // rule and no route.
428            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                // Record a hit region per criterion row; a click focuses the row and
458                // opens its picker (or the calendar), exactly as Enter/l/right would.
459                // The offset is the modal's own active-view feedback block (WP05,
460                // T024): without it a click would land on the row above the one
461                // the widget drew.
462                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            // The board renders underneath, as it does under every other modal,
479            // so the user can see the board the view list applies to.
480            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            // The Config mode is the mode's content, not a modal over the board
492            // (tui-config-mode): the section rail and the active section own the
493            // area, as the triage inbox and the cycle listing do. Its planner
494            // rows come from the same `planning_rows` the `m` surface uses.
495            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                    // The mode's content, so the picker overlays the form where it
536                    // was actually drawn rather than a smaller modal rect.
537                    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                // Record the calendar grid and chrome so pointer clicks route
556                // through the same key branches as keyboard input (D6).
557                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        // #1384: the label says the empty case out loud, because "blank clears" is
617        // the one thing a user cannot infer from an empty prompt.
618        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        // #1510. The prompt names both files — the temp copy and the newer
641        // on-disk one — so "compare" is something the user can do by hand even
642        // outside the editor the key offers.
643        Mode::EditConflict {
644            filename,
645            temp,
646            disk_path,
647        } => render_edit_conflict(app, f, area, filename, temp, disk_path),
648        // #1359. The prompt is labelled by which step it is asking, so "enter
649        // something" is never the whole instruction — and the kind step says it is
650        // **required**, which is the one thing a user needs to know before typing.
651        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
679/// Draw the current action refusal over the bottom of the active mode.
680///
681/// The board, Schedule and a few forms already own message rows; drawing this
682/// shared notice last guarantees newer top-level pages and overlays present the
683/// same reason/remedy pair rather than writing a message no renderer reads.
684fn 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    // A top border plus both text lines. Drawing the border over a two-row rect
692    // would clip the remedy entirely.
693    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
716/// Resolve the TUI's color theme (tix #1530): tag colors from the tag store
717/// (`tags_dir`), type colors from `[type_colors]`/the shipped defaults, and
718/// milestone colors from the milestone records.
719///
720/// Best-effort: a missing store or an unreadable registry contributes no colors
721/// rather than failing startup — losing a color must never stop the TUI opening.
722pub(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
745/// A template's defaults as `(field, value)` pairs, in the order the create form
746/// fills them, skipping the unset ones — a `None`/empty default is not a default,
747/// and listing it as one would make every template look alike.
748pub(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        // The body is Markdown and can be long; it is listed last so it never
763        // pushes the scalars off the pane.
764        ("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
787/// A template's one-line tail for a Library row (tix #1593): its scope and what
788/// it actually carries, so a row says more than its id.
789pub(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
806/// A view's stored plan as `key: value` detail lines, in the table's own order.
807///
808/// The plan is a `toml::Table` (the portable form), so this reads it rather than
809/// re-deriving it from the typed filter: what the page shows is what is stored.
810pub(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
816/// A `toml::Value` as display text: a string without its TOML quotes, anything
817/// else in its TOML spelling (a number, a bool, an inline array).
818pub(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}