use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use crate::app::App;
use crate::cycle_schedule::config::CycleScheduleId;
use crate::cycle_schedule::identity::GeneratedCycleId;
use crate::cycle_schedule::lifecycle::LifecycleAction;
use crate::keybindings::TuiAction;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum ScheduleTab {
#[default]
Series,
Cycles,
Capacity,
}
impl ScheduleTab {
pub const ALL: [Self; 3] = [Self::Series, Self::Cycles, Self::Capacity];
pub const fn label(self) -> &'static str {
match self {
Self::Series => "Series",
Self::Cycles => "Cycle schedules",
Self::Capacity => "Capacity",
}
}
pub const fn next(self) -> Self {
match self {
Self::Series => Self::Cycles,
Self::Cycles => Self::Capacity,
Self::Capacity => Self::Series,
}
}
pub const fn prev(self) -> Self {
match self {
Self::Series => Self::Capacity,
Self::Cycles => Self::Series,
Self::Capacity => Self::Cycles,
}
}
pub const fn hint(self) -> &'static str {
match self {
Self::Series => SERIES_KEYS,
Self::Cycles => CYCLE_SCHEDULE_KEYS,
Self::Capacity => CAPACITY_KEYS,
}
}
pub const fn empty_hint(self) -> &'static str {
match self {
Self::Series => {
"No recurring series yet. Create one with \
`tix recurring create --title <TITLE> --rule daily --anchor <DATE>`, \
or press `n` to create one here."
}
Self::Cycles => {
"No cycle schedules yet. Create one with `tix cycle schedule create --id <ID> \
--name <NAME> --cadence <KIND> --duration <N> --duration-unit <UNIT> \
--start-date <DATE> --timezone <TZ> --confirm`."
}
Self::Capacity => {
"No capacity configured. Configure one with `tix capacity set --scope \
<cycle|team|assignee> --id <REFERENCE> --value <N> --unit <UNIT>`."
}
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScheduleState {
pub tab: ScheduleTab,
pub selected: usize,
pub prompt: Option<CycleReasonPrompt>,
pub form: Option<ScheduleForm>,
pub series_form: Option<SeriesForm>,
}
impl ScheduleState {
pub fn new() -> Self {
Self::default()
}
pub fn move_selected(&mut self, delta: isize, rows: usize) -> bool {
let last = rows.saturating_sub(1);
let before = self.selected;
let next = if delta.is_negative() {
self.selected.saturating_sub(delta.unsigned_abs())
} else {
self.selected.saturating_add(delta as usize)
};
self.selected = next.min(last);
self.selected != before
}
pub fn clamp_selected(&mut self, rows: usize) {
self.selected = self.selected.min(rows.saturating_sub(1));
}
pub fn switch_tab(&mut self, delta: isize) {
self.tab = if delta.is_negative() {
self.tab.prev()
} else {
self.tab.next()
};
self.selected = 0;
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScheduleEntry {
pub title: String,
pub subtitle: String,
pub due: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CycleScheduleEntry {
pub title: String,
pub subtitle: String,
pub warned: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScheduleDetail {
pub heading: String,
pub lines: Vec<(String, String)>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CycleScheduleDetail {
pub fields: Vec<(String, String)>,
pub cycles: Vec<String>,
pub snapshots: Vec<String>,
pub findings: Vec<String>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CapacityRow {
pub title: String,
pub subtitle: String,
pub warned: bool,
pub overflowing: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CapacitySnapshotView {
pub label: String,
pub fields: Vec<(String, String)>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CapacityDetail {
pub heading: String,
pub fields: Vec<(String, String)>,
pub diagnostics: Vec<String>,
pub snapshot: Option<CapacitySnapshotView>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CapacityView {
pub rows: Vec<CapacityRow>,
pub details: Vec<CapacityDetail>,
pub summaries: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CycleReasonPrompt {
pub schedule: CycleScheduleId,
pub action: LifecycleAction,
pub cycle: Option<GeneratedCycleId>,
pub buffer: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ScheduleField {
Id,
Name,
Cadence,
Interval,
Unit,
Duration,
DurationUnit,
StartDate,
Timezone,
LookAhead,
}
impl ScheduleField {
pub const ALL: [Self; 10] = [
Self::Id,
Self::Name,
Self::Cadence,
Self::Interval,
Self::Unit,
Self::Duration,
Self::DurationUnit,
Self::StartDate,
Self::Timezone,
Self::LookAhead,
];
pub const EDIT: [Self; 9] = [
Self::Name,
Self::Cadence,
Self::Interval,
Self::Unit,
Self::Duration,
Self::DurationUnit,
Self::StartDate,
Self::Timezone,
Self::LookAhead,
];
pub const fn label(self) -> &'static str {
match self {
Self::Id => "id",
Self::Name => "name",
Self::Cadence => "cadence",
Self::Interval => "interval",
Self::Unit => "unit",
Self::Duration => "duration",
Self::DurationUnit => "duration-unit",
Self::StartDate => "start-date",
Self::Timezone => "timezone",
Self::LookAhead => "look-ahead",
}
}
pub const fn hint(self) -> &'static str {
match self {
Self::Id => "stable, never reused",
Self::Name => "display label",
Self::Cadence => "daily | weekly | monthly | interval",
Self::Interval => "1-1000 (interval cadence)",
Self::Unit => "days | weeks | months",
Self::Duration => "positive count",
Self::DurationUnit => "days | weeks | months",
Self::StartDate => "YYYY-MM-DD",
Self::Timezone => "UTC or ±HH:MM",
Self::LookAhead => "future cycles to consider",
}
}
pub const fn is_date(self) -> bool {
matches!(self, Self::StartDate)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScheduleForm {
pub editing: Option<String>,
pub fields: Vec<ScheduleField>,
pub values: Vec<String>,
pub focus: usize,
pub error: Option<String>,
}
impl ScheduleForm {
pub fn create() -> Self {
let fields = ScheduleField::ALL.to_vec();
let values = vec![String::new(); fields.len()];
Self {
editing: None,
fields,
values,
focus: 0,
error: None,
}
}
pub fn edit(id: String, values: Vec<String>) -> Self {
let fields = ScheduleField::EDIT.to_vec();
let mut filled = values;
filled.resize(fields.len(), String::new());
Self {
editing: Some(id),
fields,
values: filled,
focus: 0,
error: None,
}
}
pub fn title(&self) -> String {
match &self.editing {
Some(id) => format!("Edit cycle schedule `{id}` (enter: save, esc: cancel)"),
None => "New cycle schedule (enter: save, esc: cancel)".to_string(),
}
}
pub fn focused(&self) -> ScheduleField {
self.fields
.get(self.focus)
.copied()
.unwrap_or(ScheduleField::Id)
}
pub fn value(&self, field: ScheduleField) -> &str {
self.fields
.iter()
.position(|shown| *shown == field)
.and_then(|index| self.values.get(index))
.map(String::as_str)
.unwrap_or("")
}
pub fn focus_next(&mut self) {
let len = self.fields.len().max(1);
self.focus = (self.focus + 1) % len;
}
pub fn focus_prev(&mut self) {
let len = self.fields.len().max(1);
self.focus = (self.focus + len - 1) % len;
}
pub fn insert_char(&mut self, c: char) {
let focus = self.focus;
if let Some(value) = self.values.get_mut(focus) {
value.push(c);
}
}
pub fn delete_char(&mut self) {
let focus = self.focus;
if let Some(value) = self.values.get_mut(focus) {
value.pop();
}
}
pub fn clear_field(&mut self) {
let focus = self.focus;
if let Some(value) = self.values.get_mut(focus) {
value.clear();
}
}
pub fn set_error(&mut self, message: String) {
self.error = Some(message);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SeriesField {
Title,
Rule,
Anchor,
}
impl SeriesField {
pub const ALL: [Self; 3] = [Self::Title, Self::Rule, Self::Anchor];
pub const fn label(self) -> &'static str {
match self {
Self::Title => "title",
Self::Rule => "rule",
Self::Anchor => "anchor",
}
}
pub const fn hint(self) -> &'static str {
match self {
Self::Title => "issue title (required)",
Self::Rule => "daily | weekly | monthly",
Self::Anchor => "YYYY-MM-DD",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SeriesForm {
pub fields: Vec<SeriesField>,
pub values: Vec<String>,
pub focus: usize,
pub error: Option<String>,
}
impl SeriesForm {
pub const NOTE: &'static str = "`interval` and `cron-like` rules need the CLI: \
`tix recurring create --rule <FAMILY> --interval \
<N> --unit <UNIT>`.";
pub fn create() -> Self {
Self {
fields: SeriesField::ALL.to_vec(),
values: vec![String::new(); SeriesField::ALL.len()],
focus: 0,
error: None,
}
}
pub fn title(&self) -> &'static str {
"New recurring series (enter: save, esc: cancel)"
}
pub fn focused(&self) -> SeriesField {
self.fields
.get(self.focus)
.copied()
.unwrap_or(SeriesField::Title)
}
pub fn value(&self, field: SeriesField) -> &str {
self.fields
.iter()
.position(|shown| *shown == field)
.and_then(|index| self.values.get(index))
.map(String::as_str)
.unwrap_or("")
}
pub fn focus_next(&mut self) {
let len = self.fields.len().max(1);
self.focus = (self.focus + 1) % len;
}
pub fn focus_prev(&mut self) {
let len = self.fields.len().max(1);
self.focus = (self.focus + len - 1) % len;
}
pub fn insert_char(&mut self, c: char) {
let focus = self.focus;
if let Some(value) = self.values.get_mut(focus) {
value.push(c);
}
}
pub fn delete_char(&mut self) {
let focus = self.focus;
if let Some(value) = self.values.get_mut(focus) {
value.pop();
}
}
pub fn clear_field(&mut self) {
let focus = self.focus;
if let Some(value) = self.values.get_mut(focus) {
value.clear();
}
}
pub fn set_error(&mut self, message: String) {
self.error = Some(message);
}
}
pub const CYCLE_HELP_ACTIONS: &[(TuiAction, &str)] = &[
(TuiAction::MoveUp, "move the cursor up"),
(TuiAction::MoveDown, "move the cursor down"),
(
TuiAction::SeriesGenerate,
"generate the schedule's look-ahead",
),
(TuiAction::SeriesSkip, "skip the current cycle"),
(TuiAction::SeriesPause, "pause the schedule"),
(TuiAction::SeriesResume, "resume the schedule"),
(
TuiAction::SeriesIgnore,
"close a warning without deciding anything",
),
(TuiAction::Help, "this help"),
(TuiAction::Close, "back to the board"),
];
pub const CYCLE_HELP_RAW: &[(&str, &str)] = &[
("tab / shift+tab", "switch tabs"),
("n", "define a new schedule"),
("E", "edit the selected schedule"),
("c", "close the current cycle (asks why)"),
("R", "reopen the closed cycle (asks why)"),
("e", "extend the current cycle (asks why)"),
("s", "skip the current cycle (asks why)"),
("a", "re-derive future cycles (asks why)"),
];
pub const SERIES_KEYS: &str = "j/k: move n: new g: generate p: pause r: resume x: end \
s: skip d: defer i: ignore q: back to the board";
pub const SCHEDULE_KEYS: &str = SERIES_KEYS;
pub const CYCLE_SCHEDULE_KEYS: &str = "j/k: move Tab: tabs n: new E: edit g: generate \
c: close R: reopen e: extend s: skip p: pause \
r: resume a: adjust-future i: ignore q: back to the board";
pub const CAPACITY_KEYS: &str = "j/k: move Tab: tabs q: back to the board";
pub const CAPACITY_HELP_ACTIONS: &[(TuiAction, &str)] = &[
(TuiAction::MoveUp, "move the cursor up"),
(TuiAction::MoveDown, "move the cursor down"),
(TuiAction::Help, "this help"),
(TuiAction::Close, "back to the board"),
];
pub const CAPACITY_HELP_RAW: &[(&str, &str)] = &[("tab / shift+tab", "switch tabs")];
fn dim() -> Style {
Style::default().fg(Color::DarkGray)
}
fn tab_line(active: ScheduleTab) -> Line<'static> {
let mut spans = vec![
Span::styled("F8 Schedule", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" "),
];
for (index, tab) in ScheduleTab::ALL.iter().enumerate() {
if index > 0 {
spans.push(Span::raw(" "));
}
let style = if *tab == active {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
dim()
};
spans.push(Span::styled(format!("[{}]", tab.label()), style));
}
Line::from(spans)
}
pub fn tab_rects(area: Rect) -> Vec<(ScheduleTab, Rect)> {
use unicode_width::UnicodeWidthStr;
let mut x = area.x + UnicodeWidthStr::width("F8 Schedule") as u16 + 2;
let mut rects = Vec::with_capacity(ScheduleTab::ALL.len());
for (index, tab) in ScheduleTab::ALL.iter().enumerate() {
if index > 0 {
x += 2;
}
let label = format!("[{}]", tab.label());
let width = UnicodeWidthStr::width(label.as_str()) as u16;
rects.push((*tab, Rect::new(x, area.y, width, 1)));
x += width;
}
rects
}
fn prompt_line(prompt: &CycleReasonPrompt) -> Line<'static> {
let mut spans = vec![Span::styled(
prompt.action.as_str().to_string(),
Style::default().add_modifier(Modifier::BOLD),
)];
if let Some(cycle) = &prompt.cycle {
spans.push(Span::raw(format!(" {cycle}")));
}
spans.push(Span::styled(" reason> ", dim()));
spans.push(Span::raw(prompt.buffer.clone()));
spans.push(Span::styled(
" (enter: apply esc: cancel, nothing written)",
dim(),
));
Line::from(spans)
}
pub fn render(app: &App, f: &mut Frame, area: Rect) {
let state = app.schedule_state();
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.split(area);
f.render_widget(Paragraph::new(tab_line(state.tab)), rows[0]);
match state.prompt.as_ref() {
Some(prompt) => f.render_widget(Paragraph::new(prompt_line(prompt)), rows[1]),
None if app.status_msg.is_some() => {
let message = app.status_msg.as_deref().unwrap_or_default();
f.render_widget(
Paragraph::new(Span::styled(message, Style::default())),
rows[1],
);
}
None => f.render_widget(
Paragraph::new(Span::styled(state.tab.hint(), dim())),
rows[1],
),
}
if let Some(form) = state.series_form.as_ref() {
render_series_form(form, f, rows[2]);
return;
}
if let Some(form) = state.form.as_ref() {
render_form(form, f, rows[2]);
return;
}
match state.tab {
ScheduleTab::Series => render_series(app, f, rows[2], &state),
ScheduleTab::Cycles => render_cycles(app, f, rows[2], &state),
ScheduleTab::Capacity => render_capacity(app, f, rows[2], &state),
}
}
fn render_form(form: &ScheduleForm, f: &mut Frame, area: Rect) {
let rows: Vec<(String, String, String)> = form
.fields
.iter()
.map(|field| {
(
field.label().to_string(),
form.value(*field).to_string(),
field.hint().to_string(),
)
})
.collect();
render_form_fields(
f,
area,
&form.title(),
&rows,
form.focus,
form.error.as_deref(),
None,
);
}
fn render_series_form(form: &SeriesForm, f: &mut Frame, area: Rect) {
let rows: Vec<(String, String, String)> = form
.fields
.iter()
.map(|field| {
(
field.label().to_string(),
form.value(*field).to_string(),
field.hint().to_string(),
)
})
.collect();
render_form_fields(
f,
area,
form.title(),
&rows,
form.focus,
form.error.as_deref(),
Some(SeriesForm::NOTE),
);
}
fn render_form_fields(
f: &mut Frame,
area: Rect,
title: &str,
rows: &[(String, String, String)],
focus: usize,
error: Option<&str>,
note: Option<&str>,
) {
let block = Block::default()
.borders(Borders::ALL)
.title(title.to_string());
let inner = block.inner(area);
f.render_widget(block, area);
let mut lines: Vec<Line<'static>> = Vec::new();
for (index, (label, value, hint)) in rows.iter().enumerate() {
let focused = index == focus;
let mut spans = vec![Span::raw(if focused { "> " } else { " " })];
spans.push(Span::styled(
format!("{label}: "),
if focused {
Style::default().add_modifier(Modifier::BOLD)
} else {
dim()
},
));
spans.push(Span::raw(value.clone()));
if focused {
spans.push(Span::styled("▏", Style::default().fg(Color::Cyan)));
}
spans.push(Span::styled(format!(" ({hint})"), dim()));
lines.push(Line::from(spans));
}
lines.push(Line::from(""));
if let Some(note) = note {
lines.push(Line::from(Span::styled(note.to_string(), dim())));
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled(
"tab / shift+tab: field enter: save esc: cancel, nothing written",
dim(),
)));
if let Some(error) = error {
lines.push(Line::from(""));
for line in error.lines() {
lines.push(Line::from(Span::styled(
line.to_string(),
Style::default().fg(Color::Red),
)));
}
}
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn render_series(app: &App, f: &mut Frame, area: Rect, state: &ScheduleState) {
let entries = app.schedule_entries();
let detail = app.schedule_detail(state.selected);
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(45), Constraint::Min(24)])
.split(area);
let list = Block::default()
.borders(Borders::ALL)
.title(format!("Series ({})", entries.len()));
if entries.is_empty() {
f.render_widget(
Paragraph::new(ScheduleTab::Series.empty_hint())
.block(list)
.wrap(Wrap { trim: true })
.style(dim()),
body[0],
);
} else {
let mut lines: Vec<Line<'static>> = Vec::new();
for (index, entry) in entries.iter().enumerate() {
let selected = index == state.selected;
let mut spans = vec![Span::raw(if selected { "> " } else { " " })];
spans.push(Span::styled(
entry.title.clone(),
if selected {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
},
));
if entry.due {
spans.push(Span::styled(" [due]", Style::default().fg(Color::Cyan)));
}
lines.push(Line::from(spans));
lines.push(Line::from(Span::styled(
format!(" {}", entry.subtitle),
dim(),
)));
}
f.render_widget(Paragraph::new(lines).block(list), body[0]);
}
let block = Block::default()
.borders(Borders::ALL)
.title("Series detail");
match detail.as_ref() {
Some(detail) => {
let lines: Vec<Line<'static>> = detail
.lines
.iter()
.map(|(key, value)| {
Line::from(vec![
Span::styled(format!("{key}: "), dim()),
Span::raw(value.clone()),
])
})
.collect();
f.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
body[1],
);
}
None => f.render_widget(
Paragraph::new(Span::styled(
"Nothing selected: a series has to exist before the page can describe it.",
dim(),
))
.block(block)
.wrap(Wrap { trim: true }),
body[1],
),
}
}
fn render_cycles(app: &App, f: &mut Frame, area: Rect, state: &ScheduleState) {
let entries = app.cycle_schedule_entries();
let detail = app.cycle_schedule_detail(state.selected);
let unattached = app.cycle_schedule_unattached_findings();
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(45), Constraint::Min(24)])
.split(area);
if unattached.is_empty() {
render_cycle_list(f, body[0], &entries, state.selected);
} else {
let height = (unattached.len() as u16 * 3 + 2).min(12);
let columns = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(height)])
.split(body[0]);
render_cycle_list(f, columns[0], &entries, state.selected);
let findings: Vec<Line<'static>> = unattached
.iter()
.map(|line| Line::from(Span::styled(line.clone(), dim())))
.collect();
f.render_widget(
Paragraph::new(findings)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!("Unattached findings ({})", unattached.len())),
)
.wrap(Wrap { trim: true }),
columns[1],
);
}
let block = Block::default()
.borders(Borders::ALL)
.title("Cycle schedule detail");
match detail.as_ref() {
Some(detail) => {
let mut lines: Vec<Line<'static>> = detail
.fields
.iter()
.map(|(key, value)| {
Line::from(vec![
Span::styled(format!("{key}: "), dim()),
Span::raw(value.clone()),
])
})
.collect();
section(&mut lines, "cycles", &detail.cycles);
section(&mut lines, "snapshots", &detail.snapshots);
section(&mut lines, "findings", &detail.findings);
section(&mut lines, "warnings", &detail.warnings);
f.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
body[1],
);
}
None => f.render_widget(
Paragraph::new(Span::styled(
"Nothing selected: a cycle schedule has to exist before the page can \
describe it.",
dim(),
))
.block(block)
.wrap(Wrap { trim: true }),
body[1],
),
}
}
fn render_cycle_list(f: &mut Frame, area: Rect, entries: &[CycleScheduleEntry], selected: usize) {
let block = Block::default()
.borders(Borders::ALL)
.title(format!("Cycle schedules ({})", entries.len()));
if entries.is_empty() {
f.render_widget(
Paragraph::new(ScheduleTab::Cycles.empty_hint())
.block(block)
.wrap(Wrap { trim: true })
.style(dim()),
area,
);
return;
}
let mut lines: Vec<Line<'static>> = Vec::new();
for (index, entry) in entries.iter().enumerate() {
let is_selected = index == selected;
let mut spans = vec![Span::raw(if is_selected { "> " } else { " " })];
spans.push(Span::styled(
entry.title.clone(),
if is_selected {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
},
));
if entry.warned {
spans.push(Span::styled(" [warn]", Style::default().fg(Color::Yellow)));
}
lines.push(Line::from(spans));
lines.push(Line::from(Span::styled(
format!(" {}", entry.subtitle),
dim(),
)));
}
f.render_widget(Paragraph::new(lines).block(block), area);
}
fn section(lines: &mut Vec<Line<'static>>, heading: &str, body: &[String]) {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!("{heading} ({}):", body.len()),
Style::default().add_modifier(Modifier::BOLD),
)));
if body.is_empty() {
lines.push(Line::from(Span::styled(" none", dim())));
return;
}
for line in body {
lines.push(Line::from(Span::raw(format!(" {line}"))));
}
}
fn render_capacity(app: &App, f: &mut Frame, area: Rect, state: &ScheduleState) {
let rows = app.capacity_rows();
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(45), Constraint::Min(24)])
.split(area);
let list = Block::default()
.borders(Borders::ALL)
.title(format!("Capacity ({})", rows.len()));
if !app.capacity_read() {
f.render_widget(
Paragraph::new(
"Could not read the capacity snapshot for this project, so no capacity view \
is available.",
)
.block(list)
.wrap(Wrap { trim: true })
.style(dim()),
body[0],
);
} else if rows.is_empty() {
f.render_widget(
Paragraph::new(ScheduleTab::Capacity.empty_hint())
.block(list)
.wrap(Wrap { trim: true })
.style(dim()),
body[0],
);
} else {
let mut lines: Vec<Line<'static>> = Vec::new();
for (index, row) in rows.iter().enumerate() {
let selected = index == state.selected;
let mut spans = vec![Span::raw(if selected { "> " } else { " " })];
spans.push(Span::styled(
row.title.clone(),
if selected {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
},
));
if row.overflowing {
spans.push(Span::styled(" [over]", Style::default().fg(Color::Red)));
} else if row.warned {
spans.push(Span::styled(" [warn]", Style::default().fg(Color::Yellow)));
}
lines.push(Line::from(spans));
lines.push(Line::from(Span::styled(
format!(" {}", row.subtitle),
dim(),
)));
}
if app.capacity_nothing_committed() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"no committed work in any configured scope yet",
dim(),
)));
}
f.render_widget(Paragraph::new(lines).block(list), body[0]);
}
let block = Block::default()
.borders(Borders::ALL)
.title("Capacity detail");
match app.capacity_detail(state.selected) {
Some(detail) => {
let mut lines: Vec<Line<'static>> = Vec::new();
for (key, value) in &detail.fields {
lines.push(Line::from(vec![
Span::styled(format!("{key}: "), dim()),
Span::raw(value.clone()),
]));
}
section(&mut lines, "findings", &detail.diagnostics);
if let Some(snapshot) = &detail.snapshot {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!("closed snapshot — {}", snapshot.label),
Style::default().add_modifier(Modifier::BOLD),
)));
for (key, value) in &snapshot.fields {
lines.push(Line::from(vec![
Span::styled(format!("{key}: "), dim()),
Span::raw(value.clone()),
]));
}
if snapshot.fields.is_empty() {
lines.push(Line::from(Span::styled(" none", dim())));
}
}
f.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
body[1],
);
}
None => {
let sentence = if app.capacity_read() {
"Nothing selected: a capacity scope has to be configured before the page \
can describe it."
} else {
"Could not read the capacity snapshot for this project, so no capacity \
view is available."
};
f.render_widget(
Paragraph::new(Span::styled(sentence, dim()))
.block(block)
.wrap(Wrap { trim: true }),
body[1],
);
}
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
#[test]
fn the_cursor_clamps_to_the_row_count() {
let mut state = ScheduleState::new();
assert!(state.move_selected(3, 5));
assert_eq!(state.selected, 3);
assert!(state.move_selected(9, 5), "clamped to the last row");
assert_eq!(state.selected, 4);
assert!(!state.move_selected(1, 5));
state.move_selected(-9, 5);
assert_eq!(state.selected, 0);
state.move_selected(1, 0);
assert_eq!(state.selected, 0, "an empty list keeps the cursor at zero");
}
#[test]
fn a_shrinking_registry_clamps_the_cursor() {
let mut state = ScheduleState {
selected: 4,
..Default::default()
};
state.clamp_selected(2);
assert_eq!(state.selected, 1);
state.clamp_selected(0);
assert_eq!(state.selected, 0);
}
#[test]
fn the_tab_strip_has_all_three_tabs_and_the_series_tab_is_the_default() {
assert_eq!(ScheduleTab::default(), ScheduleTab::Series);
assert_eq!(
ScheduleTab::ALL,
[
ScheduleTab::Series,
ScheduleTab::Cycles,
ScheduleTab::Capacity
]
);
assert_eq!(ScheduleTab::Series.label(), "Series");
assert_eq!(ScheduleTab::Cycles.label(), "Cycle schedules");
assert_eq!(ScheduleTab::Capacity.label(), "Capacity");
let drawn: String = tab_line(ScheduleTab::Capacity)
.spans
.iter()
.map(|span| span.content.clone())
.collect();
assert!(drawn.contains("[Series]"), "{drawn}");
assert!(drawn.contains("[Cycle schedules]"), "{drawn}");
assert!(drawn.contains("[Capacity]"), "{drawn}");
assert!(drawn.contains("F8 Schedule"), "{drawn}");
}
#[test]
fn the_tab_strip_geometry_matches_the_drawn_line() {
let area = Rect::new(3, 7, 80, 24);
let drawn: Vec<String> = tab_line(ScheduleTab::Series)
.spans
.iter()
.map(|span| span.content.to_string())
.collect();
let line = drawn.concat();
let rects = tab_rects(area);
assert_eq!(rects.len(), ScheduleTab::ALL.len());
for (tab, rect) in rects {
assert_eq!(rect.y, area.y, "the strip is one row at the top");
assert_eq!(rect.height, 1);
let start = usize::from(rect.x - area.x);
let end = start + usize::from(rect.width);
assert_eq!(
&line[start..end],
format!("[{}]", tab.label()),
"the rect addresses {tab:?}'s own label"
);
}
}
#[test]
fn switching_tabs_wraps_and_resets_the_row_cursor() {
let mut state = ScheduleState {
selected: 3,
..Default::default()
};
state.switch_tab(1);
assert_eq!(state.tab, ScheduleTab::Cycles);
assert_eq!(
state.selected, 0,
"the cursor indexes the active tab's rows"
);
state.switch_tab(1);
assert_eq!(state.tab, ScheduleTab::Capacity);
state.switch_tab(1);
assert_eq!(state.tab, ScheduleTab::Series, "`Tab` wraps");
state.switch_tab(-1);
assert_eq!(state.tab, ScheduleTab::Capacity, "`Shift+Tab` steps back");
}
#[test]
fn every_tab_has_an_explicit_empty_state() {
for tab in ScheduleTab::ALL {
assert!(
tab.empty_hint().contains("No ") || tab.empty_hint().contains("yet"),
"{tab:?} says what is missing: {}",
tab.empty_hint()
);
}
}
#[test]
fn the_series_empty_state_keeps_the_cli_hint_and_adds_the_tui_offer() {
let hint = ScheduleTab::Series.empty_hint();
assert!(
hint.contains(
"Create one with `tix recurring create --title <TITLE> --rule daily \
--anchor <DATE>`"
),
"the CLI hint survives verbatim: {hint}"
);
assert!(
hint.contains("press `n` to create one here"),
"and the TUI offers its own key: {hint}"
);
}
#[test]
fn the_hint_line_advertises_every_key_the_series_tab_handles() {
for expected in [
"j/k",
"n: new",
"g: generate",
"p: pause",
"r: resume",
"x: end",
"s: skip",
"d: defer",
"i: ignore",
"q: back",
] {
assert!(
SERIES_KEYS.contains(expected),
"the page advertises `{expected}`: {SERIES_KEYS}"
);
}
}
#[test]
fn the_cycle_tab_hint_advertises_every_key_it_handles() {
for expected in [
"j/k",
"Tab: tabs",
"n: new",
"E: edit",
"g: generate",
"c: close",
"e: extend",
"s: skip",
"p: pause",
"r: resume",
"a: adjust-future",
"i: ignore",
"q: back",
] {
assert!(
CYCLE_SCHEDULE_KEYS.contains(expected),
"the tab advertises `{expected}`: {CYCLE_SCHEDULE_KEYS}"
);
}
}
#[test]
fn the_cycle_help_rows_advertise_the_same_keys_as_the_hint() {
let advertised: Vec<&str> = CYCLE_HELP_RAW.iter().map(|(key, _)| *key).collect();
for key in ["tab / shift+tab", "n", "E", "c", "e", "s", "a"] {
assert!(
advertised.contains(&key),
"the help names `{key}`: {advertised:?}"
);
}
let actions: Vec<TuiAction> = CYCLE_HELP_ACTIONS.iter().map(|(a, _)| *a).collect();
assert!(
!actions.contains(&TuiAction::SeriesEnd),
"`x` ends a series and means nothing here"
);
assert!(
!actions.contains(&TuiAction::SeriesDefer),
"`d` defers a series' due date and means nothing here"
);
}
#[test]
fn the_capacity_help_rows_advertise_the_same_keys_as_the_hint() {
let actions: Vec<TuiAction> = CAPACITY_HELP_ACTIONS.iter().map(|(a, _)| *a).collect();
for action in [
TuiAction::MoveUp,
TuiAction::MoveDown,
TuiAction::Help,
TuiAction::Close,
] {
assert!(
actions.contains(&action),
"the help names {action:?}: {actions:?}"
);
}
for absent in [
TuiAction::SeriesGenerate,
TuiAction::SeriesPause,
TuiAction::SeriesEnd,
] {
assert!(
!actions.contains(&absent),
"{absent:?} acts on nothing here"
);
}
assert!(CAPACITY_KEYS.contains("j/k"), "{CAPACITY_KEYS}");
assert!(CAPACITY_KEYS.contains("Tab: tabs"), "{CAPACITY_KEYS}");
assert!(CAPACITY_KEYS.contains("q: back"), "{CAPACITY_KEYS}");
assert_eq!(
CAPACITY_HELP_RAW
.iter()
.map(|(key, _)| *key)
.collect::<Vec<_>>(),
vec!["tab / shift+tab"]
);
}
#[test]
fn the_create_form_shows_every_field_and_the_edit_form_every_but_the_id() {
let create = ScheduleForm::create();
assert_eq!(create.fields, ScheduleField::ALL);
assert_eq!(create.focused(), ScheduleField::Id);
assert!(create.title().contains("New cycle schedule"));
let edit = ScheduleForm::edit("cs-x".to_string(), vec!["Name".to_string()]);
assert_eq!(edit.fields, ScheduleField::EDIT);
assert!(
!edit.fields.contains(&ScheduleField::Id),
"the identity is the registry key and does not change"
);
assert_eq!(edit.value(ScheduleField::Name), "Name");
assert_eq!(
edit.value(ScheduleField::Id),
"",
"not shown by the edit form"
);
assert_eq!(
edit.value(ScheduleField::StartDate),
"",
"a missing value is empty"
);
assert!(edit.title().contains("cs-x"));
}
#[test]
fn the_form_focus_wraps_and_typing_lands_in_the_focused_field() {
let mut form = ScheduleForm::create();
form.insert_char('c');
form.insert_char('s');
assert_eq!(form.value(ScheduleField::Id), "cs");
form.focus_next();
assert_eq!(form.focused(), ScheduleField::Name);
form.insert_char('X');
assert_eq!(form.value(ScheduleField::Name), "X");
form.delete_char();
assert_eq!(form.value(ScheduleField::Name), "");
form.focus_prev();
assert_eq!(form.focused(), ScheduleField::Id, "backward wraps too");
for _ in 0..ScheduleField::ALL.len() {
form.focus_next();
}
assert_eq!(form.focused(), ScheduleField::Id);
form.set_error("nope".to_string());
assert_eq!(form.error.as_deref(), Some("nope"));
}
#[test]
fn the_series_create_form_asks_for_the_three_required_fields() {
let form = SeriesForm::create();
assert_eq!(form.fields, SeriesField::ALL);
assert_eq!(
form.fields,
[SeriesField::Title, SeriesField::Rule, SeriesField::Anchor],
"the first-run path is title, rule, anchor — the three the CLI requires"
);
assert_eq!(form.focused(), SeriesField::Title);
for field in SeriesField::ALL {
assert!(!field.label().is_empty(), "{field:?} has a label");
assert!(!field.hint().is_empty(), "{field:?} has a hint");
}
assert_eq!(
SeriesField::Rule.hint(),
"daily | weekly | monthly",
"the rule hint names only the families the form's three fields can complete"
);
assert!(
SeriesForm::NOTE.contains("`interval`")
&& SeriesForm::NOTE.contains("`cron-like`")
&& SeriesForm::NOTE.contains("tix recurring create"),
"the families the form cannot complete are named as the CLI's: {}",
SeriesForm::NOTE
);
assert_eq!(SeriesField::Anchor.hint(), "YYYY-MM-DD");
assert!(form.title().contains("New recurring series"));
}
#[test]
fn the_series_form_focus_wraps_and_typing_lands_in_the_focused_field() {
let mut form = SeriesForm::create();
for c in "Nightly".chars() {
form.insert_char(c);
}
assert_eq!(form.value(SeriesField::Title), "Nightly");
form.focus_next();
assert_eq!(form.focused(), SeriesField::Rule);
for c in "daily".chars() {
form.insert_char(c);
}
assert_eq!(form.value(SeriesField::Rule), "daily");
form.focus_next();
form.insert_char('X');
assert_eq!(form.value(SeriesField::Anchor), "X");
form.delete_char();
assert_eq!(form.value(SeriesField::Anchor), "");
form.clear_field();
assert_eq!(form.value(SeriesField::Anchor), "");
form.focus_prev();
assert_eq!(form.focused(), SeriesField::Rule, "backward wraps too");
form.focus_next(); form.focus_next(); assert_eq!(
form.focused(),
SeriesField::Title,
"forward wraps to the first field"
);
for _ in 0..SeriesField::ALL.len() {
form.focus_next();
}
assert_eq!(form.focused(), SeriesField::Title);
form.set_error("refused".to_string());
assert_eq!(form.error.as_deref(), Some("refused"));
assert_eq!(form.value(SeriesField::Title), "Nightly");
}
}