1use ratatui::Frame;
78use ratatui::layout::{Constraint, Direction, Layout, Rect};
79use ratatui::style::{Color, Modifier, Style};
80use ratatui::text::{Line, Span};
81use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
82
83use crate::app::App;
84use crate::cycle_schedule::config::CycleScheduleId;
85use crate::cycle_schedule::identity::GeneratedCycleId;
86use crate::cycle_schedule::lifecycle::LifecycleAction;
87use crate::keybindings::TuiAction;
88
89#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
95pub enum ScheduleTab {
96 #[default]
98 Series,
99 Cycles,
101 Capacity,
104}
105
106impl ScheduleTab {
107 pub const ALL: [Self; 3] = [Self::Series, Self::Cycles, Self::Capacity];
109
110 pub const fn label(self) -> &'static str {
112 match self {
113 Self::Series => "Series",
114 Self::Cycles => "Cycle schedules",
115 Self::Capacity => "Capacity",
116 }
117 }
118
119 pub const fn next(self) -> Self {
121 match self {
122 Self::Series => Self::Cycles,
123 Self::Cycles => Self::Capacity,
124 Self::Capacity => Self::Series,
125 }
126 }
127
128 pub const fn prev(self) -> Self {
134 match self {
135 Self::Series => Self::Capacity,
136 Self::Cycles => Self::Series,
137 Self::Capacity => Self::Cycles,
138 }
139 }
140
141 pub const fn hint(self) -> &'static str {
143 match self {
144 Self::Series => SERIES_KEYS,
145 Self::Cycles => CYCLE_SCHEDULE_KEYS,
146 Self::Capacity => CAPACITY_KEYS,
147 }
148 }
149
150 pub const fn empty_hint(self) -> &'static str {
155 match self {
156 Self::Series => {
157 "No recurring series yet. Create one with \
158 `tix recurring create --title <TITLE> --rule daily --anchor <DATE>`, \
159 or press `n` to create one here."
160 }
161 Self::Cycles => {
162 "No cycle schedules yet. Create one with `tix cycle schedule create --id <ID> \
163 --name <NAME> --cadence <KIND> --duration <N> --duration-unit <UNIT> \
164 --start-date <DATE> --timezone <TZ> --confirm`."
165 }
166 Self::Capacity => {
167 "No capacity configured. Configure one with `tix capacity set --scope \
168 <cycle|team|assignee> --id <REFERENCE> --value <N> --unit <UNIT>`."
169 }
170 }
171 }
172}
173
174#[derive(Debug, Clone, Default, PartialEq, Eq)]
180pub struct ScheduleState {
181 pub tab: ScheduleTab,
183 pub selected: usize,
185 pub prompt: Option<CycleReasonPrompt>,
188 pub form: Option<ScheduleForm>,
191 pub series_form: Option<SeriesForm>,
200}
201
202impl ScheduleState {
203 pub fn new() -> Self {
205 Self::default()
206 }
207
208 pub fn move_selected(&mut self, delta: isize, rows: usize) -> bool {
210 let last = rows.saturating_sub(1);
211 let before = self.selected;
212 let next = if delta.is_negative() {
213 self.selected.saturating_sub(delta.unsigned_abs())
214 } else {
215 self.selected.saturating_add(delta as usize)
216 };
217 self.selected = next.min(last);
218 self.selected != before
219 }
220
221 pub fn clamp_selected(&mut self, rows: usize) {
224 self.selected = self.selected.min(rows.saturating_sub(1));
225 }
226
227 pub fn switch_tab(&mut self, delta: isize) {
230 self.tab = if delta.is_negative() {
231 self.tab.prev()
232 } else {
233 self.tab.next()
234 };
235 self.selected = 0;
236 }
237}
238
239#[derive(Debug, Clone, PartialEq, Eq)]
241pub struct ScheduleEntry {
242 pub title: String,
244 pub subtitle: String,
246 pub due: bool,
248}
249
250#[derive(Debug, Clone, PartialEq, Eq)]
254pub struct CycleScheduleEntry {
255 pub title: String,
257 pub subtitle: String,
259 pub warned: bool,
261}
262
263#[derive(Debug, Clone, Default, PartialEq, Eq)]
265pub struct ScheduleDetail {
266 pub heading: String,
268 pub lines: Vec<(String, String)>,
270}
271
272#[derive(Debug, Clone, Default, PartialEq, Eq)]
278pub struct CycleScheduleDetail {
279 pub fields: Vec<(String, String)>,
281 pub cycles: Vec<String>,
283 pub snapshots: Vec<String>,
285 pub findings: Vec<String>,
287 pub warnings: Vec<String>,
289}
290
291#[derive(Debug, Clone, Default, PartialEq, Eq)]
297pub struct CapacityRow {
298 pub title: String,
301 pub subtitle: String,
304 pub warned: bool,
307 pub overflowing: bool,
310}
311
312#[derive(Debug, Clone, Default, PartialEq, Eq)]
318pub struct CapacitySnapshotView {
319 pub label: String,
322 pub fields: Vec<(String, String)>,
324}
325
326#[derive(Debug, Clone, Default, PartialEq, Eq)]
330pub struct CapacityDetail {
331 pub heading: String,
333 pub fields: Vec<(String, String)>,
335 pub diagnostics: Vec<String>,
338 pub snapshot: Option<CapacitySnapshotView>,
341}
342
343#[derive(Debug, Clone, Default, PartialEq, Eq)]
350pub struct CapacityView {
351 pub rows: Vec<CapacityRow>,
353 pub details: Vec<CapacityDetail>,
355 pub summaries: std::collections::BTreeMap<String, String>,
358}
359
360#[derive(Debug, Clone, PartialEq, Eq)]
366pub struct CycleReasonPrompt {
367 pub schedule: CycleScheduleId,
369 pub action: LifecycleAction,
371 pub cycle: Option<GeneratedCycleId>,
373 pub buffer: String,
375}
376
377#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
382pub enum ScheduleField {
383 Id,
385 Name,
387 Cadence,
389 Interval,
391 Unit,
393 Duration,
395 DurationUnit,
397 StartDate,
399 Timezone,
401 LookAhead,
403}
404
405impl ScheduleField {
406 pub const ALL: [Self; 10] = [
408 Self::Id,
409 Self::Name,
410 Self::Cadence,
411 Self::Interval,
412 Self::Unit,
413 Self::Duration,
414 Self::DurationUnit,
415 Self::StartDate,
416 Self::Timezone,
417 Self::LookAhead,
418 ];
419
420 pub const EDIT: [Self; 9] = [
423 Self::Name,
424 Self::Cadence,
425 Self::Interval,
426 Self::Unit,
427 Self::Duration,
428 Self::DurationUnit,
429 Self::StartDate,
430 Self::Timezone,
431 Self::LookAhead,
432 ];
433
434 pub const fn label(self) -> &'static str {
436 match self {
437 Self::Id => "id",
438 Self::Name => "name",
439 Self::Cadence => "cadence",
440 Self::Interval => "interval",
441 Self::Unit => "unit",
442 Self::Duration => "duration",
443 Self::DurationUnit => "duration-unit",
444 Self::StartDate => "start-date",
445 Self::Timezone => "timezone",
446 Self::LookAhead => "look-ahead",
447 }
448 }
449
450 pub const fn hint(self) -> &'static str {
452 match self {
453 Self::Id => "stable, never reused",
454 Self::Name => "display label",
455 Self::Cadence => "daily | weekly | monthly | interval",
456 Self::Interval => "1-1000 (interval cadence)",
457 Self::Unit => "days | weeks | months",
458 Self::Duration => "positive count",
459 Self::DurationUnit => "days | weeks | months",
460 Self::StartDate => "YYYY-MM-DD",
461 Self::Timezone => "UTC or ±HH:MM",
462 Self::LookAhead => "future cycles to consider",
463 }
464 }
465
466 pub const fn is_date(self) -> bool {
468 matches!(self, Self::StartDate)
469 }
470}
471
472#[derive(Debug, Clone, PartialEq, Eq)]
480pub struct ScheduleForm {
481 pub editing: Option<String>,
483 pub fields: Vec<ScheduleField>,
485 pub values: Vec<String>,
487 pub focus: usize,
489 pub error: Option<String>,
491}
492
493impl ScheduleForm {
494 pub fn create() -> Self {
496 let fields = ScheduleField::ALL.to_vec();
497 let values = vec![String::new(); fields.len()];
498 Self {
499 editing: None,
500 fields,
501 values,
502 focus: 0,
503 error: None,
504 }
505 }
506
507 pub fn edit(id: String, values: Vec<String>) -> Self {
513 let fields = ScheduleField::EDIT.to_vec();
514 let mut filled = values;
515 filled.resize(fields.len(), String::new());
516 Self {
517 editing: Some(id),
518 fields,
519 values: filled,
520 focus: 0,
521 error: None,
522 }
523 }
524
525 pub fn title(&self) -> String {
527 match &self.editing {
528 Some(id) => format!("Edit cycle schedule `{id}` (enter: save, esc: cancel)"),
529 None => "New cycle schedule (enter: save, esc: cancel)".to_string(),
530 }
531 }
532
533 pub fn focused(&self) -> ScheduleField {
535 self.fields
536 .get(self.focus)
537 .copied()
538 .unwrap_or(ScheduleField::Id)
539 }
540
541 pub fn value(&self, field: ScheduleField) -> &str {
543 self.fields
544 .iter()
545 .position(|shown| *shown == field)
546 .and_then(|index| self.values.get(index))
547 .map(String::as_str)
548 .unwrap_or("")
549 }
550
551 pub fn focus_next(&mut self) {
553 let len = self.fields.len().max(1);
554 self.focus = (self.focus + 1) % len;
555 }
556
557 pub fn focus_prev(&mut self) {
559 let len = self.fields.len().max(1);
560 self.focus = (self.focus + len - 1) % len;
561 }
562
563 pub fn insert_char(&mut self, c: char) {
565 let focus = self.focus;
566 if let Some(value) = self.values.get_mut(focus) {
567 value.push(c);
568 }
569 }
570
571 pub fn delete_char(&mut self) {
573 let focus = self.focus;
574 if let Some(value) = self.values.get_mut(focus) {
575 value.pop();
576 }
577 }
578
579 pub fn clear_field(&mut self) {
581 let focus = self.focus;
582 if let Some(value) = self.values.get_mut(focus) {
583 value.clear();
584 }
585 }
586
587 pub fn set_error(&mut self, message: String) {
589 self.error = Some(message);
590 }
591}
592
593#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
601pub enum SeriesField {
602 Title,
604 Rule,
606 Anchor,
608}
609
610impl SeriesField {
611 pub const ALL: [Self; 3] = [Self::Title, Self::Rule, Self::Anchor];
613
614 pub const fn label(self) -> &'static str {
616 match self {
617 Self::Title => "title",
618 Self::Rule => "rule",
619 Self::Anchor => "anchor",
620 }
621 }
622
623 pub const fn hint(self) -> &'static str {
630 match self {
631 Self::Title => "issue title (required)",
632 Self::Rule => "daily | weekly | monthly",
633 Self::Anchor => "YYYY-MM-DD",
634 }
635 }
636}
637
638#[derive(Debug, Clone, PartialEq, Eq)]
646pub struct SeriesForm {
647 pub fields: Vec<SeriesField>,
649 pub values: Vec<String>,
651 pub focus: usize,
653 pub error: Option<String>,
655}
656
657impl SeriesForm {
658 pub const NOTE: &'static str = "`interval` and `cron-like` rules need the CLI: \
666 `tix recurring create --rule <FAMILY> --interval \
667 <N> --unit <UNIT>`.";
668
669 pub fn create() -> Self {
671 Self {
672 fields: SeriesField::ALL.to_vec(),
673 values: vec![String::new(); SeriesField::ALL.len()],
674 focus: 0,
675 error: None,
676 }
677 }
678
679 pub fn title(&self) -> &'static str {
681 "New recurring series (enter: save, esc: cancel)"
682 }
683
684 pub fn focused(&self) -> SeriesField {
686 self.fields
687 .get(self.focus)
688 .copied()
689 .unwrap_or(SeriesField::Title)
690 }
691
692 pub fn value(&self, field: SeriesField) -> &str {
694 self.fields
695 .iter()
696 .position(|shown| *shown == field)
697 .and_then(|index| self.values.get(index))
698 .map(String::as_str)
699 .unwrap_or("")
700 }
701
702 pub fn focus_next(&mut self) {
704 let len = self.fields.len().max(1);
705 self.focus = (self.focus + 1) % len;
706 }
707
708 pub fn focus_prev(&mut self) {
710 let len = self.fields.len().max(1);
711 self.focus = (self.focus + len - 1) % len;
712 }
713
714 pub fn insert_char(&mut self, c: char) {
716 let focus = self.focus;
717 if let Some(value) = self.values.get_mut(focus) {
718 value.push(c);
719 }
720 }
721
722 pub fn delete_char(&mut self) {
724 let focus = self.focus;
725 if let Some(value) = self.values.get_mut(focus) {
726 value.pop();
727 }
728 }
729
730 pub fn clear_field(&mut self) {
732 let focus = self.focus;
733 if let Some(value) = self.values.get_mut(focus) {
734 value.clear();
735 }
736 }
737
738 pub fn set_error(&mut self, message: String) {
740 self.error = Some(message);
741 }
742}
743
744pub const CYCLE_HELP_ACTIONS: &[(TuiAction, &str)] = &[
753 (TuiAction::MoveUp, "move the cursor up"),
754 (TuiAction::MoveDown, "move the cursor down"),
755 (
756 TuiAction::SeriesGenerate,
757 "generate the schedule's look-ahead",
758 ),
759 (TuiAction::SeriesSkip, "skip the current cycle"),
760 (TuiAction::SeriesPause, "pause the schedule"),
761 (TuiAction::SeriesResume, "resume the schedule"),
762 (
763 TuiAction::SeriesIgnore,
764 "close a warning without deciding anything",
765 ),
766 (TuiAction::Help, "this help"),
767 (TuiAction::Close, "back to the board"),
768];
769
770pub const CYCLE_HELP_RAW: &[(&str, &str)] = &[
773 ("tab / shift+tab", "switch tabs"),
774 ("n", "define a new schedule"),
775 ("E", "edit the selected schedule"),
776 ("c", "close the current cycle (asks why)"),
777 ("R", "reopen the closed cycle (asks why)"),
778 ("e", "extend the current cycle (asks why)"),
779 ("s", "skip the current cycle (asks why)"),
780 ("a", "re-derive future cycles (asks why)"),
781];
782
783pub const SERIES_KEYS: &str = "j/k: move n: new g: generate p: pause r: resume x: end \
785 s: skip d: defer i: ignore q: back to the board";
786
787pub const SCHEDULE_KEYS: &str = SERIES_KEYS;
789
790pub const CYCLE_SCHEDULE_KEYS: &str = "j/k: move Tab: tabs n: new E: edit g: generate \
792 c: close R: reopen e: extend s: skip p: pause \
793 r: resume a: adjust-future i: ignore q: back to the board";
794
795pub const CAPACITY_KEYS: &str = "j/k: move Tab: tabs q: back to the board";
800
801pub const CAPACITY_HELP_ACTIONS: &[(TuiAction, &str)] = &[
807 (TuiAction::MoveUp, "move the cursor up"),
808 (TuiAction::MoveDown, "move the cursor down"),
809 (TuiAction::Help, "this help"),
810 (TuiAction::Close, "back to the board"),
811];
812
813pub const CAPACITY_HELP_RAW: &[(&str, &str)] = &[("tab / shift+tab", "switch tabs")];
816
817fn dim() -> Style {
818 Style::default().fg(Color::DarkGray)
819}
820
821fn tab_line(active: ScheduleTab) -> Line<'static> {
823 let mut spans = vec![
824 Span::styled("F8 Schedule", Style::default().add_modifier(Modifier::BOLD)),
825 Span::raw(" "),
826 ];
827 for (index, tab) in ScheduleTab::ALL.iter().enumerate() {
828 if index > 0 {
829 spans.push(Span::raw(" "));
830 }
831 let style = if *tab == active {
832 Style::default()
833 .fg(Color::Cyan)
834 .add_modifier(Modifier::BOLD)
835 } else {
836 dim()
837 };
838 spans.push(Span::styled(format!("[{}]", tab.label()), style));
839 }
840 Line::from(spans)
841}
842
843pub fn tab_rects(area: Rect) -> Vec<(ScheduleTab, Rect)> {
849 use unicode_width::UnicodeWidthStr;
850
851 let mut x = area.x + UnicodeWidthStr::width("F8 Schedule") as u16 + 2;
853 let mut rects = Vec::with_capacity(ScheduleTab::ALL.len());
854 for (index, tab) in ScheduleTab::ALL.iter().enumerate() {
855 if index > 0 {
856 x += 2;
857 }
858 let label = format!("[{}]", tab.label());
859 let width = UnicodeWidthStr::width(label.as_str()) as u16;
860 rects.push((*tab, Rect::new(x, area.y, width, 1)));
861 x += width;
862 }
863 rects
864}
865
866fn prompt_line(prompt: &CycleReasonPrompt) -> Line<'static> {
868 let mut spans = vec![Span::styled(
869 prompt.action.as_str().to_string(),
870 Style::default().add_modifier(Modifier::BOLD),
871 )];
872 if let Some(cycle) = &prompt.cycle {
873 spans.push(Span::raw(format!(" {cycle}")));
874 }
875 spans.push(Span::styled(" reason> ", dim()));
876 spans.push(Span::raw(prompt.buffer.clone()));
877 spans.push(Span::styled(
878 " (enter: apply esc: cancel, nothing written)",
879 dim(),
880 ));
881 Line::from(spans)
882}
883
884pub fn render(app: &App, f: &mut Frame, area: Rect) {
886 let state = app.schedule_state();
887
888 let rows = Layout::default()
889 .direction(Direction::Vertical)
890 .constraints([
891 Constraint::Length(1),
892 Constraint::Length(1),
893 Constraint::Min(1),
894 ])
895 .split(area);
896 f.render_widget(Paragraph::new(tab_line(state.tab)), rows[0]);
897 match state.prompt.as_ref() {
902 Some(prompt) => f.render_widget(Paragraph::new(prompt_line(prompt)), rows[1]),
903 None if app.status_msg.is_some() => {
904 let message = app.status_msg.as_deref().unwrap_or_default();
905 f.render_widget(
906 Paragraph::new(Span::styled(message, Style::default())),
907 rows[1],
908 );
909 }
910 None => f.render_widget(
911 Paragraph::new(Span::styled(state.tab.hint(), dim())),
912 rows[1],
913 ),
914 }
915
916 if let Some(form) = state.series_form.as_ref() {
921 render_series_form(form, f, rows[2]);
922 return;
923 }
924 if let Some(form) = state.form.as_ref() {
925 render_form(form, f, rows[2]);
926 return;
927 }
928
929 match state.tab {
930 ScheduleTab::Series => render_series(app, f, rows[2], &state),
931 ScheduleTab::Cycles => render_cycles(app, f, rows[2], &state),
932 ScheduleTab::Capacity => render_capacity(app, f, rows[2], &state),
933 }
934}
935
936fn render_form(form: &ScheduleForm, f: &mut Frame, area: Rect) {
942 let rows: Vec<(String, String, String)> = form
943 .fields
944 .iter()
945 .map(|field| {
946 (
947 field.label().to_string(),
948 form.value(*field).to_string(),
949 field.hint().to_string(),
950 )
951 })
952 .collect();
953 render_form_fields(
954 f,
955 area,
956 &form.title(),
957 &rows,
958 form.focus,
959 form.error.as_deref(),
960 None,
961 );
962}
963
964fn render_series_form(form: &SeriesForm, f: &mut Frame, area: Rect) {
966 let rows: Vec<(String, String, String)> = form
967 .fields
968 .iter()
969 .map(|field| {
970 (
971 field.label().to_string(),
972 form.value(*field).to_string(),
973 field.hint().to_string(),
974 )
975 })
976 .collect();
977 render_form_fields(
978 f,
979 area,
980 form.title(),
981 &rows,
982 form.focus,
983 form.error.as_deref(),
984 Some(SeriesForm::NOTE),
985 );
986}
987
988fn render_form_fields(
994 f: &mut Frame,
995 area: Rect,
996 title: &str,
997 rows: &[(String, String, String)],
998 focus: usize,
999 error: Option<&str>,
1000 note: Option<&str>,
1001) {
1002 let block = Block::default()
1003 .borders(Borders::ALL)
1004 .title(title.to_string());
1005 let inner = block.inner(area);
1006 f.render_widget(block, area);
1007
1008 let mut lines: Vec<Line<'static>> = Vec::new();
1009 for (index, (label, value, hint)) in rows.iter().enumerate() {
1010 let focused = index == focus;
1011 let mut spans = vec![Span::raw(if focused { "> " } else { " " })];
1012 spans.push(Span::styled(
1013 format!("{label}: "),
1014 if focused {
1015 Style::default().add_modifier(Modifier::BOLD)
1016 } else {
1017 dim()
1018 },
1019 ));
1020 spans.push(Span::raw(value.clone()));
1021 if focused {
1022 spans.push(Span::styled("▏", Style::default().fg(Color::Cyan)));
1023 }
1024 spans.push(Span::styled(format!(" ({hint})"), dim()));
1025 lines.push(Line::from(spans));
1026 }
1027 lines.push(Line::from(""));
1028 if let Some(note) = note {
1029 lines.push(Line::from(Span::styled(note.to_string(), dim())));
1030 lines.push(Line::from(""));
1031 }
1032 lines.push(Line::from(Span::styled(
1033 "tab / shift+tab: field enter: save esc: cancel, nothing written",
1034 dim(),
1035 )));
1036 if let Some(error) = error {
1037 lines.push(Line::from(""));
1038 for line in error.lines() {
1039 lines.push(Line::from(Span::styled(
1040 line.to_string(),
1041 Style::default().fg(Color::Red),
1042 )));
1043 }
1044 }
1045 f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
1046}
1047
1048fn render_series(app: &App, f: &mut Frame, area: Rect, state: &ScheduleState) {
1050 let entries = app.schedule_entries();
1051 let detail = app.schedule_detail(state.selected);
1052
1053 let body = Layout::default()
1054 .direction(Direction::Horizontal)
1055 .constraints([Constraint::Percentage(45), Constraint::Min(24)])
1056 .split(area);
1057
1058 let list = Block::default()
1059 .borders(Borders::ALL)
1060 .title(format!("Series ({})", entries.len()));
1061 if entries.is_empty() {
1062 f.render_widget(
1063 Paragraph::new(ScheduleTab::Series.empty_hint())
1064 .block(list)
1065 .wrap(Wrap { trim: true })
1066 .style(dim()),
1067 body[0],
1068 );
1069 } else {
1070 let mut lines: Vec<Line<'static>> = Vec::new();
1071 for (index, entry) in entries.iter().enumerate() {
1072 let selected = index == state.selected;
1073 let mut spans = vec![Span::raw(if selected { "> " } else { " " })];
1074 spans.push(Span::styled(
1075 entry.title.clone(),
1076 if selected {
1077 Style::default().add_modifier(Modifier::BOLD)
1078 } else {
1079 Style::default()
1080 },
1081 ));
1082 if entry.due {
1083 spans.push(Span::styled(" [due]", Style::default().fg(Color::Cyan)));
1084 }
1085 lines.push(Line::from(spans));
1086 lines.push(Line::from(Span::styled(
1087 format!(" {}", entry.subtitle),
1088 dim(),
1089 )));
1090 }
1091 f.render_widget(Paragraph::new(lines).block(list), body[0]);
1092 }
1093
1094 let block = Block::default()
1095 .borders(Borders::ALL)
1096 .title("Series detail");
1097 match detail.as_ref() {
1098 Some(detail) => {
1099 let lines: Vec<Line<'static>> = detail
1100 .lines
1101 .iter()
1102 .map(|(key, value)| {
1103 Line::from(vec![
1104 Span::styled(format!("{key}: "), dim()),
1105 Span::raw(value.clone()),
1106 ])
1107 })
1108 .collect();
1109 f.render_widget(
1110 Paragraph::new(lines)
1111 .block(block)
1112 .wrap(Wrap { trim: false }),
1113 body[1],
1114 );
1115 }
1116 None => f.render_widget(
1117 Paragraph::new(Span::styled(
1118 "Nothing selected: a series has to exist before the page can describe it.",
1119 dim(),
1120 ))
1121 .block(block)
1122 .wrap(Wrap { trim: true }),
1123 body[1],
1124 ),
1125 }
1126}
1127
1128fn render_cycles(app: &App, f: &mut Frame, area: Rect, state: &ScheduleState) {
1131 let entries = app.cycle_schedule_entries();
1132 let detail = app.cycle_schedule_detail(state.selected);
1133 let unattached = app.cycle_schedule_unattached_findings();
1134
1135 let body = Layout::default()
1136 .direction(Direction::Horizontal)
1137 .constraints([Constraint::Percentage(45), Constraint::Min(24)])
1138 .split(area);
1139
1140 if unattached.is_empty() {
1145 render_cycle_list(f, body[0], &entries, state.selected);
1146 } else {
1147 let height = (unattached.len() as u16 * 3 + 2).min(12);
1152 let columns = Layout::default()
1153 .direction(Direction::Vertical)
1154 .constraints([Constraint::Min(3), Constraint::Length(height)])
1155 .split(body[0]);
1156 render_cycle_list(f, columns[0], &entries, state.selected);
1157 let findings: Vec<Line<'static>> = unattached
1158 .iter()
1159 .map(|line| Line::from(Span::styled(line.clone(), dim())))
1160 .collect();
1161 f.render_widget(
1162 Paragraph::new(findings)
1163 .block(
1164 Block::default()
1165 .borders(Borders::ALL)
1166 .title(format!("Unattached findings ({})", unattached.len())),
1167 )
1168 .wrap(Wrap { trim: true }),
1169 columns[1],
1170 );
1171 }
1172
1173 let block = Block::default()
1174 .borders(Borders::ALL)
1175 .title("Cycle schedule detail");
1176 match detail.as_ref() {
1177 Some(detail) => {
1178 let mut lines: Vec<Line<'static>> = detail
1179 .fields
1180 .iter()
1181 .map(|(key, value)| {
1182 Line::from(vec![
1183 Span::styled(format!("{key}: "), dim()),
1184 Span::raw(value.clone()),
1185 ])
1186 })
1187 .collect();
1188 section(&mut lines, "cycles", &detail.cycles);
1189 section(&mut lines, "snapshots", &detail.snapshots);
1190 section(&mut lines, "findings", &detail.findings);
1191 section(&mut lines, "warnings", &detail.warnings);
1192 f.render_widget(
1193 Paragraph::new(lines)
1194 .block(block)
1195 .wrap(Wrap { trim: false }),
1196 body[1],
1197 );
1198 }
1199 None => f.render_widget(
1200 Paragraph::new(Span::styled(
1201 "Nothing selected: a cycle schedule has to exist before the page can \
1202 describe it.",
1203 dim(),
1204 ))
1205 .block(block)
1206 .wrap(Wrap { trim: true }),
1207 body[1],
1208 ),
1209 }
1210}
1211
1212fn render_cycle_list(f: &mut Frame, area: Rect, entries: &[CycleScheduleEntry], selected: usize) {
1214 let block = Block::default()
1215 .borders(Borders::ALL)
1216 .title(format!("Cycle schedules ({})", entries.len()));
1217 if entries.is_empty() {
1218 f.render_widget(
1219 Paragraph::new(ScheduleTab::Cycles.empty_hint())
1220 .block(block)
1221 .wrap(Wrap { trim: true })
1222 .style(dim()),
1223 area,
1224 );
1225 return;
1226 }
1227 let mut lines: Vec<Line<'static>> = Vec::new();
1228 for (index, entry) in entries.iter().enumerate() {
1229 let is_selected = index == selected;
1230 let mut spans = vec![Span::raw(if is_selected { "> " } else { " " })];
1231 spans.push(Span::styled(
1232 entry.title.clone(),
1233 if is_selected {
1234 Style::default().add_modifier(Modifier::BOLD)
1235 } else {
1236 Style::default()
1237 },
1238 ));
1239 if entry.warned {
1240 spans.push(Span::styled(" [warn]", Style::default().fg(Color::Yellow)));
1241 }
1242 lines.push(Line::from(spans));
1243 lines.push(Line::from(Span::styled(
1244 format!(" {}", entry.subtitle),
1245 dim(),
1246 )));
1247 }
1248 f.render_widget(Paragraph::new(lines).block(block), area);
1249}
1250
1251fn section(lines: &mut Vec<Line<'static>>, heading: &str, body: &[String]) {
1254 lines.push(Line::from(""));
1255 lines.push(Line::from(Span::styled(
1256 format!("{heading} ({}):", body.len()),
1257 Style::default().add_modifier(Modifier::BOLD),
1258 )));
1259 if body.is_empty() {
1260 lines.push(Line::from(Span::styled(" none", dim())));
1261 return;
1262 }
1263 for line in body {
1264 lines.push(Line::from(Span::raw(format!(" {line}"))));
1265 }
1266}
1267
1268fn render_capacity(app: &App, f: &mut Frame, area: Rect, state: &ScheduleState) {
1278 let rows = app.capacity_rows();
1279 let body = Layout::default()
1280 .direction(Direction::Horizontal)
1281 .constraints([Constraint::Percentage(45), Constraint::Min(24)])
1282 .split(area);
1283
1284 let list = Block::default()
1285 .borders(Borders::ALL)
1286 .title(format!("Capacity ({})", rows.len()));
1287 if !app.capacity_read() {
1288 f.render_widget(
1289 Paragraph::new(
1290 "Could not read the capacity snapshot for this project, so no capacity view \
1291 is available.",
1292 )
1293 .block(list)
1294 .wrap(Wrap { trim: true })
1295 .style(dim()),
1296 body[0],
1297 );
1298 } else if rows.is_empty() {
1299 f.render_widget(
1300 Paragraph::new(ScheduleTab::Capacity.empty_hint())
1301 .block(list)
1302 .wrap(Wrap { trim: true })
1303 .style(dim()),
1304 body[0],
1305 );
1306 } else {
1307 let mut lines: Vec<Line<'static>> = Vec::new();
1308 for (index, row) in rows.iter().enumerate() {
1309 let selected = index == state.selected;
1310 let mut spans = vec![Span::raw(if selected { "> " } else { " " })];
1311 spans.push(Span::styled(
1312 row.title.clone(),
1313 if selected {
1314 Style::default().add_modifier(Modifier::BOLD)
1315 } else {
1316 Style::default()
1317 },
1318 ));
1319 if row.overflowing {
1320 spans.push(Span::styled(" [over]", Style::default().fg(Color::Red)));
1321 } else if row.warned {
1322 spans.push(Span::styled(" [warn]", Style::default().fg(Color::Yellow)));
1323 }
1324 lines.push(Line::from(spans));
1325 lines.push(Line::from(Span::styled(
1326 format!(" {}", row.subtitle),
1327 dim(),
1328 )));
1329 }
1330 if app.capacity_nothing_committed() {
1333 lines.push(Line::from(""));
1334 lines.push(Line::from(Span::styled(
1335 "no committed work in any configured scope yet",
1336 dim(),
1337 )));
1338 }
1339 f.render_widget(Paragraph::new(lines).block(list), body[0]);
1340 }
1341
1342 let block = Block::default()
1343 .borders(Borders::ALL)
1344 .title("Capacity detail");
1345 match app.capacity_detail(state.selected) {
1346 Some(detail) => {
1347 let mut lines: Vec<Line<'static>> = Vec::new();
1348 for (key, value) in &detail.fields {
1349 lines.push(Line::from(vec![
1350 Span::styled(format!("{key}: "), dim()),
1351 Span::raw(value.clone()),
1352 ]));
1353 }
1354 section(&mut lines, "findings", &detail.diagnostics);
1355 if let Some(snapshot) = &detail.snapshot {
1356 lines.push(Line::from(""));
1357 lines.push(Line::from(Span::styled(
1358 format!("closed snapshot — {}", snapshot.label),
1359 Style::default().add_modifier(Modifier::BOLD),
1360 )));
1361 for (key, value) in &snapshot.fields {
1362 lines.push(Line::from(vec![
1363 Span::styled(format!("{key}: "), dim()),
1364 Span::raw(value.clone()),
1365 ]));
1366 }
1367 if snapshot.fields.is_empty() {
1368 lines.push(Line::from(Span::styled(" none", dim())));
1369 }
1370 }
1371 f.render_widget(
1372 Paragraph::new(lines)
1373 .block(block)
1374 .wrap(Wrap { trim: false }),
1375 body[1],
1376 );
1377 }
1378 None => {
1379 let sentence = if app.capacity_read() {
1385 "Nothing selected: a capacity scope has to be configured before the page \
1386 can describe it."
1387 } else {
1388 "Could not read the capacity snapshot for this project, so no capacity \
1389 view is available."
1390 };
1391 f.render_widget(
1392 Paragraph::new(Span::styled(sentence, dim()))
1393 .block(block)
1394 .wrap(Wrap { trim: true }),
1395 body[1],
1396 );
1397 }
1398 }
1399}
1400
1401#[cfg(test)]
1402mod tests {
1403 #[allow(clippy::wildcard_imports)]
1404 use super::*;
1405
1406 #[test]
1407 fn the_cursor_clamps_to_the_row_count() {
1408 let mut state = ScheduleState::new();
1409 assert!(state.move_selected(3, 5));
1410 assert_eq!(state.selected, 3);
1411 assert!(state.move_selected(9, 5), "clamped to the last row");
1412 assert_eq!(state.selected, 4);
1413 assert!(!state.move_selected(1, 5));
1414 state.move_selected(-9, 5);
1415 assert_eq!(state.selected, 0);
1416 state.move_selected(1, 0);
1417 assert_eq!(state.selected, 0, "an empty list keeps the cursor at zero");
1418 }
1419
1420 #[test]
1421 fn a_shrinking_registry_clamps_the_cursor() {
1422 let mut state = ScheduleState {
1423 selected: 4,
1424 ..Default::default()
1425 };
1426 state.clamp_selected(2);
1427 assert_eq!(state.selected, 1);
1428 state.clamp_selected(0);
1429 assert_eq!(state.selected, 0);
1430 }
1431
1432 #[test]
1433 fn the_tab_strip_has_all_three_tabs_and_the_series_tab_is_the_default() {
1434 assert_eq!(ScheduleTab::default(), ScheduleTab::Series);
1435 assert_eq!(
1436 ScheduleTab::ALL,
1437 [
1438 ScheduleTab::Series,
1439 ScheduleTab::Cycles,
1440 ScheduleTab::Capacity
1441 ]
1442 );
1443 assert_eq!(ScheduleTab::Series.label(), "Series");
1444 assert_eq!(ScheduleTab::Cycles.label(), "Cycle schedules");
1445 assert_eq!(ScheduleTab::Capacity.label(), "Capacity");
1446 let drawn: String = tab_line(ScheduleTab::Capacity)
1447 .spans
1448 .iter()
1449 .map(|span| span.content.clone())
1450 .collect();
1451 assert!(drawn.contains("[Series]"), "{drawn}");
1452 assert!(drawn.contains("[Cycle schedules]"), "{drawn}");
1453 assert!(drawn.contains("[Capacity]"), "{drawn}");
1454 assert!(drawn.contains("F8 Schedule"), "{drawn}");
1455 }
1456
1457 #[test]
1458 fn the_tab_strip_geometry_matches_the_drawn_line() {
1459 let area = Rect::new(3, 7, 80, 24);
1463 let drawn: Vec<String> = tab_line(ScheduleTab::Series)
1464 .spans
1465 .iter()
1466 .map(|span| span.content.to_string())
1467 .collect();
1468 let line = drawn.concat();
1469 let rects = tab_rects(area);
1470 assert_eq!(rects.len(), ScheduleTab::ALL.len());
1471 for (tab, rect) in rects {
1472 assert_eq!(rect.y, area.y, "the strip is one row at the top");
1473 assert_eq!(rect.height, 1);
1474 let start = usize::from(rect.x - area.x);
1475 let end = start + usize::from(rect.width);
1476 assert_eq!(
1477 &line[start..end],
1478 format!("[{}]", tab.label()),
1479 "the rect addresses {tab:?}'s own label"
1480 );
1481 }
1482 }
1483
1484 #[test]
1485 fn switching_tabs_wraps_and_resets_the_row_cursor() {
1486 let mut state = ScheduleState {
1487 selected: 3,
1488 ..Default::default()
1489 };
1490 state.switch_tab(1);
1491 assert_eq!(state.tab, ScheduleTab::Cycles);
1492 assert_eq!(
1493 state.selected, 0,
1494 "the cursor indexes the active tab's rows"
1495 );
1496 state.switch_tab(1);
1497 assert_eq!(state.tab, ScheduleTab::Capacity);
1498 state.switch_tab(1);
1499 assert_eq!(state.tab, ScheduleTab::Series, "`Tab` wraps");
1500 state.switch_tab(-1);
1501 assert_eq!(state.tab, ScheduleTab::Capacity, "`Shift+Tab` steps back");
1502 }
1503
1504 #[test]
1505 fn every_tab_has_an_explicit_empty_state() {
1506 for tab in ScheduleTab::ALL {
1507 assert!(
1508 tab.empty_hint().contains("No ") || tab.empty_hint().contains("yet"),
1509 "{tab:?} says what is missing: {}",
1510 tab.empty_hint()
1511 );
1512 }
1513 }
1514
1515 #[test]
1516 fn the_series_empty_state_keeps_the_cli_hint_and_adds_the_tui_offer() {
1517 let hint = ScheduleTab::Series.empty_hint();
1518 assert!(
1519 hint.contains(
1520 "Create one with `tix recurring create --title <TITLE> --rule daily \
1521 --anchor <DATE>`"
1522 ),
1523 "the CLI hint survives verbatim: {hint}"
1524 );
1525 assert!(
1526 hint.contains("press `n` to create one here"),
1527 "and the TUI offers its own key: {hint}"
1528 );
1529 }
1530
1531 #[test]
1532 fn the_hint_line_advertises_every_key_the_series_tab_handles() {
1533 for expected in [
1534 "j/k",
1535 "n: new",
1536 "g: generate",
1537 "p: pause",
1538 "r: resume",
1539 "x: end",
1540 "s: skip",
1541 "d: defer",
1542 "i: ignore",
1543 "q: back",
1544 ] {
1545 assert!(
1546 SERIES_KEYS.contains(expected),
1547 "the page advertises `{expected}`: {SERIES_KEYS}"
1548 );
1549 }
1550 }
1551
1552 #[test]
1553 fn the_cycle_tab_hint_advertises_every_key_it_handles() {
1554 for expected in [
1555 "j/k",
1556 "Tab: tabs",
1557 "n: new",
1558 "E: edit",
1559 "g: generate",
1560 "c: close",
1561 "e: extend",
1562 "s: skip",
1563 "p: pause",
1564 "r: resume",
1565 "a: adjust-future",
1566 "i: ignore",
1567 "q: back",
1568 ] {
1569 assert!(
1570 CYCLE_SCHEDULE_KEYS.contains(expected),
1571 "the tab advertises `{expected}`: {CYCLE_SCHEDULE_KEYS}"
1572 );
1573 }
1574 }
1575
1576 #[test]
1577 fn the_cycle_help_rows_advertise_the_same_keys_as_the_hint() {
1578 let advertised: Vec<&str> = CYCLE_HELP_RAW.iter().map(|(key, _)| *key).collect();
1581 for key in ["tab / shift+tab", "n", "E", "c", "e", "s", "a"] {
1582 assert!(
1583 advertised.contains(&key),
1584 "the help names `{key}`: {advertised:?}"
1585 );
1586 }
1587 let actions: Vec<TuiAction> = CYCLE_HELP_ACTIONS.iter().map(|(a, _)| *a).collect();
1589 assert!(
1590 !actions.contains(&TuiAction::SeriesEnd),
1591 "`x` ends a series and means nothing here"
1592 );
1593 assert!(
1594 !actions.contains(&TuiAction::SeriesDefer),
1595 "`d` defers a series' due date and means nothing here"
1596 );
1597 }
1598
1599 #[test]
1600 fn the_capacity_help_rows_advertise_the_same_keys_as_the_hint() {
1601 let actions: Vec<TuiAction> = CAPACITY_HELP_ACTIONS.iter().map(|(a, _)| *a).collect();
1605 for action in [
1606 TuiAction::MoveUp,
1607 TuiAction::MoveDown,
1608 TuiAction::Help,
1609 TuiAction::Close,
1610 ] {
1611 assert!(
1612 actions.contains(&action),
1613 "the help names {action:?}: {actions:?}"
1614 );
1615 }
1616 for absent in [
1617 TuiAction::SeriesGenerate,
1618 TuiAction::SeriesPause,
1619 TuiAction::SeriesEnd,
1620 ] {
1621 assert!(
1622 !actions.contains(&absent),
1623 "{absent:?} acts on nothing here"
1624 );
1625 }
1626 assert!(CAPACITY_KEYS.contains("j/k"), "{CAPACITY_KEYS}");
1627 assert!(CAPACITY_KEYS.contains("Tab: tabs"), "{CAPACITY_KEYS}");
1628 assert!(CAPACITY_KEYS.contains("q: back"), "{CAPACITY_KEYS}");
1629 assert_eq!(
1630 CAPACITY_HELP_RAW
1631 .iter()
1632 .map(|(key, _)| *key)
1633 .collect::<Vec<_>>(),
1634 vec!["tab / shift+tab"]
1635 );
1636 }
1637
1638 #[test]
1639 fn the_create_form_shows_every_field_and_the_edit_form_every_but_the_id() {
1640 let create = ScheduleForm::create();
1641 assert_eq!(create.fields, ScheduleField::ALL);
1642 assert_eq!(create.focused(), ScheduleField::Id);
1643 assert!(create.title().contains("New cycle schedule"));
1644
1645 let edit = ScheduleForm::edit("cs-x".to_string(), vec!["Name".to_string()]);
1646 assert_eq!(edit.fields, ScheduleField::EDIT);
1647 assert!(
1648 !edit.fields.contains(&ScheduleField::Id),
1649 "the identity is the registry key and does not change"
1650 );
1651 assert_eq!(edit.value(ScheduleField::Name), "Name");
1652 assert_eq!(
1653 edit.value(ScheduleField::Id),
1654 "",
1655 "not shown by the edit form"
1656 );
1657 assert_eq!(
1658 edit.value(ScheduleField::StartDate),
1659 "",
1660 "a missing value is empty"
1661 );
1662 assert!(edit.title().contains("cs-x"));
1663 }
1664
1665 #[test]
1666 fn the_form_focus_wraps_and_typing_lands_in_the_focused_field() {
1667 let mut form = ScheduleForm::create();
1668 form.insert_char('c');
1669 form.insert_char('s');
1670 assert_eq!(form.value(ScheduleField::Id), "cs");
1671 form.focus_next();
1672 assert_eq!(form.focused(), ScheduleField::Name);
1673 form.insert_char('X');
1674 assert_eq!(form.value(ScheduleField::Name), "X");
1675 form.delete_char();
1676 assert_eq!(form.value(ScheduleField::Name), "");
1677 form.focus_prev();
1678 assert_eq!(form.focused(), ScheduleField::Id, "backward wraps too");
1679 for _ in 0..ScheduleField::ALL.len() {
1681 form.focus_next();
1682 }
1683 assert_eq!(form.focused(), ScheduleField::Id);
1684
1685 form.set_error("nope".to_string());
1687 assert_eq!(form.error.as_deref(), Some("nope"));
1688 }
1689
1690 #[test]
1691 fn the_series_create_form_asks_for_the_three_required_fields() {
1692 let form = SeriesForm::create();
1693 assert_eq!(form.fields, SeriesField::ALL);
1694 assert_eq!(
1695 form.fields,
1696 [SeriesField::Title, SeriesField::Rule, SeriesField::Anchor],
1697 "the first-run path is title, rule, anchor — the three the CLI requires"
1698 );
1699 assert_eq!(form.focused(), SeriesField::Title);
1700 for field in SeriesField::ALL {
1702 assert!(!field.label().is_empty(), "{field:?} has a label");
1703 assert!(!field.hint().is_empty(), "{field:?} has a hint");
1704 }
1705 assert_eq!(
1706 SeriesField::Rule.hint(),
1707 "daily | weekly | monthly",
1708 "the rule hint names only the families the form's three fields can complete"
1709 );
1710 assert!(
1711 SeriesForm::NOTE.contains("`interval`")
1712 && SeriesForm::NOTE.contains("`cron-like`")
1713 && SeriesForm::NOTE.contains("tix recurring create"),
1714 "the families the form cannot complete are named as the CLI's: {}",
1715 SeriesForm::NOTE
1716 );
1717 assert_eq!(SeriesField::Anchor.hint(), "YYYY-MM-DD");
1718 assert!(form.title().contains("New recurring series"));
1719 }
1720
1721 #[test]
1722 fn the_series_form_focus_wraps_and_typing_lands_in_the_focused_field() {
1723 let mut form = SeriesForm::create();
1724 for c in "Nightly".chars() {
1725 form.insert_char(c);
1726 }
1727 assert_eq!(form.value(SeriesField::Title), "Nightly");
1728 form.focus_next();
1729 assert_eq!(form.focused(), SeriesField::Rule);
1730 for c in "daily".chars() {
1731 form.insert_char(c);
1732 }
1733 assert_eq!(form.value(SeriesField::Rule), "daily");
1734 form.focus_next();
1735 form.insert_char('X');
1736 assert_eq!(form.value(SeriesField::Anchor), "X");
1737 form.delete_char();
1738 assert_eq!(form.value(SeriesField::Anchor), "");
1739 form.clear_field();
1740 assert_eq!(form.value(SeriesField::Anchor), "");
1741 form.focus_prev();
1742 assert_eq!(form.focused(), SeriesField::Rule, "backward wraps too");
1743 form.focus_next(); form.focus_next(); assert_eq!(
1747 form.focused(),
1748 SeriesField::Title,
1749 "forward wraps to the first field"
1750 );
1751 for _ in 0..SeriesField::ALL.len() {
1752 form.focus_next();
1753 }
1754 assert_eq!(form.focused(), SeriesField::Title);
1755
1756 form.set_error("refused".to_string());
1757 assert_eq!(form.error.as_deref(), Some("refused"));
1758 assert_eq!(form.value(SeriesField::Title), "Nightly");
1760 }
1761}