1use 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
32pub const DISPLAY_ROWS: usize = 3;
36
37pub const GENERAL_ROWS: usize = 2;
41
42pub fn board_rows_len(board: &crate::config::BoardConfiguration) -> usize {
45 board.columns.len() + DISPLAY_ROWS + GENERAL_ROWS
46}
47
48pub const SECTION_RAIL_WIDTH: u16 = 21;
51
52pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum ConfigSection {
67 #[default]
69 Dashboards,
70 Cycles,
72 Milestones,
74 Types,
76 CustomAttributes,
78}
79
80impl ConfigSection {
81 pub const ALL: [Self; 5] = [
83 Self::Dashboards,
84 Self::Cycles,
85 Self::Milestones,
86 Self::Types,
87 Self::CustomAttributes,
88 ];
89
90 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 pub fn next(self) -> Self {
103 let index = self.index();
104 Self::ALL[(index + 1) % Self::ALL.len()]
105 }
106
107 pub fn prev(self) -> Self {
109 let index = self.index();
110 Self::ALL[(index + Self::ALL.len() - 1) % Self::ALL.len()]
111 }
112
113 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 pub const fn is_list(self) -> bool {
127 matches!(self, Self::Types | Self::CustomAttributes)
128 }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
139pub enum ConfigLevel {
140 #[default]
142 Sections,
143 Index,
145 Detail,
147}
148
149impl ConfigLevel {
150 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 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 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#[derive(Debug, Clone, Copy)]
180pub struct DashboardView<'a> {
181 pub name: &'a str,
183 pub board: &'a crate::config::BoardConfiguration,
185 pub editor: Option<&'a str>,
187 pub viewer: &'a str,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
194pub enum SettingsPrompt {
195 AddColumn(String),
197 NewBoard(String),
199 RenameBoard {
201 old: String,
203 buffer: String,
205 },
206 ConfirmDeleteBoard {
210 name: String,
212 referencing: usize,
214 },
215 SetEditor(String),
217 SetViewer(String),
220 AddType(String),
222 AddAttribute(String),
225}
226
227impl SettingsPrompt {
228 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#[derive(Debug, Clone, Default)]
250pub struct SettingsModal {
251 pub selected: usize,
254 pub detail_selected: usize,
257 pub prompt: Option<SettingsPrompt>,
259 pub section: ConfigSection,
261 pub level: ConfigLevel,
263}
264
265impl SettingsModal {
266 pub fn new() -> Self {
267 Self::default()
268 }
269
270 pub fn focus(&mut self, level: ConfigLevel) {
272 self.level = level;
273 }
274
275 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 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 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 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 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 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 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
436pub 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
455fn 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
466pub 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 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 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 let rows = app.store.config.attributes();
531 render_list_section(app, f, body, "Attributes", &rows, body_focused);
532 }
533 }
534}
535
536fn 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
607fn 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
680fn 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
691fn 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 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
734fn 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 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 for i in 0..rows.len() {
810 app.record_region(body_row_rect(body, i), Action::ConfigPlanningRow(i));
811 }
812}
813
814fn 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 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 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 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 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}