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 self.status = None;
212 let Mode::Edit(i, text) = &mut self.mode else {
213 return;
214 };
215 let i = *i;
216 match key.code {
217 KeyCode::Esc => self.mode = Mode::Browse,
218 KeyCode::Enter => {
219 let text = text.trim().to_string();
220 if text.is_empty() {
221 self.controls[i].value = Setting::Number(None);
222 } else {
223 match text.parse::<f64>() {
224 Ok(v) if v.is_finite() => self.controls[i].value = Setting::Number(Some(v)),
225 _ => {
226 self.status = Some(format!("{text}: not a number"));
227 return;
228 }
229 }
230 }
231 self.mode = Mode::Browse;
232 self.replan();
233 }
234 KeyCode::Backspace => {
235 text.pop();
236 }
237 KeyCode::Char(c) if c.is_ascii_digit() || matches!(c, '.' | '-' | 'e') => text.push(c),
238 _ => {}
239 }
240 }
241
242 fn table(&self) -> Table<'static> {
243 let n = self.plan.columns.len();
244 let mut widths = vec![0usize; n];
245 for row in std::iter::once(&self.plan.columns).chain(&self.plan.rows) {
246 for (w, cell) in widths.iter_mut().zip(row) {
247 *w = (*w).max(cell.chars().count());
248 }
249 }
250 let head = Row::new(
251 self.plan
252 .columns
253 .iter()
254 .map(|c| Cell::from(c.clone()).style(DIM)),
255 );
256 let rows: Vec<Row> = self
257 .plan
258 .rows
259 .iter()
260 .skip(self.offset)
261 .map(|r| Row::new(r.iter().map(|c| Cell::from(c.clone()))))
262 .collect();
263 let widths: Vec<Constraint> = widths
264 .iter()
265 .map(|&w| Constraint::Length(w.min(48) as u16))
266 .collect();
267 Table::new(rows, widths).header(head).column_spacing(2)
268 }
269
270 fn footer(&self) -> Line<'static> {
271 if let Mode::Edit(i, text) = &self.mode {
272 let label = self.controls[*i].label;
273 let mut line = input_line(
274 &format!("{label}: "),
275 text,
276 &[("enter", "set"), ("esc", "back")],
277 );
278 if let Some(s) = &self.status {
280 line.push_span(Span::styled(format!(" {s}"), HIGHLIGHT));
281 }
282 return line;
283 }
284 if let Some(s) = &self.status {
285 return Line::from(Span::styled(format!(" {s}"), HIGHLIGHT));
286 }
287 let mut keys: Vec<(String, String)> = vec![("enter".into(), self.verb.into())];
288 for c in &self.controls {
289 keys.push((c.key.to_string(), c.label.into()));
290 }
291 keys.push(("q".into(), self.cancel.into()));
292 let pairs: Vec<(&str, &str)> = keys.iter().map(|(k, w)| (k.as_str(), w.as_str())).collect();
293 help_line(&pairs)
294 }
295}
296
297impl Screen for PlanView<'_> {
298 fn render(&mut self, frame: &mut Frame) {
299 let settings = self.controls.len().div_ceil(3) as u16;
300 let notes = self.plan.notes.len() as u16 + u16::from(self.plan.blocked.is_some());
301 let [top, table, below, controls, foot] = Layout::vertical([
302 Constraint::Length(1),
303 Constraint::Fill(1),
304 Constraint::Length(notes),
305 Constraint::Length(if settings > 0 { settings + 2 } else { 0 }),
306 Constraint::Length(1),
307 ])
308 .areas(frame.area());
309 let extra = match &self.plan.blocked {
310 Some(_) => "cannot be written yet".to_string(),
311 None => format!("Enter to {}", self.verb),
312 };
313 title_bar(
314 frame,
315 top,
316 header(self.badge, &self.title, &extra),
317 &self.session_keys,
318 );
319 let block = panel("plan".into(), true);
320 let inner = block.inner(table);
321 frame.render_widget(block, table);
322 frame.render_widget(self.table(), inner);
323 let mut lines: Vec<Line> = self
324 .plan
325 .notes
326 .iter()
327 .map(|n| Line::from(format!(" {n}")))
328 .collect();
329 if let Some(why) = &self.plan.blocked {
330 lines.push(Line::from(Span::styled(
331 format!(" ✗ {why}"),
332 HIGHLIGHT.add_modifier(Modifier::BOLD),
333 )));
334 }
335 frame.render_widget(Paragraph::new(lines), below);
336 if settings > 0 {
337 let block = panel("settings".into(), false);
338 let inner = block.inner(controls);
339 frame.render_widget(block, controls);
340 let lines: Vec<Line> = self
341 .controls
342 .chunks(3)
343 .map(|chunk| {
344 let mut spans = Vec::new();
345 for c in chunk {
346 spans.push(Span::styled(format!(" {} ", c.key), HIGHLIGHT));
347 spans.push(Span::styled(format!("{} ", c.label), DIM));
348 spans.push(Span::raw(format!("{:<12}", c.value.shown())));
349 }
350 Line::from(spans)
351 })
352 .collect();
353 frame.render_widget(Paragraph::new(lines), inner);
354 }
355 frame.render_widget(self.footer(), foot);
356 }
357
358 fn handle_key(&mut self, key: KeyEvent) {
359 match self.mode {
360 Mode::Browse => self.browse_key(key),
361 Mode::Edit(..) => self.edit_key(key),
362 }
363 }
364
365 fn interrupt(&mut self) {
366 self.decision = Some(false);
367 }
368
369 fn done(&self) -> bool {
370 self.decision.is_some()
371 }
372
373 fn status(&self) -> Option<String> {
374 Some(format!("planning {}", self.title))
375 }
376}
377
378pub fn plan_in(tui: &mut Tui<'_>, mut view: PlanView<'_>) -> anyhow::Result<Option<Vec<Setting>>> {
381 tui.run(&mut view)?;
382 Ok(view.decided())
383}
384
385#[cfg(test)]
386#[path = "tests/plan_tui.rs"]
387mod tests;