Skip to main content

pito_tui/
layer.rs

1use std::borrow::Cow;
2use std::time::Duration;
3
4use crossterm::event::{KeyCode, KeyEvent};
5use pito_footer::{Edit, Hint, Input, InputBar, Key};
6use pito_list::{Cell, Column, Keys, List, Part as Piece, Row, Step};
7use ratatui::{
8    Frame,
9    layout::Rect,
10    style::Style,
11    text::Line,
12    widgets::{Block, BorderType, Clear, Paragraph},
13};
14
15use crate::filter::matches;
16use crate::label::Span;
17use crate::layout::Spot;
18use crate::palette::{Palette, Tone};
19use crate::text::cells;
20
21const CANCEL: &[Key] = &[Key::Esc];
22const DISMISS: &[Key] = &[Key::Enter, Key::Char('q')];
23const ROWS: u16 = 10;
24const NARROWEST: u16 = 24;
25const FIELD: u16 = 32;
26const LASTING: Duration = Duration::from_secs(3);
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum Event {
31    Chosen { part: usize, choice: usize },
32    Moved { part: usize, choice: usize },
33    Edited { part: usize },
34    Submitted { part: usize },
35    Closed,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Choice {
40    spans: Vec<(String, Style)>,
41    skip: bool,
42    confirm: Option<Cow<'static, str>>,
43}
44
45impl Choice {
46    pub fn new(text: impl Into<String>) -> Self {
47        Choice::spans([(text.into(), Style::new())])
48    }
49
50    pub fn spans(spans: impl IntoIterator<Item = (String, Style)>) -> Self {
51        Choice {
52            spans: spans.into_iter().collect(),
53            skip: false,
54            confirm: None,
55        }
56    }
57
58    pub fn skip(mut self) -> Self {
59        self.skip = true;
60        self
61    }
62
63    pub fn confirm(mut self, warning: impl Into<Cow<'static, str>>) -> Self {
64        self.confirm = Some(warning.into());
65        self
66    }
67
68    pub fn text(&self) -> String {
69        self.spans.iter().map(|(text, _)| text.as_str()).collect()
70    }
71
72    fn width(&self) -> u16 {
73        u16::try_from(cells(&self.text())).unwrap_or(u16::MAX)
74    }
75
76    fn row(&self) -> Row {
77        let parts = self
78            .spans
79            .iter()
80            .map(|(text, style)| Piece::new(text.clone()).style(*style));
81        if self.skip {
82            Row::heading(parts)
83        } else {
84            Row::new([Cell::parts(parts)])
85        }
86    }
87}
88
89#[derive(Debug, Clone)]
90pub struct Field {
91    label: Cow<'static, str>,
92    placeholder: Cow<'static, str>,
93    input: Input,
94}
95
96impl Field {
97    pub fn new(label: impl Into<Cow<'static, str>>) -> Self {
98        Field {
99            label: label.into(),
100            placeholder: Cow::Borrowed(""),
101            input: Input::new(),
102        }
103    }
104
105    pub fn placeholder(mut self, text: impl Into<Cow<'static, str>>) -> Self {
106        self.placeholder = text.into();
107        self
108    }
109
110    pub fn input(mut self, input: Input) -> Self {
111        self.input = input;
112        self
113    }
114
115    fn width(&self) -> u16 {
116        let label = u16::try_from(cells(&self.label)).unwrap_or(u16::MAX);
117        label.saturating_add(FIELD)
118    }
119}
120
121#[derive(Debug, Clone)]
122struct Choices {
123    all: Vec<Choice>,
124    shown: Vec<usize>,
125    list: List,
126    rows: u16,
127}
128
129impl Choices {
130    fn new(all: Vec<Choice>, rows: u16) -> Self {
131        let mut choices = Choices {
132            all,
133            shown: Vec::new(),
134            list: List::new().keys(Keys::new()),
135            rows,
136        };
137        choices.sift("");
138        choices
139    }
140
141    fn sift(&mut self, query: &str) {
142        let kept = self.current();
143        self.shown = (0..self.all.len())
144            .filter(|index| query.trim().is_empty() || matches(&self.all[*index].text(), query))
145            .collect();
146        let rows: Vec<Row> = self
147            .shown
148            .iter()
149            .map(|index| self.all[*index].row())
150            .collect();
151        self.list.set_rows(rows);
152        let at = kept
153            .and_then(|kept| self.shown.iter().position(|index| *index == kept))
154            .or_else(|| self.shown.iter().position(|index| !self.all[*index].skip));
155        if let Some(at) = at {
156            self.list.select(at);
157        }
158    }
159
160    fn current(&self) -> Option<usize> {
161        self.list
162            .selected()
163            .and_then(|at| self.shown.get(at).copied())
164    }
165
166    fn width(&self) -> u16 {
167        self.all.iter().map(Choice::width).max().unwrap_or(0)
168    }
169
170    fn height(&self) -> u16 {
171        u16::try_from(self.shown.len())
172            .unwrap_or(u16::MAX)
173            .clamp(1, self.rows.max(1))
174    }
175}
176
177#[derive(Debug, Clone)]
178enum Part {
179    Text(Vec<Line<'static>>),
180    List(Box<Choices>),
181    Field(Field),
182}
183
184impl Part {
185    fn focusable(&self) -> bool {
186        !matches!(self, Part::Text(_))
187    }
188
189    fn height(&self) -> u16 {
190        match self {
191            Part::Text(lines) => u16::try_from(lines.len()).unwrap_or(u16::MAX),
192            Part::List(choices) => choices.height(),
193            Part::Field(_) => 1,
194        }
195    }
196
197    fn width(&self) -> u16 {
198        match self {
199            Part::Text(lines) => lines
200                .iter()
201                .map(|line| u16::try_from(line.width()).unwrap_or(u16::MAX))
202                .max()
203                .unwrap_or(0),
204            Part::List(choices) => choices.width().saturating_add(3),
205            Part::Field(field) => field.width(),
206        }
207    }
208}
209
210#[derive(Debug, Clone)]
211pub struct Modal {
212    pub(crate) id: u64,
213    title: Cow<'static, str>,
214    parts: Vec<Part>,
215    focus: usize,
216    status: Option<(Cow<'static, str>, Tone)>,
217    width: u16,
218    pub(crate) cancel: &'static [Key],
219    pub(crate) close: &'static [Key],
220    pub(crate) hints: Vec<Hint<'static>>,
221    filter: Option<(usize, usize)>,
222    armed: Option<usize>,
223}
224
225pub(crate) enum Outcome {
226    Stay,
227    Heard(Event),
228    Close,
229}
230
231impl Modal {
232    pub fn new(id: u64, title: impl Into<Cow<'static, str>>) -> Self {
233        Modal {
234            id,
235            title: title.into(),
236            parts: Vec::new(),
237            focus: 0,
238            status: None,
239            width: 0,
240            cancel: CANCEL,
241            close: &[],
242            hints: Vec::new(),
243            filter: None,
244            armed: None,
245        }
246    }
247
248    pub fn alert(
249        id: u64,
250        title: impl Into<Cow<'static, str>>,
251        lines: impl IntoIterator<Item = Line<'static>>,
252    ) -> Self {
253        Modal::new(id, title).text(lines).close_keys(DISMISS)
254    }
255
256    pub fn text(mut self, lines: impl IntoIterator<Item = Line<'static>>) -> Self {
257        self.parts.push(Part::Text(lines.into_iter().collect()));
258        self.refocus();
259        self
260    }
261
262    pub fn list(self, choices: impl IntoIterator<Item = Choice>) -> Self {
263        self.list_rows(choices, ROWS)
264    }
265
266    pub fn list_rows(mut self, choices: impl IntoIterator<Item = Choice>, rows: u16) -> Self {
267        let choices = Choices::new(choices.into_iter().collect(), rows);
268        self.parts.push(Part::List(Box::new(choices)));
269        self.refocus();
270        self
271    }
272
273    pub fn field(mut self, field: Field) -> Self {
274        self.parts.push(Part::Field(field));
275        self.refocus();
276        self
277    }
278
279    pub fn filter(mut self, field: usize, list: usize) -> Self {
280        let linked = matches!(self.parts.get(field), Some(Part::Field(_)))
281            && matches!(self.parts.get(list), Some(Part::List(_)));
282        self.filter = linked.then_some((field, list));
283        self
284    }
285
286    pub fn status(mut self, text: impl Into<Cow<'static, str>>, tone: Tone) -> Self {
287        self.status = Some((text.into(), tone));
288        self
289    }
290
291    pub fn width(mut self, cells: u16) -> Self {
292        self.width = cells;
293        self
294    }
295
296    pub fn hints(mut self, hints: impl IntoIterator<Item = Hint<'static>>) -> Self {
297        self.hints = hints.into_iter().collect();
298        self
299    }
300
301    pub fn cancel_keys(mut self, keys: &'static [Key]) -> Self {
302        self.cancel = keys;
303        self
304    }
305
306    pub fn close_keys(mut self, keys: &'static [Key]) -> Self {
307        self.close = keys;
308        self
309    }
310
311    pub fn focus(mut self, part: usize) -> Self {
312        if self.parts.get(part).is_some_and(Part::focusable) {
313            self.focus = part;
314        }
315        self
316    }
317
318    pub fn id(&self) -> u64 {
319        self.id
320    }
321
322    pub fn focused(&self) -> usize {
323        self.focus
324    }
325
326    pub fn set_status(&mut self, status: Option<(Cow<'static, str>, Tone)>) {
327        self.status = status;
328    }
329
330    pub fn set_text(
331        &mut self,
332        part: usize,
333        lines: impl IntoIterator<Item = Line<'static>>,
334    ) -> bool {
335        let Some(Part::Text(text)) = self.parts.get_mut(part) else {
336            return false;
337        };
338        *text = lines.into_iter().collect();
339        true
340    }
341
342    pub fn set_choices(&mut self, part: usize, choices: impl IntoIterator<Item = Choice>) -> bool {
343        let query = self.query(part).unwrap_or_default();
344        let Some(Part::List(list)) = self.parts.get_mut(part) else {
345            return false;
346        };
347        list.all = choices.into_iter().collect();
348        list.sift(&query);
349        self.armed = None;
350        true
351    }
352
353    pub fn choice(&self, part: usize) -> Option<usize> {
354        match self.parts.get(part)? {
355            Part::List(choices) => choices.current(),
356            _ => None,
357        }
358    }
359
360    pub fn value(&self, part: usize) -> Option<&str> {
361        match self.parts.get(part)? {
362            Part::Field(field) => Some(field.input.value()),
363            _ => None,
364        }
365    }
366
367    pub fn input_mut(&mut self, part: usize) -> Option<&mut Input> {
368        match self.parts.get_mut(part)? {
369            Part::Field(field) => Some(&mut field.input),
370            _ => None,
371        }
372    }
373
374    fn query(&self, list: usize) -> Option<String> {
375        let (field, linked) = self.filter?;
376        (linked == list).then(|| self.value(field).unwrap_or_default().to_string())
377    }
378
379    fn refocus(&mut self) {
380        if !self.parts.get(self.focus).is_some_and(Part::focusable)
381            && let Some(at) = self.parts.iter().position(Part::focusable)
382        {
383            self.focus = at;
384        }
385    }
386
387    fn cycle(&mut self, by: isize) {
388        let count = self.parts.len();
389        if count == 0 {
390            return;
391        }
392        let mut at = self.focus;
393        for _ in 0..count {
394            at = (at as isize + by).rem_euclid(count as isize) as usize;
395            if self.parts[at].focusable() {
396                self.focus = at;
397                return;
398            }
399        }
400    }
401
402    fn steered(&self) -> Option<usize> {
403        match self.parts.get(self.focus)? {
404            Part::List(_) => Some(self.focus),
405            Part::Field(_) => match self.filter {
406                Some((field, list)) if field == self.focus => Some(list),
407                _ => self
408                    .parts
409                    .iter()
410                    .position(|part| matches!(part, Part::List(_))),
411            },
412            Part::Text(_) => None,
413        }
414    }
415
416    fn choose(&mut self, part: usize) -> Outcome {
417        let Some(Part::List(choices)) = self.parts.get(part) else {
418            return Outcome::Stay;
419        };
420        let Some(choice) = choices.current() else {
421            return Outcome::Stay;
422        };
423        if choices.all[choice].confirm.is_some() && self.armed != Some(choice) {
424            self.armed = Some(choice);
425            return Outcome::Stay;
426        }
427        self.armed = None;
428        Outcome::Heard(Event::Chosen { part, choice })
429    }
430
431    pub(crate) fn key(&mut self, event: KeyEvent) -> Outcome {
432        let key: Key = event.into();
433        if self.cancel.contains(&key) || self.close.contains(&key) {
434            return Outcome::Close;
435        }
436        match key {
437            Key::Tab => {
438                self.cycle(1);
439                return Outcome::Stay;
440            }
441            Key::BackTab => {
442                self.cycle(-1);
443                return Outcome::Stay;
444            }
445            Key::Enter => {
446                return match (self.parts.get(self.focus), self.filter) {
447                    (Some(Part::List(_)), _) => self.choose(self.focus),
448                    (Some(Part::Field(_)), Some((field, list))) if field == self.focus => {
449                        self.choose(list)
450                    }
451                    (Some(Part::Field(_)), _) => {
452                        Outcome::Heard(Event::Submitted { part: self.focus })
453                    }
454                    _ => Outcome::Stay,
455                };
456            }
457            _ => {}
458        }
459        let on_field = matches!(self.parts.get(self.focus), Some(Part::Field(_)));
460        let listing = !on_field
461            || matches!(
462                event.code,
463                KeyCode::Up | KeyCode::Down | KeyCode::PageUp | KeyCode::PageDown
464            );
465        if listing
466            && let Some(part) = self.steered()
467            && let Some(Part::List(choices)) = self.parts.get_mut(part)
468        {
469            match choices.list.key(event.into()) {
470                Step::Moved => {
471                    self.armed = None;
472                    let choice = choices.current().unwrap_or(0);
473                    return Outcome::Heard(Event::Moved { part, choice });
474                }
475                Step::Held => return Outcome::Stay,
476                _ => {}
477            }
478        }
479        self.type_in(|input| input.key(key))
480    }
481
482    pub(crate) fn paste(&mut self, text: &str) -> Outcome {
483        self.type_in(|input| input.paste(text))
484    }
485
486    fn type_in(&mut self, edit: impl FnOnce(&mut Input) -> Edit) -> Outcome {
487        let part = self.focus;
488        let Some(Part::Field(field)) = self.parts.get_mut(part) else {
489            return Outcome::Stay;
490        };
491        if edit(&mut field.input) != Edit::Changed {
492            return Outcome::Stay;
493        }
494        self.armed = None;
495        if let Some((linked, list)) = self.filter
496            && linked == part
497        {
498            let query = self.value(part).unwrap_or_default().to_string();
499            if let Some(Part::List(choices)) = self.parts.get_mut(list) {
500                choices.sift(&query);
501            }
502        }
503        Outcome::Heard(Event::Edited { part })
504    }
505
506    fn line(&self) -> Option<(&str, Tone)> {
507        if let Some(choice) = self.armed {
508            let warning = self.parts.iter().find_map(|part| match part {
509                Part::List(choices) => choices.all.get(choice)?.confirm.as_deref(),
510                _ => None,
511            });
512            if let Some(warning) = warning {
513                return Some((warning, Tone::Warn));
514            }
515        }
516        self.status
517            .as_ref()
518            .map(|(text, tone)| (text.as_ref(), *tone))
519            .filter(|(text, _)| !text.is_empty())
520    }
521
522    pub(crate) fn draw(&mut self, frame: &mut Frame, area: Rect, palette: &Palette) {
523        let title = u16::try_from(cells(&self.title)).unwrap_or(u16::MAX);
524        let line = self.line().map_or(0, |(text, _)| {
525            u16::try_from(cells(text)).unwrap_or(u16::MAX)
526        });
527        let natural = self
528            .parts
529            .iter()
530            .map(Part::width)
531            .chain([title.saturating_add(2), line])
532            .max()
533            .unwrap_or(0)
534            .saturating_add(4)
535            .max(NARROWEST);
536        let width = if self.width > 0 { self.width } else { natural }.min(area.width);
537        let status = u16::from(self.line().is_some()) * 2;
538        let gaps = u16::try_from(self.parts.len().saturating_sub(1)).unwrap_or(0);
539        let body: u16 = self.parts.iter().map(Part::height).sum();
540        let wanted = body
541            .saturating_add(gaps)
542            .saturating_add(status)
543            .saturating_add(2);
544        let height = wanted.min(area.height);
545        let mut spare = wanted - height;
546        for part in self.parts.iter_mut().rev() {
547            if let Part::List(choices) = part {
548                let cut = spare.min(choices.height().saturating_sub(1));
549                choices.rows = choices.height() - cut;
550                spare -= cut;
551            }
552        }
553        let place = Spot::THIRD.place(area, width, height);
554        frame.render_widget(Clear, place);
555        let block = Block::bordered()
556            .border_type(BorderType::Rounded)
557            .border_style(palette.rule)
558            .style(palette.base)
559            .title(Line::styled(format!(" {} ", self.title), palette.selected));
560        let inner = block.inner(place);
561        frame.render_widget(block, place);
562        let inner = Rect {
563            x: inner.x.saturating_add(1),
564            width: inner.width.saturating_sub(2),
565            ..inner
566        };
567        let mut y = inner.y;
568        let columns = [Column::new("", 4, 0).flex()];
569        let focus = self.focus;
570        for (index, part) in self.parts.iter_mut().enumerate() {
571            if y >= inner.bottom() {
572                break;
573            }
574            if index > 0 {
575                y += 1;
576            }
577            let rows = part.height().min(inner.bottom().saturating_sub(y));
578            let at = Rect::new(inner.x, y, inner.width, rows);
579            match part {
580                Part::Text(lines) => {
581                    let lines: Vec<Line> = lines
582                        .iter()
583                        .map(|line| line.clone().patch_style(palette.ink))
584                        .collect();
585                    frame.render_widget(Paragraph::new(lines), at);
586                }
587                Part::List(choices) => {
588                    let view = palette.view(&mut choices.list, &columns).header(false);
589                    frame.render_widget(view, at);
590                }
591                Part::Field(field) => {
592                    let bar = InputBar::new(&field.label, &field.input)
593                        .placeholder(&field.placeholder)
594                        .rule(false)
595                        .caret(index == focus)
596                        .styles(palette.footer());
597                    frame.render_widget(bar, at);
598                }
599            }
600            y = y.saturating_add(rows);
601        }
602        if let Some((text, tone)) = self.line() {
603            let at = Rect::new(inner.x, inner.bottom().saturating_sub(1), inner.width, 1);
604            frame.render_widget(
605                Paragraph::new(Line::styled(text.to_string(), palette.tone(tone))),
606                at,
607            );
608        }
609    }
610}
611
612#[derive(Debug, Clone, PartialEq, Eq)]
613pub struct Takeover {
614    pub(crate) id: u64,
615    pub(crate) title: Vec<Span>,
616    pub(crate) label: Cow<'static, str>,
617    pub(crate) cancel: &'static [Key],
618}
619
620impl Takeover {
621    pub fn new(id: u64, title: impl Into<Cow<'static, str>>) -> Self {
622        Takeover {
623            id,
624            title: vec![(title.into(), Style::new())],
625            label: Cow::Borrowed(""),
626            cancel: &[],
627        }
628    }
629
630    pub fn title_spans<T: Into<Cow<'static, str>>>(
631        mut self,
632        spans: impl IntoIterator<Item = (T, Style)>,
633    ) -> Self {
634        self.title = spans
635            .into_iter()
636            .map(|(text, style)| (text.into(), style))
637            .collect();
638        self
639    }
640
641    pub fn label(mut self, text: impl Into<Cow<'static, str>>) -> Self {
642        self.label = text.into();
643        self
644    }
645
646    pub fn cancel(mut self, keys: &'static [Key]) -> Self {
647        self.cancel = keys;
648        self
649    }
650}
651
652#[derive(Debug, Clone, PartialEq, Eq)]
653pub struct Toast {
654    pub(crate) text: Cow<'static, str>,
655    pub(crate) tone: Tone,
656    pub(crate) spot: Spot,
657    pub(crate) lasting: Duration,
658}
659
660impl Toast {
661    pub fn new(text: impl Into<Cow<'static, str>>) -> Self {
662        Toast {
663            text: text.into(),
664            tone: Tone::Ink,
665            spot: Spot::TOP_RIGHT,
666            lasting: LASTING,
667        }
668    }
669
670    pub fn tone(mut self, tone: Tone) -> Self {
671        self.tone = tone;
672        self
673    }
674
675    pub fn at(mut self, spot: Spot) -> Self {
676        self.spot = spot;
677        self
678    }
679
680    pub fn lasting(mut self, lasting: Duration) -> Self {
681        self.lasting = lasting;
682        self
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use crossterm::event::KeyModifiers;
690
691    fn press(code: KeyCode) -> KeyEvent {
692        KeyEvent::new(code, KeyModifiers::NONE)
693    }
694
695    fn typed(modal: &mut Modal, text: &str) {
696        for c in text.chars() {
697            modal.key(press(KeyCode::Char(c)));
698        }
699    }
700
701    #[test]
702    fn a_filtered_list_skips_headings_and_a_risky_row_needs_a_second_enter() {
703        let mut modal = Modal::new(7, "Move")
704            .field(Field::new("Find"))
705            .list([
706                Choice::new("Recent").skip(),
707                Choice::new("archive"),
708                Choice::new("delete forever").confirm("enter again deletes it"),
709                Choice::new("draft"),
710            ])
711            .filter(0, 1);
712        assert_eq!(
713            modal.choice(1),
714            Some(1),
715            "the cursor starts past the heading"
716        );
717        typed(&mut modal, "de");
718        assert_eq!(modal.choice(1), Some(2), "only delete forever is left");
719        assert!(matches!(modal.key(press(KeyCode::Enter)), Outcome::Stay));
720        assert_eq!(modal.line(), Some(("enter again deletes it", Tone::Warn)));
721        let second = modal.key(press(KeyCode::Enter));
722        assert!(matches!(
723            second,
724            Outcome::Heard(Event::Chosen { part: 1, choice: 2 })
725        ));
726        assert!(matches!(modal.key(press(KeyCode::Esc)), Outcome::Close));
727    }
728}