1use ratatui::crossterm::event::{KeyCode, KeyEvent};
9use ratatui::layout::{Constraint, Layout};
10use ratatui::style::Modifier;
11use ratatui::text::{Line, Span};
12use ratatui::widgets::{Cell, Paragraph, Row, Table};
13use ratatui::Frame;
14
15use super::ui::{header, help_line, input_line, panel, title_bar, Screen, Tui, DIM, HIGHLIGHT};
16
17#[derive(Debug, Clone, PartialEq)]
19pub enum Setting {
20 Flag(bool),
21 Number(Option<f64>),
23}
24
25impl Setting {
26 pub fn flag(&self) -> bool {
27 matches!(self, Setting::Flag(true))
28 }
29
30 pub fn number(&self) -> Option<f64> {
31 match self {
32 Setting::Number(v) => *v,
33 Setting::Flag(_) => None,
34 }
35 }
36
37 fn shown(&self) -> String {
38 match self {
39 Setting::Flag(true) => "on".into(),
40 Setting::Flag(false) => "off".into(),
41 Setting::Number(Some(v)) => short(*v),
42 Setting::Number(None) => "unset".into(),
43 }
44 }
45}
46
47fn short(v: f64) -> String {
49 if v.fract() == 0.0 && v.abs() < 1e15 {
50 format!("{}", v as i64)
51 } else {
52 format!("{v}")
53 }
54}
55
56#[derive(Debug, Clone)]
58pub struct Control {
59 pub key: char,
60 pub label: &'static str,
61 pub value: Setting,
62}
63
64impl Control {
65 pub fn flag(key: char, label: &'static str, on: bool) -> Self {
66 Self {
67 key,
68 label,
69 value: Setting::Flag(on),
70 }
71 }
72
73 pub fn number(key: char, label: &'static str, value: Option<f64>) -> Self {
74 Self {
75 key,
76 label,
77 value: Setting::Number(value),
78 }
79 }
80}
81
82#[derive(Debug, Clone, Default, PartialEq)]
84pub struct Plan {
85 pub columns: Vec<String>,
87 pub rows: Vec<Vec<String>>,
88 pub notes: Vec<String>,
90 pub blocked: Option<String>,
92}
93
94pub type Planner<'a> = Box<dyn Fn(&[Setting]) -> anyhow::Result<Plan> + 'a>;
96
97#[derive(Debug, Clone, PartialEq)]
98enum Mode {
99 Browse,
100 Edit(usize, String),
102}
103
104pub struct PlanView<'a> {
106 badge: &'static str,
107 title: String,
108 verb: &'static str,
110 cancel: &'static str,
111 controls: Vec<Control>,
112 planner: Planner<'a>,
113 plan: Plan,
114 mode: Mode,
115 status: Option<String>,
116 decision: Option<bool>,
118 offset: usize,
119 session_keys: Vec<(String, String)>,
120}
121
122impl<'a> PlanView<'a> {
123 pub fn new(
124 badge: &'static str,
125 title: &str,
126 verb: &'static str,
127 controls: Vec<Control>,
128 planner: Planner<'a>,
129 ) -> Self {
130 debug_assert!(
131 controls.iter().all(|c| c.key != 'q'),
132 "q leaves the plan; no setting takes it"
133 );
134 let mut view = Self {
135 badge,
136 title: title.to_string(),
137 verb,
138 cancel: "cancel",
139 controls,
140 planner,
141 plan: Plan::default(),
142 mode: Mode::Browse,
143 status: None,
144 decision: None,
145 offset: 0,
146 session_keys: super::session_keys(),
147 };
148 view.replan();
149 view
150 }
151
152 pub fn cancel_label(mut self, label: &'static str) -> Self {
154 self.cancel = label;
155 self
156 }
157
158 fn settings(&self) -> Vec<Setting> {
159 self.controls.iter().map(|c| c.value.clone()).collect()
160 }
161
162 fn replan(&mut self) {
163 self.plan = match (self.planner)(&self.settings()) {
164 Ok(plan) => plan,
165 Err(e) => Plan {
166 blocked: Some(format!("{e:#}")),
167 ..Plan::default()
168 },
169 };
170 self.offset = 0;
171 }
172
173 pub fn decided(&self) -> Option<Vec<Setting>> {
175 (self.decision == Some(true)).then(|| self.settings())
176 }
177
178 fn browse_key(&mut self, key: KeyEvent) {
179 self.status = None;
180 if let KeyCode::Char(c) = key.code {
182 if let Some(i) = self.controls.iter().position(|k| k.key == c) {
183 match &self.controls[i].value {
184 Setting::Flag(on) => {
185 self.controls[i].value = Setting::Flag(!on);
186 self.replan();
187 }
188 Setting::Number(v) => {
189 let text = v.map(short).unwrap_or_default();
190 self.mode = Mode::Edit(i, text);
191 }
192 }
193 return;
194 }
195 }
196 match key.code {
197 KeyCode::Char('q') | KeyCode::Esc => self.decision = Some(false),
198 KeyCode::Enter => match &self.plan.blocked {
199 Some(why) => self.status = Some(format!("not yet: {why}")),
200 None => self.decision = Some(true),
201 },
202 KeyCode::Down => {
203 self.offset = (self.offset + 1).min(self.plan.rows.len().saturating_sub(1))
204 }
205 KeyCode::Up => self.offset = self.offset.saturating_sub(1),
206 _ => {}
207 }
208 }
209
210 fn edit_key(&mut self, key: KeyEvent) {
211 let Mode::Edit(i, text) = &mut self.mode else {
212 return;
213 };
214 let i = *i;
215 match key.code {
216 KeyCode::Esc => self.mode = Mode::Browse,
217 KeyCode::Enter => {
218 let text = text.trim().to_string();
219 if text.is_empty() {
220 self.controls[i].value = Setting::Number(None);
221 } else {
222 match text.parse::<f64>() {
223 Ok(v) if v.is_finite() => self.controls[i].value = Setting::Number(Some(v)),
224 _ => {
225 self.status = Some(format!("{text}: not a number"));
226 return;
227 }
228 }
229 }
230 self.mode = Mode::Browse;
231 self.replan();
232 }
233 KeyCode::Backspace => {
234 text.pop();
235 }
236 KeyCode::Char(c) if c.is_ascii_digit() || matches!(c, '.' | '-' | 'e') => text.push(c),
237 _ => {}
238 }
239 }
240
241 fn table(&self) -> Table<'static> {
242 let n = self.plan.columns.len();
243 let mut widths = vec![0usize; n];
244 for row in std::iter::once(&self.plan.columns).chain(&self.plan.rows) {
245 for (w, cell) in widths.iter_mut().zip(row) {
246 *w = (*w).max(cell.chars().count());
247 }
248 }
249 let head = Row::new(
250 self.plan
251 .columns
252 .iter()
253 .map(|c| Cell::from(c.clone()).style(DIM)),
254 );
255 let rows: Vec<Row> = self
256 .plan
257 .rows
258 .iter()
259 .skip(self.offset)
260 .map(|r| Row::new(r.iter().map(|c| Cell::from(c.clone()))))
261 .collect();
262 let widths: Vec<Constraint> = widths
263 .iter()
264 .map(|&w| Constraint::Length(w.min(48) as u16))
265 .collect();
266 Table::new(rows, widths).header(head).column_spacing(2)
267 }
268
269 fn footer(&self) -> Line<'static> {
270 if let Mode::Edit(i, text) = &self.mode {
271 let label = self.controls[*i].label;
272 return input_line(
273 &format!("{label}: "),
274 text,
275 &[("enter", "set"), ("esc", "back")],
276 );
277 }
278 if let Some(s) = &self.status {
279 return Line::from(Span::styled(format!(" {s}"), HIGHLIGHT));
280 }
281 let mut keys: Vec<(String, String)> = vec![("enter".into(), self.verb.into())];
282 for c in &self.controls {
283 keys.push((c.key.to_string(), c.label.into()));
284 }
285 keys.push(("q".into(), self.cancel.into()));
286 let pairs: Vec<(&str, &str)> = keys.iter().map(|(k, w)| (k.as_str(), w.as_str())).collect();
287 help_line(&pairs)
288 }
289}
290
291impl Screen for PlanView<'_> {
292 fn render(&mut self, frame: &mut Frame) {
293 let settings = self.controls.len().div_ceil(3) as u16;
294 let notes = self.plan.notes.len() as u16 + u16::from(self.plan.blocked.is_some());
295 let [top, table, below, controls, foot] = Layout::vertical([
296 Constraint::Length(1),
297 Constraint::Fill(1),
298 Constraint::Length(notes),
299 Constraint::Length(if settings > 0 { settings + 2 } else { 0 }),
300 Constraint::Length(1),
301 ])
302 .areas(frame.area());
303 let extra = match &self.plan.blocked {
304 Some(_) => "cannot be written yet".to_string(),
305 None => format!("Enter to {}", self.verb),
306 };
307 title_bar(
308 frame,
309 top,
310 header(self.badge, &self.title, &extra),
311 &self.session_keys,
312 );
313 let block = panel("plan".into(), true);
314 let inner = block.inner(table);
315 frame.render_widget(block, table);
316 frame.render_widget(self.table(), inner);
317 let mut lines: Vec<Line> = self
318 .plan
319 .notes
320 .iter()
321 .map(|n| Line::from(format!(" {n}")))
322 .collect();
323 if let Some(why) = &self.plan.blocked {
324 lines.push(Line::from(Span::styled(
325 format!(" ✗ {why}"),
326 HIGHLIGHT.add_modifier(Modifier::BOLD),
327 )));
328 }
329 frame.render_widget(Paragraph::new(lines), below);
330 if settings > 0 {
331 let block = panel("settings".into(), false);
332 let inner = block.inner(controls);
333 frame.render_widget(block, controls);
334 let lines: Vec<Line> = self
335 .controls
336 .chunks(3)
337 .map(|chunk| {
338 let mut spans = Vec::new();
339 for c in chunk {
340 spans.push(Span::styled(format!(" {} ", c.key), HIGHLIGHT));
341 spans.push(Span::styled(format!("{} ", c.label), DIM));
342 spans.push(Span::raw(format!("{:<12}", c.value.shown())));
343 }
344 Line::from(spans)
345 })
346 .collect();
347 frame.render_widget(Paragraph::new(lines), inner);
348 }
349 frame.render_widget(self.footer(), foot);
350 }
351
352 fn handle_key(&mut self, key: KeyEvent) {
353 match self.mode {
354 Mode::Browse => self.browse_key(key),
355 Mode::Edit(..) => self.edit_key(key),
356 }
357 }
358
359 fn interrupt(&mut self) {
360 self.decision = Some(false);
361 }
362
363 fn done(&self) -> bool {
364 self.decision.is_some()
365 }
366
367 fn status(&self) -> Option<String> {
368 Some(format!("planning {}", self.title))
369 }
370}
371
372pub fn plan_in(tui: &mut Tui<'_>, mut view: PlanView<'_>) -> anyhow::Result<Option<Vec<Setting>>> {
375 tui.run(&mut view)?;
376 Ok(view.decided())
377}
378
379#[cfg(test)]
380#[path = "tests/plan_tui.rs"]
381mod tests;