thedes_tui/
menu.rs

1use std::fmt;
2
3use thedes_tui_core::{
4    App,
5    event::{Event, Key},
6    geometry::Coord,
7    mutation::Set,
8    screen::{self, FlushError},
9};
10use thiserror::Error;
11use unicode_segmentation::UnicodeSegmentation;
12
13use crate::{
14    cancellability::{Cancellation, NonCancellable},
15    text,
16};
17
18pub use style::Style;
19
20mod style;
21
22pub fn default_key_bindings() -> KeyBindingMap {
23    KeyBindingMap::new()
24        .with(Key::Enter, Command::Confirm)
25        .with(Key::Esc, Command::ConfirmCancel)
26        .with(Key::Char('q'), Command::ConfirmCancel)
27        .with(Key::Up, Command::ItemAbove)
28        .with(Key::Down, Command::ItemBelow)
29        .with(Key::Left, Command::UnsetCancelling)
30        .with(Key::Right, Command::SetCancelling)
31}
32
33#[derive(Debug, Error)]
34pub enum Error {
35    #[error("Cannot create menu with no items")]
36    NoItems,
37    #[error("Requested index {given} out of bounds with length {len}")]
38    OutOfBounds { len: usize, given: usize },
39    #[error("Requested scroll top index will exclude selected index")]
40    ScrollExcludesSelected,
41    #[error("Failed to render text")]
42    RenderText(
43        #[from]
44        #[source]
45        text::Error,
46    ),
47    #[error("Failed to flush tiles to canvas")]
48    CanvasFlush(
49        #[from]
50        #[source]
51        FlushError,
52    ),
53    #[error("Menu was cancelled")]
54    Cancelled,
55}
56
57pub type KeyBindingMap = crate::key_bindings::KeyBindingMap<Command>;
58
59pub trait Item: fmt::Display {}
60
61impl<T> Item for T where T: fmt::Display {}
62
63#[derive(Debug, Clone, Copy, PartialEq)]
64pub enum Command {
65    Confirm,
66    ConfirmCancel,
67    ConfirmItem,
68    ItemAbove,
69    ItemBelow,
70    SetCancelling,
71    UnsetCancelling,
72    Select(usize),
73    SelectConfirm(usize),
74}
75
76#[derive(Debug, Clone)]
77pub struct Menu<I, C = NonCancellable> {
78    title: String,
79    items: Vec<I>,
80    selected: usize,
81    scroll: usize,
82    style: Style,
83    cancellation: C,
84    key_bindings: KeyBindingMap,
85}
86
87impl<I> Menu<I>
88where
89    I: Item,
90{
91    pub fn new(
92        title: impl AsRef<str>,
93        items: impl IntoIterator<Item = I>,
94    ) -> Result<Self, Error> {
95        Self::from_cancellation(title, items, NonCancellable)
96    }
97}
98
99impl<I, C> Menu<I, C>
100where
101    I: Item,
102    for<'a> C: Cancellation<&'a I>,
103{
104    pub fn from_cancellation(
105        title: impl AsRef<str>,
106        items: impl IntoIterator<Item = I>,
107        cancellation: C,
108    ) -> Result<Self, Error> {
109        let items: Vec<_> = items.into_iter().collect();
110        if items.is_empty() {
111            Err(Error::NoItems)?
112        }
113        Ok(Self {
114            title: title.as_ref().to_owned(),
115            items,
116            scroll: 0,
117            selected: 0,
118            style: Style::default(),
119            cancellation,
120            key_bindings: default_key_bindings(),
121        })
122    }
123
124    pub fn with_title(mut self, title: impl AsRef<str>) -> Self {
125        self.set_title(title);
126        self
127    }
128
129    pub fn set_title(&mut self, title: impl AsRef<str>) -> &mut Self {
130        self.title = title.as_ref().to_owned();
131        self
132    }
133
134    pub fn with_items(
135        mut self,
136        items: impl IntoIterator<Item = I>,
137    ) -> Result<Self, Error> {
138        self.set_items(items)?;
139        Ok(self)
140    }
141
142    pub fn set_items(
143        &mut self,
144        items: impl IntoIterator<Item = I>,
145    ) -> Result<&mut Self, Error> {
146        let items: Vec<_> = items.into_iter().collect();
147        if items.is_empty() {
148            Err(Error::NoItems)?
149        }
150        self.items = items;
151        self.selected = self.selected.min(self.items.len() - 1);
152        Ok(self)
153    }
154
155    pub fn with_cancellation(mut self, cancellation: C) -> Self {
156        self.set_cancellation(cancellation);
157        self
158    }
159
160    pub fn set_cancellation(&mut self, cancellation: C) -> &mut Self {
161        self.cancellation = cancellation;
162        self
163    }
164
165    pub fn with_style(mut self, style: Style) -> Self {
166        self.set_style(style);
167        self
168    }
169
170    pub fn set_style(&mut self, style: Style) -> &mut Self {
171        self.style = style;
172        self
173    }
174
175    pub fn with_keybindings(mut self, map: KeyBindingMap) -> Self {
176        self.set_keybindings(map);
177        self
178    }
179
180    pub fn set_keybindings(&mut self, map: KeyBindingMap) -> &mut Self {
181        self.key_bindings = map;
182        self
183    }
184
185    pub fn with_selected(mut self, index: usize) -> Result<Self, Error> {
186        self.set_selected(index)?;
187        Ok(self)
188    }
189
190    pub fn set_selected(&mut self, index: usize) -> Result<&mut Self, Error> {
191        if index >= self.items.len() {
192            Err(Error::OutOfBounds { len: self.items.len(), given: index })?
193        }
194        self.selected = index;
195        Ok(self)
196    }
197
198    pub fn with_scroll(mut self, top_item_index: usize) -> Result<Self, Error> {
199        self.set_scroll(top_item_index)?;
200        Ok(self)
201    }
202
203    pub fn set_scroll(
204        &mut self,
205        top_item_index: usize,
206    ) -> Result<&mut Self, Error> {
207        if top_item_index >= self.items.len() {
208            Err(Error::OutOfBounds {
209                len: self.items.len(),
210                given: top_item_index,
211            })?
212        }
213        if top_item_index > self.selected_index() {
214            Err(Error::ScrollExcludesSelected)?;
215        }
216        self.scroll = top_item_index;
217        Ok(self)
218    }
219
220    pub fn title(&self) -> &str {
221        &self.title
222    }
223
224    pub fn items(&self) -> &[I] {
225        &self.items
226    }
227
228    pub fn cancellation(&self) -> &C {
229        &self.cancellation
230    }
231
232    pub fn style(&self) -> &Style {
233        &self.style
234    }
235
236    pub fn key_bindings(&self) -> &KeyBindingMap {
237        &self.key_bindings
238    }
239
240    pub fn selected_index(&self) -> usize {
241        self.selected
242    }
243
244    pub fn scroll_top_index(&self) -> usize {
245        self.scroll
246    }
247
248    pub fn selected_item(&self) -> &I {
249        &self.items[self.selected]
250    }
251
252    pub fn output<'a>(&'a self) -> <C as Cancellation<&'a I>>::Output {
253        self.cancellation().make_output(self.selected_item())
254    }
255
256    pub fn is_cancellable(&self) -> bool {
257        self.cancellation().is_cancellable()
258    }
259
260    pub fn is_cancelling(&self) -> bool {
261        self.cancellation().is_cancelling()
262    }
263
264    pub fn set_cancelling(&mut self, is_it: bool) {
265        self.cancellation.set_cancelling(is_it);
266    }
267
268    pub fn run_command(&mut self, cmd: Command) -> Result<bool, Error> {
269        match cmd {
270            Command::Confirm => return Ok(false),
271            Command::ConfirmItem => {
272                self.set_cancelling(false);
273                return Ok(false);
274            },
275            Command::ConfirmCancel => {
276                if self.is_cancellable() {
277                    self.set_cancelling(true);
278                    return Ok(false);
279                }
280            },
281            Command::ItemAbove => {
282                if self.is_cancelling() {
283                    self.set_cancelling(false);
284                } else {
285                    let new_index = self.selected_index().saturating_sub(1);
286                    self.set_selected(new_index)?;
287                }
288            },
289            Command::ItemBelow => {
290                match self
291                    .selected_index()
292                    .checked_add(1)
293                    .filter(|index| *index < self.items().len())
294                {
295                    Some(new_index) => {
296                        self.set_selected(new_index)?;
297                    },
298                    None => {
299                        self.set_cancelling(true);
300                    },
301                }
302            },
303            Command::SetCancelling => {
304                self.set_cancelling(true);
305            },
306            Command::UnsetCancelling => {
307                self.set_cancelling(false);
308            },
309            Command::Select(index) => {
310                self.set_selected(index)?;
311            },
312            Command::SelectConfirm(index) => {
313                self.set_selected(index)?;
314                return Ok(false);
315            },
316        }
317        Ok(true)
318    }
319
320    pub async fn run(&mut self, app: &mut App) -> Result<(), Error> {
321        while self.handle_input(app)? {
322            self.render(app)?;
323            tokio::select! {
324                _ = app.tick_session.tick() => (),
325                _ = app.cancel_token.cancelled() => Err(Error::Cancelled)?,
326            }
327        }
328        Ok(())
329    }
330
331    fn handle_input(&mut self, app: &mut App) -> Result<bool, Error> {
332        let Ok(mut events) = app.events.read_until_now() else {
333            Err(Error::Cancelled)?
334        };
335        let mut should_continue = true;
336        while let Some(event) = events.next().filter(|_| should_continue) {
337            let Event::Key(key) = event else { continue };
338            let Some(&command) = self.key_bindings.command_for(key) else {
339                continue;
340            };
341            should_continue = self.run_command(command)?;
342        }
343        Ok(should_continue)
344    }
345
346    fn render(&mut self, app: &mut App) -> Result<(), Error> {
347        app.canvas
348            .queue([screen::Command::ClearScreen(self.style().background())]);
349
350        let mut height = self.style().top_margin();
351        self.render_title(app, &mut height)?;
352
353        let scroll_bottom_index = self.compute_scroll_bottom(app, height)?;
354
355        self.render_top_arrow(app, &mut height)?;
356        self.render_items(app, &mut height, scroll_bottom_index)?;
357        self.render_bottom_arrow(app, &mut height, scroll_bottom_index)?;
358        self.render_cancel(app, &mut height)?;
359
360        app.canvas.flush()?;
361
362        Ok(())
363    }
364
365    fn render_title(
366        &mut self,
367        app: &mut App,
368        height: &mut Coord,
369    ) -> Result<(), Error> {
370        *height = text::styled(
371            app,
372            self.title(),
373            &text::Style::new_with_colors(Set(self.style().title_colors()))
374                .with_align(1, 2)
375                .with_top_margin(*height)
376                .with_left_margin(self.style().left_margin())
377                .with_right_margin(self.style().right_margin()),
378        )?;
379        *height += self.style().title_top_arrow_padding();
380        Ok(())
381    }
382
383    fn compute_scroll_bottom(
384        &mut self,
385        app: &mut App,
386        height: Coord,
387    ) -> Result<usize, Error> {
388        let max_items_rem = self.items().len() - self.scroll_top_index();
389        let mut available_screen = app.canvas.size().y;
390        available_screen -= height;
391        available_screen -= self.style().bottom_margin();
392        available_screen -= self.style().items_bottom_arrow_padding();
393        available_screen -= 1;
394        if self.is_cancellable() {
395            available_screen -= self.style().bottom_arrow_cancel_padding();
396            available_screen -= 1;
397        }
398        let item_required_space = 1 + self.style().item_between_padding();
399        let max_displayable_items = usize::from(
400            (available_screen + item_required_space - 1) / item_required_space,
401        );
402        let scroll_count = max_items_rem.min(max_displayable_items);
403        let mut scroll_bottom_index = self.scroll_top_index() + scroll_count;
404        if self.selected_index() >= scroll_bottom_index {
405            scroll_bottom_index = self.selected_index() + 1;
406            self.set_scroll(scroll_bottom_index - scroll_count)?;
407        }
408        Ok(scroll_bottom_index)
409    }
410
411    fn render_top_arrow(
412        &mut self,
413        app: &mut App,
414        height: &mut Coord,
415    ) -> Result<(), Error> {
416        if self.scroll_top_index() > 0 {
417            let colors = self.style().top_arrow_colors();
418            text::styled(
419                app,
420                self.style().top_arrow(),
421                &text::Style::new_with_colors(Set(colors))
422                    .with_align(1, 2)
423                    .with_top_margin(*height)
424                    .with_bottom_margin(app.canvas.size().y - *height - 2)
425                    .with_left_margin(self.style().left_margin())
426                    .with_right_margin(self.style().right_margin()),
427            )?;
428        }
429
430        *height += 1;
431        *height += self.style().top_arrow_items_padding();
432
433        Ok(())
434    }
435
436    fn render_items(
437        &mut self,
438        app: &mut App,
439        height: &mut Coord,
440        scroll_bottom_index: usize,
441    ) -> Result<(), Error> {
442        for (scroll_i, i) in
443            (self.scroll_top_index() .. scroll_bottom_index).enumerate()
444        {
445            if scroll_i > 0 {
446                *height += self.style().item_between_padding();
447            }
448            self.render_item(app, height, i)?;
449        }
450        Ok(())
451    }
452
453    fn render_item(
454        &mut self,
455        app: &mut App,
456        height: &mut Coord,
457        index: usize,
458    ) -> Result<(), Error> {
459        let is_selected =
460            index == self.selected_index() && !self.is_cancelling();
461        let rendered_raw = self.items()[index].to_string();
462        let graphemes = rendered_raw.graphemes(true).count();
463        let right_padding = if graphemes % 2 == 0 { " " } else { "" };
464        let (colors, rendered) = if is_selected {
465            let rendered = format!(
466                "{}{}{}{}",
467                self.style().selected_left(),
468                rendered_raw,
469                right_padding,
470                self.style().selected_right(),
471            );
472            (self.style().selected_colors(), rendered)
473        } else {
474            let rendered = format!("{}{}", rendered_raw, right_padding);
475            (self.style().unselected_colors(), rendered)
476        };
477        text::styled(
478            app,
479            &rendered,
480            &text::Style::new_with_colors(Set(colors))
481                .with_align(1, 2)
482                .with_top_margin(*height)
483                .with_bottom_margin(app.canvas.size().y - *height - 2)
484                .with_left_margin(self.style().left_margin())
485                .with_right_margin(self.style().right_margin()),
486        )?;
487        *height += 1;
488        Ok(())
489    }
490
491    fn render_bottom_arrow(
492        &mut self,
493        app: &mut App,
494        height: &mut Coord,
495        scroll_bottom_index: usize,
496    ) -> Result<(), Error> {
497        *height += self.style().items_bottom_arrow_padding();
498        if scroll_bottom_index > self.items().len() {
499            let colors = self.style().bottom_arrow_colors();
500            text::styled(
501                app,
502                self.style().bottom_arrow(),
503                &text::Style::new_with_colors(Set(colors))
504                    .with_align(1, 2)
505                    .with_top_margin(*height)
506                    .with_bottom_margin(app.canvas.size().y - *height - 2)
507                    .with_left_margin(self.style().left_margin())
508                    .with_right_margin(self.style().right_margin()),
509            )?;
510        }
511
512        Ok(())
513    }
514
515    fn render_cancel(
516        &mut self,
517        app: &mut App,
518        height: &mut Coord,
519    ) -> Result<(), Error> {
520        if self.is_cancellable() {
521            *height += self.style().bottom_arrow_cancel_padding();
522            let is_selected = self.is_cancelling();
523            let mut right_margin = self.style().right_margin();
524            right_margin += 1;
525            let (colors, rendered) = if is_selected {
526                let rendered = format!(
527                    "{}{}{}",
528                    self.style().selected_left(),
529                    self.style().cancel_label(),
530                    self.style().selected_right(),
531                );
532                (self.style().selected_colors(), rendered)
533            } else {
534                right_margin += self.style().selected_right().len() as Coord;
535                let rendered = format!("{}", self.style().cancel_label());
536                (self.style().unselected_colors(), rendered)
537            };
538
539            *height = app.canvas.size().y - self.style().bottom_margin() - 2;
540
541            text::styled(
542                app,
543                &rendered,
544                &text::Style::new_with_colors(Set(colors))
545                    .with_align(4, 5)
546                    .with_top_margin(*height)
547                    .with_bottom_margin(app.canvas.size().y - *height - 2)
548                    .with_left_margin(self.style().left_margin())
549                    .with_right_margin(right_margin),
550            )?;
551        }
552
553        Ok(())
554    }
555}