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        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            // A value refused stays to be mended, with why beside it.
279            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
378/// Show `view` on the screen of `tui`; the settings it was written with,
379/// or `None` when cancelled.
380pub 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;