Skip to main content

data_beans/interactive/
plan_tui.rs

1//! What a command is about to write, before it writes it: a table of the
2//! inputs or outputs (rows, columns, what is shared), notes, and the
3//! command's settings, each on a key. A flag flips on its key; a number
4//! asks for a new value. Every change redraws the plan; Enter writes with
5//! the settings shown, `q` writes nothing. A plan that cannot be written
6//! says why, and Enter waits for a setting that can.
7
8use 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/// A setting's value.
18#[derive(Debug, Clone, PartialEq)]
19pub enum Setting {
20    Flag(bool),
21    /// A number, with the value it is reset to if cleared (`None`: unset).
22    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
47/// `v` without a trailing `.0`.
48fn 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/// One setting on a key.
57#[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/// What a command would write with some settings.
83#[derive(Debug, Clone, Default, PartialEq)]
84pub struct Plan {
85    /// The table's heading, then its rows.
86    pub columns: Vec<String>,
87    pub rows: Vec<Vec<String>>,
88    /// Lines under the table: the output, its size.
89    pub notes: Vec<String>,
90    /// Why it cannot be written so; Enter waits.
91    pub blocked: Option<String>,
92}
93
94/// Computes the plan for the settings, in control order.
95pub type Planner<'a> = Box<dyn Fn(&[Setting]) -> anyhow::Result<Plan> + 'a>;
96
97#[derive(Debug, Clone, PartialEq)]
98enum Mode {
99    Browse,
100    /// Typing a new value for the control at this index.
101    Edit(usize, String),
102}
103
104/// The view, independent of the terminal so it can be tested.
105pub struct PlanView<'a> {
106    badge: &'static str,
107    title: String,
108    /// What Enter does, in a word or two (`merge`, `write`), and `q`.
109    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    /// Some(true) on Enter, Some(false) on `q`.
117    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    /// What `q` is said to do (`cancel` unless told).
153    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    /// The settings Enter wrote with, or `None` when left with `q`.
174    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        // A setting's key first: it may be any letter but q.
181        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
372/// Show `view` on the screen of `tui`; the settings it was written with,
373/// or `None` when cancelled.
374pub 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;