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}