Skip to main content

tix/ui/
settings.rs

1//! The Config mode surface (FR: board/column configuration, tui-config-mode).
2//!
3//! One sectioned view over the configuration areas — **dashboards**,
4//! **cycles**, **milestones**, **types**, and **attributes** — reached with F12.
5//! The surface is a three-level drill-down (tui-config-mode): the **section
6//! rail** on the left, the active section's **index** beside it, and a selected
7//! record's **detail** over the whole content area. `h`/`l` (and `←`/`→`) move
8//! one level; `j`/`k` (and `↓`/`↑`) move inside the current level.
9//!
10//! The dashboards index lists every configured board; its detail is the former
11//! settings modal's row list (column visibility checkboxes, the display toggles,
12//! and the `$EDITOR` override). The cycles and milestones sections render the
13//! same [`PlanningRow`]s the planning surface does, through the same
14//! `rows_lines` helper, so the two surfaces cannot disagree.
15//!
16//! Like `filter_modal`, this widget is a **pure view + model**: it does not
17//! read keys or mutate the config — the app routes keys and applies mutations
18//! (`App::handle_settings_key`). It does record its click targets through
19//! [`App::record_region`] so pointer hits run the same actions the keys do
20//! (D3/D6).
21
22use ratatui::Frame;
23use ratatui::layout::{Constraint, Direction, Layout, Rect};
24use ratatui::style::{Color, Modifier, Style};
25use ratatui::text::Line;
26use ratatui::widgets::{Block, Borders, Paragraph};
27
28use crate::app::{Action, App};
29use crate::keybindings::{EffectiveBindings, InputContext};
30use crate::ui::planning::{PlanningRow, prompt_line, rows_lines};
31
32/// Number of non-column rows in a dashboard's detail: the display settings
33/// (title wrap toggle, line limit, ordering). The app uses this to size the
34/// detail cursor and to route toggles; the widget uses it to render the rows.
35pub const DISPLAY_ROWS: usize = 3;
36
37/// Non-column rows beyond the display toggles: the `$EDITOR` override and the
38/// read-only viewer, both `tix.toml` settings rather than per-record metadata
39/// files.
40pub const GENERAL_ROWS: usize = 2;
41
42/// Every row in a dashboard's detail: the board's columns, the display
43/// toggles, and the general settings.
44pub fn board_rows_len(board: &crate::config::BoardConfiguration) -> usize {
45    board.columns.len() + DISPLAY_ROWS + GENERAL_ROWS
46}
47
48/// The width of the section rail, in columns. Wide enough for the longest
49/// label ("Attributes") plus the cursor and border.
50pub const SECTION_RAIL_WIDTH: u16 = 21;
51
52/// The body area to the right of the section rail, where a section's rows are
53/// drawn. Shared by the renderer and the pointer-hit tests so the two never
54/// compute different geometry.
55pub fn config_body_rect(area: Rect) -> Rect {
56    let rail = SECTION_RAIL_WIDTH.min(area.width);
57    Rect {
58        x: area.x + rail,
59        width: area.width - rail,
60        ..area
61    }
62}
63
64/// One configuration section of the Config mode.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum ConfigSection {
67    /// Boards: columns, display toggles, and the editor override.
68    #[default]
69    Dashboards,
70    /// Cycles, from `docs/cycles/` (or legacy `tix.toml`).
71    Cycles,
72    /// Milestones, from `docs/milestones/`.
73    Milestones,
74    /// The allowed issue `type:` values (`custom_types` in `tix.toml`).
75    Types,
76    /// The declared custom frontmatter keys (`[custom_attributes].keys`).
77    CustomAttributes,
78}
79
80impl ConfigSection {
81    /// The sections, in rail order.
82    pub const ALL: [Self; 5] = [
83        Self::Dashboards,
84        Self::Cycles,
85        Self::Milestones,
86        Self::Types,
87        Self::CustomAttributes,
88    ];
89
90    /// The rail label.
91    pub const fn label(self) -> &'static str {
92        match self {
93            Self::Dashboards => "Dashboards",
94            Self::Cycles => "Cycles",
95            Self::Milestones => "Milestones",
96            Self::Types => "Types",
97            Self::CustomAttributes => "Attributes",
98        }
99    }
100
101    /// The next section, wrapping.
102    pub fn next(self) -> Self {
103        let index = self.index();
104        Self::ALL[(index + 1) % Self::ALL.len()]
105    }
106
107    /// The previous section, wrapping.
108    pub fn prev(self) -> Self {
109        let index = self.index();
110        Self::ALL[(index + Self::ALL.len() - 1) % Self::ALL.len()]
111    }
112
113    /// The section's position in the rail.
114    pub const fn index(self) -> usize {
115        match self {
116            Self::Dashboards => 0,
117            Self::Cycles => 1,
118            Self::Milestones => 2,
119            Self::Types => 3,
120            Self::CustomAttributes => 4,
121        }
122    }
123
124    /// Whether the section edits one of the two string lists (`Types` /
125    /// `Attributes`) rather than a planning or board surface.
126    pub const fn is_list(self) -> bool {
127        matches!(self, Self::Types | Self::CustomAttributes)
128    }
129}
130
131/// Which level of the Config mode's drill-down holds the focus.
132///
133/// The surface is a hierarchy: the **section rail** (`Sections`), the active
134/// section's **index** of records (`Index`), and the selected record's
135/// **detail** (`Detail`, drawn over the whole content area with the rail
136/// hidden). `h`/`l` (and `←`/`→`) step one level; `j`/`k` (and `↓`/`↑`) move
137/// inside the current level.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
139pub enum ConfigLevel {
140    /// The left rail: the sections (Dashboards / Cycles / Milestones / …).
141    #[default]
142    Sections,
143    /// The active section's index of records.
144    Index,
145    /// The selected record's detail, over the whole content area.
146    Detail,
147}
148
149impl ConfigLevel {
150    /// The level's position on the vertical axis, top to bottom.
151    pub const fn index(self) -> usize {
152        match self {
153            Self::Sections => 0,
154            Self::Index => 1,
155            Self::Detail => 2,
156        }
157    }
158
159    /// The level one step deeper, clamped at [`ConfigLevel::Detail`].
160    pub const fn deeper(self) -> Self {
161        match self {
162            Self::Sections => Self::Index,
163            Self::Index | Self::Detail => Self::Detail,
164        }
165    }
166
167    /// The level one step shallower, clamped at [`ConfigLevel::Sections`].
168    pub const fn shallower(self) -> Self {
169        match self {
170            Self::Sections | Self::Index => Self::Sections,
171            Self::Detail => Self::Index,
172        }
173    }
174}
175
176/// The dashboards section's data: the active board, its name, and the
177/// `$EDITOR` override. Bundled so the render entry points stay within a sane
178/// argument count.
179#[derive(Debug, Clone, Copy)]
180pub struct DashboardView<'a> {
181    /// The active board's name.
182    pub name: &'a str,
183    /// The active board's configuration.
184    pub board: &'a crate::config::BoardConfiguration,
185    /// The configured editor override, if any.
186    pub editor: Option<&'a str>,
187    /// The Viewer row's label: the configured override, or the resolved auto
188    /// viewer (tix #1507).
189    pub viewer: &'a str,
190}
191
192/// An active inline prompt inside the Config mode.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub enum SettingsPrompt {
195    /// Naming a new column (`a`).
196    AddColumn(String),
197    /// Naming a new board (`n`).
198    NewBoard(String),
199    /// Renaming the current board (`e`), seeded with its current name.
200    RenameBoard {
201        /// The board's current name.
202        old: String,
203        /// The in-progress new name.
204        buffer: String,
205    },
206    /// Confirming deletion of the current board (`x`), naming how many issues
207    /// reference it (always 0 today: nothing in issue frontmatter names a
208    /// board, but the count is reported rather than assumed).
209    ConfirmDeleteBoard {
210        /// The board's name.
211        name: String,
212        /// Issues that reference the board (0 — see the variant docs).
213        referencing: usize,
214    },
215    /// Setting the `$EDITOR` override in `tix.toml`.
216    SetEditor(String),
217    /// Setting the read-only `viewer` of the TUI's `v` key in `tix.toml`
218    /// (tix #1507).
219    SetViewer(String),
220    /// Naming a new `custom_types` entry (`n` in the Types section).
221    AddType(String),
222    /// Naming a new `[custom_attributes].keys` entry (`n` in the Custom
223    /// attributes section).
224    AddAttribute(String),
225}
226
227impl SettingsPrompt {
228    /// The single-line prompt text shown under the active section's rows.
229    pub fn line(&self) -> String {
230        match self {
231            Self::AddColumn(buffer) => format!("New column: {buffer}_"),
232            Self::NewBoard(buffer) => format!("New board: {buffer}_"),
233            Self::RenameBoard { buffer, .. } => format!("Rename board to: {buffer}_"),
234            Self::ConfirmDeleteBoard { name, referencing } => format!(
235                "Delete board '{name}'? {referencing} {} reference it. [y/N]",
236                crate::ui::planning::issues_word(*referencing)
237            ),
238            Self::SetEditor(buffer) => format!("Editor: {buffer}_"),
239            Self::SetViewer(buffer) => format!("Viewer: {buffer}_"),
240            Self::AddType(buffer) => format!("New type: {buffer}_"),
241            Self::AddAttribute(buffer) => format!("New attribute: {buffer}_"),
242        }
243    }
244}
245
246/// The Config mode's cursor state: the active section, the drill-down level,
247/// the index cursor (one row per record in the section), the dashboard detail
248/// cursor, and an optional inline prompt.
249#[derive(Debug, Clone, Default)]
250pub struct SettingsModal {
251    /// Row cursor within the active section's **index** (one row per board,
252    /// cycle, milestone, type, or attribute).
253    pub selected: usize,
254    /// Row cursor within a dashboard's **detail** (a board's columns, then the
255    /// display toggles, then the general rows).
256    pub detail_selected: usize,
257    /// Active inline prompt (None = browsing).
258    pub prompt: Option<SettingsPrompt>,
259    /// The active section.
260    pub section: ConfigSection,
261    /// Which level of the drill-down holds the focus.
262    pub level: ConfigLevel,
263}
264
265impl SettingsModal {
266    pub fn new() -> Self {
267        Self::default()
268    }
269
270    /// Focus `level` (an `h`/`l` step, or a click on that level).
271    pub fn focus(&mut self, level: ConfigLevel) {
272        self.level = level;
273    }
274
275    /// Move the level focus `delta` levels along the vertical axis, clamped at
276    /// the ends (`Sections` at the top, `Detail` at the bottom) rather than
277    /// wrapping.
278    pub fn move_level(&mut self, delta: isize) {
279        let next = self.level.index() as isize + delta;
280        self.level = match next {
281            n if n <= 0 => ConfigLevel::Sections,
282            1 => ConfigLevel::Index,
283            _ => ConfigLevel::Detail,
284        };
285    }
286
287    /// Switch to `section`, putting the index and detail cursors back at the
288    /// top when the section changes (the cursors are per-section state).
289    pub fn set_section(&mut self, section: ConfigSection) {
290        if self.section != section {
291            self.section = section;
292            self.selected = 0;
293            self.detail_selected = 0;
294        }
295    }
296
297    /// Move the **index** cursor (clamped to `total_rows`).
298    pub fn move_selected(&mut self, delta: isize, total_rows: usize) {
299        if total_rows == 0 {
300            self.selected = 0;
301            return;
302        }
303        let next = self.selected as isize + delta;
304        self.selected = next.clamp(0, total_rows as isize - 1) as usize;
305    }
306
307    /// Move the **detail** cursor (clamped to `total_rows`).
308    pub fn move_detail(&mut self, delta: isize, total_rows: usize) {
309        if total_rows == 0 {
310            self.detail_selected = 0;
311            return;
312        }
313        let next = self.detail_selected as isize + delta;
314        self.detail_selected = next.clamp(0, total_rows as isize - 1) as usize;
315    }
316
317    /// Jump the index cursor straight to `row` (a row click, D6), clamped to
318    /// the same range the arrow keys navigate.
319    pub fn move_selected_to(&mut self, row: usize, total_rows: usize) {
320        if total_rows == 0 {
321            self.selected = 0;
322            return;
323        }
324        self.selected = row.min(total_rows - 1);
325    }
326
327    /// Jump the detail cursor straight to `row` (a row click, D6), clamped to
328    /// the same range the arrow keys navigate.
329    pub fn move_detail_to(&mut self, row: usize, total_rows: usize) {
330        if total_rows == 0 {
331            self.detail_selected = 0;
332            return;
333        }
334        self.detail_selected = row.min(total_rows - 1);
335    }
336
337    /// Render the board rows of the dashboards **detail**: the column
338    /// checkboxes, the display toggles, the general rows, and the active
339    /// prompt. Shared by the Config mode's dashboards detail and the widget's
340    /// own tests.
341    ///
342    /// `focused` is whether the detail owns the Config mode's focus; the
343    /// focused detail's border is accented and an unfocused one dimmed.
344    pub fn render_body(
345        &self,
346        f: &mut Frame,
347        rect: Rect,
348        view: DashboardView<'_>,
349        bindings: &EffectiveBindings,
350        focused: bool,
351    ) {
352        let DashboardView {
353            name: board_name,
354            board,
355            editor,
356            viewer,
357        } = view;
358        let context = InputContext::Settings;
359        let columns = &board.columns;
360        let hidden = &board.hidden_columns;
361        let wrap_titles = board.wrap_titles;
362        let title_lines = board.title_lines;
363        let order_by = board.order_by;
364        let mut rows: Vec<Line> = Vec::new();
365
366        for (i, col) in columns.iter().enumerate() {
367            let is_hidden = hidden.iter().any(|h| h == col);
368            let mark = if is_hidden { "[ ]" } else { "[x]" };
369            let cursor = if i == self.detail_selected {
370                "> "
371            } else {
372                "  "
373            };
374            let style = row_style(i == self.detail_selected);
375            let hidden_suffix = if is_hidden { " (hidden)" } else { "" };
376            rows.push(Line::from(vec![
377                ratatui::text::Span::styled(cursor, style),
378                ratatui::text::Span::styled(format!("{mark} {col}{hidden_suffix}"), style),
379            ]));
380        }
381
382        let display_texts = [
383            format!("Wrap titles: {}", if wrap_titles { "[x]" } else { "[ ]" }),
384            format!("Title lines: {title_lines}"),
385            format!("Order by: {}", order_by.as_str()),
386            format!("Editor: {}", editor.unwrap_or("(default)")),
387            format!("Viewer: {viewer}"),
388        ];
389        for (row, text) in display_texts.into_iter().enumerate() {
390            let idx = columns.len() + row;
391            let style = row_style(idx == self.detail_selected);
392            let cursor = if idx == self.detail_selected {
393                "> "
394            } else {
395                "  "
396            };
397            rows.push(Line::from(vec![
398                ratatui::text::Span::styled(cursor, style),
399                ratatui::text::Span::styled(text, style),
400            ]));
401        }
402
403        if let Some(prompt) = &self.prompt {
404            rows.push(Line::from(""));
405            let line = prompt.line();
406            rows.push(Line::from(ratatui::text::Span::styled(
407                line,
408                Style::default().fg(Color::Cyan),
409            )));
410        } else {
411            rows.push(Line::from(""));
412            rows.push(Line::from(ratatui::text::Span::styled(
413                format!(
414                    "{}: activate · {}: toggle · {}: new column · {} / {}: move column · {}: back · {}: close",
415                    bindings.label(context, crate::keybindings::TuiAction::Submit),
416                    bindings.label(context, crate::keybindings::TuiAction::Toggle),
417                    bindings.label(context, crate::keybindings::TuiAction::AddColumn),
418                    bindings.label(context, crate::keybindings::TuiAction::MoveColumnUp),
419                    bindings.label(context, crate::keybindings::TuiAction::MoveColumnDown),
420                    bindings.label(context, crate::keybindings::TuiAction::MoveLeft),
421                    bindings.label(context, crate::keybindings::TuiAction::Close),
422                ),
423                Style::default().fg(Color::DarkGray),
424            )));
425        }
426
427        let block = Block::default()
428            .borders(Borders::ALL)
429            .title(format!(" {board_name} — columns & display "))
430            .style(pane_style(focused));
431        let para = Paragraph::new(rows).block(block).style(Style::default());
432        f.render_widget(para, rect);
433    }
434}
435
436/// The rows of the dashboards index: every configured board's name and its
437/// ordered column names, in configuration order.
438pub fn dashboard_index_rows(config: &crate::config::Config) -> Vec<(String, String)> {
439    config
440        .configs()
441        .map(|(name, board)| (name.to_string(), board.columns.join(" · ")))
442        .collect()
443}
444
445fn row_style(selected: bool) -> Style {
446    if selected {
447        Style::default()
448            .fg(Color::Yellow)
449            .add_modifier(Modifier::BOLD)
450    } else {
451        Style::default().fg(Color::White)
452    }
453}
454
455/// A pane's border/title style: the focused pane is accented, the unfocused
456/// one dimmed. The pane that owns the keys is visible at a glance, and the
457/// focused pane's selected entry keeps the [`row_style`] cursor.
458fn pane_style(focused: bool) -> Style {
459    if focused {
460        Style::default().fg(Color::Yellow)
461    } else {
462        Style::default().fg(Color::DarkGray)
463    }
464}
465
466/// Render the Config mode: the section rail plus the active section's index,
467/// or — at the dashboards detail level — the board's detail over the whole
468/// content area. Records a click target per rail entry, index row, and detail
469/// row so pointer hits run the same actions the keys do (D3/D6).
470pub fn render_config(
471    app: &App,
472    f: &mut Frame,
473    area: Rect,
474    view: DashboardView<'_>,
475    cycles: &[PlanningRow],
476    milestones: &[PlanningRow],
477) {
478    let Some(modal) = app.settings.as_ref() else {
479        return;
480    };
481    // The Milestones mode borrows this surface pinned to the milestones
482    // section: registry on top, flat issue table below, the same table the
483    // Cycle mode draws. It has no rail, so the registry owns the upper area.
484    if app.milestone_mode {
485        let chunks = Layout::default()
486            .direction(Direction::Vertical)
487            .constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
488            .split(area);
489        render_planning_section(app, f, chunks[0], "Milestones", milestones, true);
490        render_milestone_issues(app, f, chunks[1]);
491        return;
492    }
493    // A selected dashboard's detail owns the whole content area — the rail is
494    // hidden so the editing surface gets the full width ("complete widget
495    // screen space").
496    if modal.level == ConfigLevel::Detail && modal.section == ConfigSection::Dashboards {
497        modal.render_body(f, area, view, &app.bindings, true);
498        for row in 0..board_rows_len(view.board) {
499            app.record_region(body_row_rect(area, row), Action::ConfigDetailRow(row));
500        }
501        return;
502    }
503    render_section_rail(
504        app,
505        f,
506        area,
507        modal.section,
508        modal.level == ConfigLevel::Sections,
509    );
510    let body = config_body_rect(area);
511    let body_focused = modal.level == ConfigLevel::Index;
512    match modal.section {
513        ConfigSection::Dashboards => {
514            let rows = dashboard_index_rows(&app.store.config);
515            render_dashboard_index(app, f, body, &rows, body_focused);
516        }
517        ConfigSection::Cycles => {
518            render_planning_section(app, f, body, "Cycles", cycles, body_focused);
519        }
520        ConfigSection::Milestones => {
521            render_planning_section(app, f, body, "Milestones", milestones, body_focused);
522        }
523        ConfigSection::Types => {
524            let rows = app.store.config.custom_types();
525            render_list_section(app, f, body, "Types", &rows, body_focused);
526        }
527        ConfigSection::CustomAttributes => {
528            // The effective attribute list: declared keys, or the built-in
529            // attributes when none are declared (mirrors the Types section).
530            let rows = app.store.config.attributes();
531            render_list_section(app, f, body, "Attributes", &rows, body_focused);
532        }
533    }
534}
535
536/// Render the dashboards **index**: one row per configured board, showing its
537/// name and its ordered columns. Records a click target per row
538/// ([`Action::SettingsRow`]).
539fn render_dashboard_index(
540    app: &App,
541    f: &mut Frame,
542    body: Rect,
543    rows: &[(String, String)],
544    focused: bool,
545) {
546    let selected = app.settings.as_ref().map(|m| m.selected).unwrap_or(0);
547    let prompt = app.settings.as_ref().and_then(|m| m.prompt.as_ref());
548    let mut lines: Vec<Line> = Vec::new();
549    if rows.is_empty() && prompt.is_none() {
550        lines.push(Line::from("No dashboards configured — press n to add one."));
551    } else {
552        for (index, (name, columns)) in rows.iter().enumerate() {
553            let style = row_style(index == selected);
554            let cursor = if index == selected { "> " } else { "  " };
555            lines.push(Line::from(vec![
556                ratatui::text::Span::styled(cursor, style),
557                ratatui::text::Span::styled(format!("{name:<18}"), style),
558                ratatui::text::Span::styled(columns.clone(), Style::default().fg(Color::DarkGray)),
559            ]));
560        }
561    }
562    lines.push(Line::from(""));
563    match prompt {
564        Some(prompt) => lines.push(Line::from(ratatui::text::Span::styled(
565            prompt.line(),
566            Style::default().fg(Color::Cyan),
567        ))),
568        None => lines.push(Line::from(ratatui::text::Span::styled(
569            format!(
570                "{}: open detail · {}: new board · {}: rename board · {}: delete board · {}: switch board · {}: close",
571                app.bindings.label(
572                    InputContext::Settings,
573                    crate::keybindings::TuiAction::MoveRight
574                ),
575                app.bindings.label(
576                    InputContext::Settings,
577                    crate::keybindings::TuiAction::NewBoard
578                ),
579                app.bindings.label(
580                    InputContext::Settings,
581                    crate::keybindings::TuiAction::EditPlanningItem
582                ),
583                app.bindings.label(
584                    InputContext::Settings,
585                    crate::keybindings::TuiAction::Delete
586                ),
587                app.bindings.label(
588                    InputContext::Settings,
589                    crate::keybindings::TuiAction::NextBoard
590                ),
591                app.bindings
592                    .label(InputContext::Settings, crate::keybindings::TuiAction::Close),
593            ),
594            Style::default().fg(Color::DarkGray),
595        ))),
596    }
597    let block = Block::default()
598        .borders(Borders::ALL)
599        .title(format!(" Dashboards — {} boards ", rows.len()))
600        .style(pane_style(focused));
601    f.render_widget(Paragraph::new(lines).block(block), body);
602    for index in 0..rows.len() {
603        app.record_region(body_row_rect(body, index), Action::SettingsRow(index));
604    }
605}
606
607/// Render one of the Config mode's string-list sections (`Types` / `Custom
608/// attributes`): one row per entry with the same cursor affordance the
609/// dashboards rows use, an inline prompt when one is active, and a key hint
610/// line. Records a click target per row.
611fn render_list_section(
612    app: &App,
613    f: &mut Frame,
614    body: Rect,
615    title: &str,
616    rows: &[String],
617    focused: bool,
618) {
619    let (selected, prompt) = app
620        .settings
621        .as_ref()
622        .map(|modal| (modal.selected, modal.prompt.as_ref()))
623        .unwrap_or((0, None));
624    let mut lines: Vec<Line> = Vec::new();
625    if rows.is_empty() && prompt.is_none() {
626        lines.push(Line::from(format!(
627            "No {title} configured — press n to add one."
628        )));
629    } else {
630        for (index, row) in rows.iter().enumerate() {
631            let cursor = if index == selected { "> " } else { "  " };
632            let style = row_style(index == selected);
633            lines.push(Line::from(vec![
634                ratatui::text::Span::styled(cursor, style),
635                ratatui::text::Span::styled(row.clone(), style),
636            ]));
637        }
638    }
639    lines.push(Line::from(""));
640    match prompt {
641        Some(prompt) => lines.push(Line::from(ratatui::text::Span::styled(
642            prompt.line(),
643            Style::default().fg(Color::Cyan),
644        ))),
645        None => lines.push(Line::from(ratatui::text::Span::styled(
646            format!(
647                "{}: new · {}: remove selected · {} / {}: level · {}: close",
648                app.bindings.label(
649                    InputContext::Settings,
650                    crate::keybindings::TuiAction::NewBoard
651                ),
652                app.bindings.label(
653                    InputContext::Settings,
654                    crate::keybindings::TuiAction::Delete
655                ),
656                app.bindings.label(
657                    InputContext::Settings,
658                    crate::keybindings::TuiAction::MoveLeft
659                ),
660                app.bindings.label(
661                    InputContext::Settings,
662                    crate::keybindings::TuiAction::MoveRight
663                ),
664                app.bindings
665                    .label(InputContext::Settings, crate::keybindings::TuiAction::Close),
666            ),
667            Style::default().fg(Color::DarkGray),
668        ))),
669    }
670    let block = Block::default()
671        .borders(Borders::ALL)
672        .title(format!(" {title} — {} items ", rows.len()))
673        .style(pane_style(focused));
674    f.render_widget(Paragraph::new(lines).block(block), body);
675    for index in 0..rows.len() {
676        app.record_region(body_row_rect(body, index), Action::ConfigListRow(index));
677    }
678}
679
680/// The click target of row `row`: the body's first inner line is row 0, exactly
681/// where the row lists are drawn.
682fn body_row_rect(body: Rect, row: usize) -> Rect {
683    Rect {
684        x: body.x + 1,
685        y: body.y + 1 + row as u16,
686        width: body.width.saturating_sub(2),
687        height: 1,
688    }
689}
690
691/// The section rail: the sections as a vertical list, the active one
692/// highlighted, each entry a click target. `focused` accents the rail's border
693/// when it holds the Config mode's focus.
694fn render_section_rail(app: &App, f: &mut Frame, area: Rect, active: ConfigSection, focused: bool) {
695    let rail = Rect {
696        width: SECTION_RAIL_WIDTH.min(area.width),
697        ..area
698    };
699    let mut lines: Vec<Line> = Vec::new();
700    for section in ConfigSection::ALL {
701        let style = if section == active {
702            row_style(true)
703        } else if app.is_hovered(&Action::ConfigSection(section)) {
704            // Hover highlight only — the active section is untouched.
705            Style::default()
706                .fg(Color::Black)
707                .bg(Color::DarkGray)
708                .add_modifier(Modifier::BOLD)
709        } else {
710            row_style(false)
711        };
712        let cursor = if section == active { "> " } else { "  " };
713        lines.push(Line::from(vec![
714            ratatui::text::Span::styled(cursor, style),
715            ratatui::text::Span::styled(section.label(), style),
716        ]));
717    }
718    let block = Block::default()
719        .borders(Borders::ALL)
720        .title(" Sections ")
721        .style(pane_style(focused));
722    f.render_widget(Paragraph::new(lines).block(block), rail);
723    for section in ConfigSection::ALL {
724        let hit = Rect {
725            x: rail.x + 1,
726            y: rail.y + 1 + section.index() as u16,
727            width: rail.width.saturating_sub(2),
728            height: 1,
729        };
730        app.record_region(hit, Action::ConfigSection(section));
731    }
732}
733
734/// Render one of the Config mode's planning sections (cycles or milestones):
735/// the same rows and prompt the planning surface draws, plus this mode's key
736/// hints. Records a click target per row. `focused` accents the border when the
737/// body pane holds the Config mode's focus.
738fn render_planning_section(
739    app: &App,
740    f: &mut Frame,
741    body: Rect,
742    label: &str,
743    rows: &[PlanningRow],
744    focused: bool,
745) {
746    let selected = app.planning.as_ref().map(|p| p.selected).unwrap_or(0);
747    let prompt = app.planning.as_ref().and_then(|p| p.prompt.as_ref());
748    let inner = body.height.saturating_sub(2) as usize;
749    let view_h = inner.saturating_sub(2);
750
751    let mut lines: Vec<Line> = Vec::new();
752    if rows.is_empty() {
753        if prompt.is_none() {
754            lines.push(Line::from(format!(
755                "No {} yet — press n to create one.",
756                label.to_lowercase()
757            )));
758        }
759    } else {
760        lines.extend(rows_lines(rows, selected, view_h));
761    }
762    lines.push(Line::from(""));
763    match prompt {
764        Some(prompt) => lines.push(prompt_line(prompt)),
765        None => lines.push(Line::from(ratatui::text::Span::styled(
766            format!(
767                "{}: new · {}: open/edit · {}: delete · {}: open detail · {} / {}: level · {}: close",
768                app.bindings.label(
769                    InputContext::Settings,
770                    crate::keybindings::TuiAction::NewBoard
771                ),
772                app.bindings.label(
773                    InputContext::Settings,
774                    crate::keybindings::TuiAction::EditPlanningItem
775                ),
776                // `delete` is the bound action in this context (x / Shift+X).
777                // `delete_planning_item` is the planning surface's own name for the
778                // same key and is not bound here, so its label would be "—".
779                app.bindings.label(
780                    InputContext::Settings,
781                    crate::keybindings::TuiAction::Delete
782                ),
783                app.bindings.label(
784                    InputContext::Settings,
785                    crate::keybindings::TuiAction::Submit
786                ),
787                app.bindings.label(
788                    InputContext::Settings,
789                    crate::keybindings::TuiAction::MoveLeft
790                ),
791                app.bindings.label(
792                    InputContext::Settings,
793                    crate::keybindings::TuiAction::MoveRight
794                ),
795                app.bindings
796                    .label(InputContext::Settings, crate::keybindings::TuiAction::Close),
797            ),
798            Style::default().fg(Color::DarkGray),
799        ))),
800    }
801
802    let block = Block::default()
803        .borders(Borders::ALL)
804        .title(format!(" {label} — {} items ", rows.len()))
805        .style(pane_style(focused));
806    f.render_widget(Paragraph::new(lines).block(block), body);
807    // Rows are drawn from the body's first inner line, so a click on row `i`
808    // resolves to `ConfigPlanningRow(i)` — the same index the cursor uses.
809    for i in 0..rows.len() {
810        app.record_region(body_row_rect(body, i), Action::ConfigPlanningRow(i));
811    }
812}
813
814/// The focused milestone's flat issue table, drawn by the F5 Milestones mode
815/// beneath the registry — the same shared table the Cycle mode draws.
816///
817/// The name comes from [`App::active_milestone_name`], so the table cannot
818/// list one milestone while the rail bolds another.
819fn render_milestone_issues(app: &App, f: &mut Frame, area: Rect) {
820    let (title, rows) = match app.active_milestone_name() {
821        Some(name) => (
822            format!("issues — {name}"),
823            crate::ui::issue_table::milestone_rows(&app.store, &name),
824        ),
825        None => ("issues".to_string(), Vec::new()),
826    };
827    crate::ui::issue_table::render(app, f, area, &title, &rows);
828}
829
830#[cfg(test)]
831mod tests {
832    #[allow(clippy::wildcard_imports)]
833    use super::*;
834    use ratatui::Terminal;
835    use ratatui::backend::TestBackend;
836
837    /// Draw the dashboard detail over a full 80x24 area and return the
838    /// terminal, so a test can inspect the buffer.
839    fn draw(
840        modal: &SettingsModal,
841        board: &crate::config::BoardConfiguration,
842        w: u16,
843        h: u16,
844    ) -> Terminal<TestBackend> {
845        draw_focused(modal, board, w, h, false)
846    }
847
848    /// [`draw`], with the detail's focus flag under test's control.
849    fn draw_focused(
850        modal: &SettingsModal,
851        board: &crate::config::BoardConfiguration,
852        w: u16,
853        h: u16,
854        focused: bool,
855    ) -> Terminal<TestBackend> {
856        let backend = TestBackend::new(w, h);
857        let mut terminal = Terminal::new(backend).unwrap();
858        let bindings =
859            crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
860                .expect("built-in bindings are valid");
861        terminal
862            .draw(|f| {
863                modal.render_body(
864                    f,
865                    f.area(),
866                    DashboardView {
867                        name: "default",
868                        board,
869                        editor: None,
870                        viewer: "mdterm (auto)",
871                    },
872                    &bindings,
873                    focused,
874                );
875            })
876            .unwrap();
877        terminal
878    }
879
880    fn rows_of(terminal: &Terminal<TestBackend>) -> Vec<String> {
881        let buffer = terminal.backend().buffer();
882        (0..buffer.area().height)
883            .map(|y| {
884                (0..buffer.area().width)
885                    .map(|x| buffer[(x, y)].symbol().chars().next().unwrap_or(' '))
886                    .collect()
887            })
888            .collect()
889    }
890
891    fn test_board() -> crate::config::BoardConfiguration {
892        crate::config::BoardConfiguration {
893            columns: vec!["todo".to_string(), "done".to_string()],
894            hidden_columns: vec!["done".to_string()],
895            ..Default::default()
896        }
897    }
898
899    fn render_smoke(prompt: Option<SettingsPrompt>, w: u16, h: u16) {
900        let modal = SettingsModal {
901            detail_selected: 1,
902            prompt,
903            ..SettingsModal::default()
904        };
905        let _ = draw(&modal, &test_board(), w, h);
906    }
907
908    #[test]
909    fn renders_display_rows_after_columns() {
910        let modal = SettingsModal {
911            detail_selected: 2,
912            prompt: None,
913            ..SettingsModal::default()
914        };
915        let board = crate::config::BoardConfiguration {
916            columns: vec!["todo".to_string(), "done".to_string()],
917            hidden_columns: vec![],
918            wrap_titles: false,
919            title_lines: 5,
920            order_by: crate::config::OrderBy::None,
921            ..Default::default()
922        };
923        let terminal = draw(&modal, &board, 80, 24);
924        let buffer = terminal.backend().buffer();
925        let rows = rows_of(&terminal);
926
927        let row_of =
928            |needle: &str| -> Option<usize> { rows.iter().position(|r| r.contains(needle)) };
929
930        let wrap_row = row_of("Wrap titles: [ ]").expect("wrap row shows unchecked mark");
931        let lines_row = row_of("Title lines: 5").expect("line-limit row shows the value");
932        let order_row = row_of("Order by: none").expect("ordering row shows the value");
933        let editor_row = row_of("Editor: (default)").expect("general row shows the editor");
934        let viewer_row = row_of("Viewer: mdterm (auto)").expect("general row shows the viewer");
935        assert!(
936            wrap_row < lines_row
937                && lines_row < order_row
938                && order_row < editor_row
939                && editor_row < viewer_row,
940            "display and general rows render beneath the columns in order"
941        );
942
943        // The selected display row (detail_selected=2 → first display row) is highlighted.
944        assert!(
945            rows[wrap_row].contains('>'),
946            "selected display row shows the cursor: {:?}",
947            rows[wrap_row]
948        );
949        assert!(
950            !rows[lines_row].contains('>'),
951            "unselected display row has no cursor: {:?}",
952            rows[lines_row]
953        );
954        let selection_x = rows[wrap_row].find('>').expect("cursor sits in the row") as u16;
955        let selected = &buffer[(selection_x + 2, wrap_row as u16)];
956        assert_eq!(selected.fg, Color::Yellow, "selected row gets cursor style");
957    }
958
959    #[test]
960    fn renders_at_80x24() {
961        render_smoke(None, 80, 24);
962    }
963
964    #[test]
965    fn renders_prompt_at_80x24() {
966        render_smoke(Some(SettingsPrompt::AddColumn("".into())), 80, 24);
967    }
968
969    #[test]
970    fn renders_rename_and_delete_prompts_at_80x24() {
971        render_smoke(
972            Some(SettingsPrompt::RenameBoard {
973                old: "default".into(),
974                buffer: "ops".into(),
975            }),
976            80,
977            24,
978        );
979        render_smoke(
980            Some(SettingsPrompt::ConfirmDeleteBoard {
981                name: "default".into(),
982                referencing: 0,
983            }),
984            80,
985            24,
986        );
987        render_smoke(Some(SettingsPrompt::SetEditor("nvim".into())), 80, 24);
988        render_smoke(Some(SettingsPrompt::SetViewer("less".into())), 80, 24);
989    }
990
991    #[test]
992    fn renders_at_tiny_20x5() {
993        render_smoke(None, 20, 5);
994    }
995
996    #[test]
997    fn move_selected_clamps_within_columns() {
998        let mut modal = SettingsModal::new();
999        modal.move_selected(-1, 3);
1000        assert_eq!(modal.selected, 0);
1001        modal.move_selected(5, [0usize, 1, 2, 3].len());
1002        assert_eq!(modal.selected, 3);
1003        modal.move_selected(1, 0);
1004        assert_eq!(modal.selected, 0);
1005    }
1006
1007    #[test]
1008    fn move_detail_clamps_within_rows() {
1009        let mut modal = SettingsModal::new();
1010        modal.move_detail(-1, 4);
1011        assert_eq!(modal.detail_selected, 0);
1012        modal.move_detail(9, 4);
1013        assert_eq!(modal.detail_selected, 3);
1014        modal.move_detail(1, 0);
1015        assert_eq!(modal.detail_selected, 0);
1016    }
1017
1018    #[test]
1019    fn defaults_to_first_row() {
1020        assert_eq!(SettingsModal::new().selected, 0);
1021        assert_eq!(SettingsModal::new().detail_selected, 0);
1022        assert_eq!(SettingsModal::new().prompt, None);
1023        assert_eq!(SettingsModal::new().section, ConfigSection::Dashboards);
1024        assert_eq!(SettingsModal::new().level, ConfigLevel::Sections);
1025    }
1026
1027    #[test]
1028    fn sections_cycle_in_both_directions_and_wrap() {
1029        assert_eq!(ConfigSection::Dashboards.next(), ConfigSection::Cycles);
1030        assert_eq!(ConfigSection::Cycles.next(), ConfigSection::Milestones);
1031        assert_eq!(ConfigSection::Milestones.next(), ConfigSection::Types);
1032        assert_eq!(ConfigSection::Types.next(), ConfigSection::CustomAttributes);
1033        assert_eq!(
1034            ConfigSection::CustomAttributes.next(),
1035            ConfigSection::Dashboards
1036        );
1037        assert_eq!(
1038            ConfigSection::Dashboards.prev(),
1039            ConfigSection::CustomAttributes
1040        );
1041        assert_eq!(ConfigSection::CustomAttributes.prev(), ConfigSection::Types);
1042        assert_eq!(ConfigSection::ALL.len(), 5);
1043        // The section is labelled "Attributes", not "Custom attributes".
1044        assert_eq!(ConfigSection::CustomAttributes.label(), "Attributes");
1045    }
1046
1047    #[test]
1048    fn set_section_resets_the_cursors_only_on_a_change() {
1049        let mut modal = SettingsModal {
1050            selected: 2,
1051            detail_selected: 1,
1052            ..SettingsModal::default()
1053        };
1054        modal.set_section(ConfigSection::Cycles);
1055        assert_eq!(modal.selected, 0);
1056        assert_eq!(modal.detail_selected, 0);
1057        modal.selected = 1;
1058        modal.detail_selected = 2;
1059        modal.set_section(ConfigSection::Cycles);
1060        assert_eq!(
1061            modal.selected, 1,
1062            "re-selecting the same section keeps the index cursor"
1063        );
1064        assert_eq!(
1065            modal.detail_selected, 2,
1066            "re-selecting the same section keeps the detail cursor"
1067        );
1068    }
1069
1070    #[test]
1071    fn board_rows_len_counts_columns_display_and_general_rows() {
1072        let board = crate::config::BoardConfiguration {
1073            columns: vec!["todo".into(), "done".into()],
1074            ..Default::default()
1075        };
1076        assert_eq!(board_rows_len(&board), 2 + DISPLAY_ROWS + GENERAL_ROWS);
1077    }
1078
1079    #[test]
1080    fn dashboard_index_rows_list_every_board_with_its_columns() {
1081        let mut config = crate::config::Config::default();
1082        config.kanban.configurations.clear();
1083        config
1084            .kanban
1085            .configurations
1086            .insert("default".into(), crate::config::starter_board());
1087        let ops = crate::config::BoardConfiguration {
1088            columns: vec!["backlog".into(), "shipped".into()],
1089            ..Default::default()
1090        };
1091        config.kanban.configurations.insert("ops".into(), ops);
1092
1093        let rows = dashboard_index_rows(&config);
1094        assert_eq!(rows.len(), 2, "every configured board is listed: {rows:?}");
1095        let (_, columns) = rows
1096            .iter()
1097            .find(|(name, _)| name == "ops")
1098            .expect("the `ops` board is listed");
1099        assert!(
1100            columns.contains("backlog") && columns.contains("shipped"),
1101            "the row shows the board's columns: {columns}"
1102        );
1103    }
1104
1105    #[test]
1106    fn level_focus_defaults_to_the_section_rail() {
1107        let modal = SettingsModal::new();
1108        assert_eq!(modal.level, ConfigLevel::Sections);
1109        assert_eq!(ConfigLevel::Sections.index(), 0);
1110        assert_eq!(ConfigLevel::Index.index(), 1);
1111        assert_eq!(ConfigLevel::Detail.index(), 2);
1112    }
1113
1114    #[test]
1115    fn move_level_clamps_at_both_ends() {
1116        let mut modal = SettingsModal::new();
1117        modal.move_level(1);
1118        assert_eq!(modal.level, ConfigLevel::Index);
1119        modal.move_level(1);
1120        assert_eq!(modal.level, ConfigLevel::Detail);
1121        modal.move_level(1);
1122        assert_eq!(modal.level, ConfigLevel::Detail, "the bottom end clamps");
1123        modal.move_level(-1);
1124        assert_eq!(modal.level, ConfigLevel::Index);
1125        modal.move_level(-1);
1126        assert_eq!(modal.level, ConfigLevel::Sections);
1127        modal.move_level(-1);
1128        assert_eq!(modal.level, ConfigLevel::Sections, "the top end clamps");
1129        modal.focus(ConfigLevel::Detail);
1130        assert_eq!(modal.level, ConfigLevel::Detail);
1131    }
1132
1133    #[test]
1134    fn focused_detail_border_is_rendered_distinctly() {
1135        let board = test_board();
1136        let modal = SettingsModal::new();
1137        let focused = draw_focused(&modal, &board, 80, 24, true);
1138        let unfocused = draw_focused(&modal, &board, 80, 24, false);
1139        let rect = focused.backend().buffer().area;
1140        let border = |term: &Terminal<TestBackend>| term.backend().buffer()[(rect.x, rect.y)].fg;
1141        assert_eq!(
1142            border(&focused),
1143            Color::Yellow,
1144            "the focused detail's border is accented"
1145        );
1146        assert_eq!(
1147            border(&unfocused),
1148            Color::DarkGray,
1149            "the unfocused detail's border is dimmed"
1150        );
1151        assert_ne!(border(&focused), border(&unfocused));
1152    }
1153}