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}