1use std::cmp;
2use std::collections::HashSet;
3use std::hash::Hash;
4
5use ratatui::symbols::border;
6use serde::{Deserialize, Serialize};
7
8use ratatui::layout::{Alignment, Direction, Layout, Position, Rect};
9use ratatui::style::{Style, Stylize};
10use ratatui::text::{Line, Span, Text};
11use ratatui::widgets::{Block, BorderType, Row, Scrollbar, ScrollbarOrientation, ScrollbarState};
12use ratatui::Frame;
13use ratatui::{layout::Constraint, widgets::Paragraph};
14
15use crate::event::Key;
16use crate::ui::ext::{FooterBlock, FooterBlockType, HeaderBlock};
17use crate::ui::layout::Spacing;
18use crate::ui::theme::{style, Theme};
19use crate::ui::ToRow;
20use crate::ui::{layout, span, ToTree};
21
22use super::{Context, InnerResponse, Response, Ui};
23
24pub type AddContentFn<'a, M, R> = dyn FnOnce(&mut Ui<M>) -> R + 'a;
25
26pub const RENDER_WIDTH_XSMALL: usize = 50;
27pub const RENDER_WIDTH_SMALL: usize = 70;
28pub const RENDER_WIDTH_MEDIUM: usize = 150;
29pub const RENDER_WIDTH_LARGE: usize = usize::MAX;
30
31pub trait Widget {
32 fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response
33 where
34 M: Clone;
35}
36
37pub enum Borders {
39 None,
40 Spacer { top: usize, left: usize },
41 All,
42 Top,
43 Sides,
44 Bottom,
45 BottomSides,
46}
47
48#[derive(Clone, Debug, Default)]
49pub struct ColumnView {
50 small: bool,
51 medium: bool,
52 large: bool,
53}
54
55impl ColumnView {
56 pub fn all() -> Self {
57 Self {
58 small: true,
59 medium: true,
60 large: true,
61 }
62 }
63
64 pub fn small(mut self) -> Self {
65 self.small = true;
66 self
67 }
68
69 pub fn medium(mut self) -> Self {
70 self.medium = true;
71 self
72 }
73
74 pub fn large(mut self) -> Self {
75 self.large = true;
76 self
77 }
78}
79
80#[derive(Clone, Debug)]
81pub struct Column<'a> {
82 pub text: Text<'a>,
83 pub width: Constraint,
84 pub skip: bool,
85 pub view: ColumnView,
86}
87
88impl<'a> Column<'a> {
89 pub fn new(text: impl Into<Text<'a>>, width: Constraint) -> Self {
90 Self {
91 text: text.into(),
92 width,
93 skip: false,
94 view: ColumnView::all(),
95 }
96 }
97
98 pub fn skip(mut self, skip: bool) -> Self {
99 self.skip = skip;
100 self
101 }
102
103 pub fn hide_small(mut self) -> Self {
104 self.view = ColumnView::default().medium().large();
105 self
106 }
107
108 pub fn hide_medium(mut self) -> Self {
109 self.view = ColumnView::default().large();
110 self
111 }
112
113 pub fn displayed(&self, area_width: usize) -> bool {
114 if area_width < RENDER_WIDTH_SMALL {
115 self.view.small
116 } else if area_width < RENDER_WIDTH_MEDIUM {
117 self.view.medium
118 } else if area_width < RENDER_WIDTH_LARGE {
119 self.view.large
120 } else {
121 true
122 }
123 }
124}
125
126#[derive(Default)]
127pub struct Window {}
128
129impl Window {
130 #[inline]
131 pub fn show<M, R>(
132 self,
133 ctx: &Context<M>,
134 theme: Theme,
135 add_contents: impl FnOnce(&mut Ui<M>) -> R,
136 ) -> Option<InnerResponse<Option<R>>>
137 where
138 M: Clone,
139 {
140 self.show_dyn(ctx, theme, Box::new(add_contents))
141 }
142
143 fn show_dyn<M, R>(
144 self,
145 ctx: &Context<M>,
146 theme: Theme,
147 add_contents: Box<AddContentFn<M, R>>,
148 ) -> Option<InnerResponse<Option<R>>>
149 where
150 M: Clone,
151 {
152 let mut ui = Ui::default()
153 .with_focus()
154 .with_area(ctx.frame_size())
155 .with_ctx(ctx.clone())
156 .with_layout(Layout::horizontal([Constraint::Min(1)]).into())
157 .with_area_focus(Some(0))
158 .with_theme(theme);
159
160 let inner = add_contents(&mut ui);
161
162 Some(InnerResponse::new(Some(inner), Response::default()))
163 }
164}
165
166#[derive(Clone, Debug, Serialize, Deserialize)]
167pub struct ContainerState {
168 len: usize,
169 focus: Option<usize>,
170}
171
172impl ContainerState {
173 pub fn new(len: usize, focus: Option<usize>) -> Self {
174 Self { len, focus }
175 }
176
177 pub fn focus(&self) -> Option<usize> {
178 self.focus
179 }
180
181 pub fn len(&self) -> usize {
182 self.len
183 }
184
185 pub fn is_empty(&self) -> bool {
186 self.len == 0
187 }
188
189 pub fn focus_next(&mut self) -> bool {
190 let focus = self
191 .focus
192 .map(|focus| cmp::min(focus.saturating_add(1), self.len.saturating_sub(1)));
193 let changed = focus != self.focus;
194 if changed {
195 self.focus = focus;
196 }
197 changed
198 }
199
200 pub fn focus_prev(&mut self) -> bool {
201 let focus = self.focus.map(|f| f.saturating_sub(1));
202 let changed = focus != self.focus;
203 if changed {
204 self.focus = focus;
205 }
206 changed
207 }
208
209 pub fn focus_index(&mut self, focus: usize) -> bool {
210 let focus = (focus < self.len).then_some(focus);
211 let changed = focus.is_some() && focus != self.focus;
212 if changed {
213 self.focus = focus;
214 }
215 changed
216 }
217}
218
219impl<'a> From<&Container<'a>> for ContainerState {
220 fn from(container: &Container<'a>) -> Self {
221 Self {
222 len: container.len,
223 focus: *container.focus,
224 }
225 }
226}
227
228pub struct Container<'a> {
229 focus: &'a mut Option<usize>,
230 len: usize,
231}
232
233impl<'a> Container<'a> {
234 pub fn new(len: usize, focus: &'a mut Option<usize>) -> Self {
235 Self { len, focus }
236 }
237
238 pub fn show<M, R>(
239 self,
240 ui: &mut Ui<M>,
241 add_contents: impl FnOnce(&mut Ui<M>) -> R,
242 ) -> InnerResponse<R>
243 where
244 M: Clone,
245 {
246 self.show_dyn(ui, Box::new(add_contents))
247 }
248
249 pub fn show_dyn<M, R>(
250 self,
251 ui: &mut Ui<M>,
252 add_contents: Box<AddContentFn<M, R>>,
253 ) -> InnerResponse<R>
254 where
255 M: Clone,
256 {
257 let mut response = Response::default();
258 let mut state = ContainerState::from(&self);
259
260 response.changed |= ui.has_global_input(|key| key == Key::Tab) && state.focus_next();
261 response.changed |= ui.has_global_input(|key| key == Key::BackTab) && state.focus_prev();
262 for index in 1..=self.len {
263 if let Some(c) = char::from_digit(index as u32, 10) {
264 response.changed |=
265 ui.has_global_input(|key| key == Key::Char(c)) && state.focus_index(index - 1);
266 }
267 }
268 *self.focus = state.focus;
269
270 InnerResponse::new(
271 add_contents(&mut ui.clone().with_area_focus(state.focus)),
272 response,
273 )
274 }
275}
276
277#[derive(Default)]
278pub struct Popup {}
279
280impl Popup {
281 pub fn show<M, R>(
282 self,
283 ui: &mut Ui<M>,
284 add_contents: impl FnOnce(&mut Ui<M>) -> R,
285 ) -> InnerResponse<R>
286 where
287 M: Clone,
288 {
289 self.show_dyn(ui, Box::new(add_contents))
290 }
291
292 pub fn show_dyn<M, R>(
293 self,
294 ui: &mut Ui<M>,
295 add_contents: Box<AddContentFn<M, R>>,
296 ) -> InnerResponse<R>
297 where
298 M: Clone,
299 {
300 let inner = add_contents(ui);
301 InnerResponse::new(inner, Response::default())
302 }
303}
304
305pub struct Label<'a> {
306 content: Text<'a>,
307}
308
309impl<'a> Label<'a> {
310 pub fn new(content: impl Into<Text<'a>>) -> Self {
311 Self {
312 content: content.into(),
313 }
314 }
315}
316
317impl Widget for Label<'_> {
318 fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response {
319 let (area, _) = ui.next_area().unwrap_or_default();
320 frame.render_widget(self.content, area);
321
322 Response::default()
323 }
324}
325
326#[derive(Clone, Debug, Serialize, Deserialize)]
327pub struct TableState {
328 internal: ratatui::widgets::TableState,
329}
330
331impl TableState {
332 pub fn new(selected: Option<usize>) -> Self {
333 let mut internal = ratatui::widgets::TableState::default();
334 internal.select(selected);
335
336 Self { internal }
337 }
338
339 pub fn selected(&self) -> Option<usize> {
340 self.internal.selected()
341 }
342
343 pub fn select_first(&mut self) {
344 self.internal.select(Some(0));
345 }
346}
347
348impl TableState {
349 fn prev(&mut self) -> Option<usize> {
350 let selected = self
351 .internal
352 .selected()
353 .map(|current| current.saturating_sub(1));
354 self.select(selected);
355 selected
356 }
357
358 fn next(&mut self, len: usize) -> Option<usize> {
359 let selected = self.internal.selected().map(|current| {
360 if current < len.saturating_sub(1) {
361 current.saturating_add(1)
362 } else {
363 current
364 }
365 });
366 self.select(selected);
367 selected
368 }
369
370 fn prev_page(&mut self, page_size: usize) -> Option<usize> {
371 let selected = self
372 .internal
373 .selected()
374 .map(|current| current.saturating_sub(page_size));
375 self.select(selected);
376 selected
377 }
378
379 fn next_page(&mut self, len: usize, page_size: usize) -> Option<usize> {
380 let selected = self.internal.selected().map(|current| {
381 if current < len.saturating_sub(1) {
382 cmp::min(current.saturating_add(page_size), len.saturating_sub(1))
383 } else {
384 current
385 }
386 });
387 self.select(selected);
388 selected
389 }
390
391 fn begin(&mut self) {
392 self.select(Some(0));
393 }
394
395 fn end(&mut self, len: usize) {
396 self.select(Some(len.saturating_sub(1)));
397 }
398
399 fn select(&mut self, selected: Option<usize>) {
400 self.internal.select(selected);
401 }
402}
403
404pub struct Table<'a, R, const W: usize> {
405 items: &'a Vec<R>,
406 selected: &'a mut Option<usize>,
407 columns: Vec<Column<'a>>,
408 spacing: Spacing,
409 borders: Option<Borders>,
410 show_scrollbar: bool,
411 empty_message: Option<String>,
412 dim: bool,
413}
414
415impl<'a, R, const W: usize> Table<'a, R, W>
416where
417 R: ToRow<W>,
418{
419 pub fn new(
420 selected: &'a mut Option<usize>,
421 items: &'a Vec<R>,
422 columns: Vec<Column<'a>>,
423 empty_message: Option<String>,
424 borders: Option<Borders>,
425 ) -> Self {
426 Self {
427 items,
428 selected,
429 columns,
430 spacing: Spacing::from(1),
431 empty_message,
432 borders,
433 show_scrollbar: true,
434 dim: false,
435 }
436 }
437
438 pub fn dim(mut self, dim: bool) -> Self {
439 self.dim = dim;
440 self
441 }
442
443 pub fn spacing(mut self, spacing: Spacing) -> Self {
444 self.spacing = spacing;
445 self
446 }
447}
448
449impl<R, const W: usize> Widget for Table<'_, R, W>
450where
451 R: ToRow<W> + Clone,
452{
453 fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response
454 where
455 M: Clone,
456 {
457 let mut response = Response::default();
458
459 let (area, area_focus) = ui.next_area().unwrap_or_default();
460
461 let show_scrollbar = self.show_scrollbar && self.items.len() >= area.height.into();
462 let has_items = !self.items.is_empty();
463
464 let mut state = TableState {
465 internal: {
466 let mut state = ratatui::widgets::TableState::default();
467 state.select(*self.selected);
468 state
469 },
470 };
471
472 let border_style = if ui.has_focus {
473 ui.theme.focus_border_style
474 } else {
475 ui.theme.border_style
476 };
477
478 let area = render_block(frame, area, self.borders, border_style);
479
480 if let Some(key) = ui.get_input(|_| true) {
481 let len = self.items.len();
482 let page_size = area.height as usize;
483
484 match key {
485 Key::Up | Key::Char('k') => {
486 state.prev();
487 response.changed = true;
488 }
489 Key::Down | Key::Char('j') => {
490 state.next(len);
491 response.changed = true;
492 }
493 Key::PageUp => {
494 state.prev_page(page_size);
495 response.changed = true;
496 }
497 Key::PageDown => {
498 state.next_page(len, page_size);
499 response.changed = true;
500 }
501 Key::Home => {
502 state.begin();
503 response.changed = true;
504 }
505 Key::End => {
506 state.end(len);
507 response.changed = true;
508 }
509 _ => {}
510 }
511 }
512
513 let widths: Vec<Constraint> = self
514 .columns
515 .iter()
516 .filter_map(|c| {
517 if !c.skip && c.displayed(area.width as usize) {
518 Some(c.width)
519 } else {
520 None
521 }
522 })
523 .collect();
524
525 if has_items {
526 let [table_area, scroller_area] =
527 Layout::horizontal([Constraint::Min(1), Constraint::Length(1)]).areas(area);
528
529 let rows = self
530 .items
531 .iter()
532 .map(|item| {
533 let mut cells = vec![];
534 let mut it = self.columns.iter();
535
536 for cell in item.to_row() {
537 if let Some(col) = it.next() {
538 if !col.skip && col.displayed(table_area.width as usize) {
539 cells.push(cell.clone())
540 }
541 } else {
542 continue;
543 }
544 }
545
546 Row::new(cells)
547 })
548 .collect::<Vec<_>>();
549
550 let table = ratatui::widgets::Table::default()
551 .rows(rows)
552 .widths(widths)
553 .column_spacing(self.spacing.into())
554 .row_highlight_style(ui.theme.highlight(ui.has_focus));
555
556 let table = if !area_focus && self.dim {
557 table.dim()
558 } else {
559 table
560 };
561
562 frame.render_stateful_widget(table, table_area, &mut state.internal);
563
564 if show_scrollbar {
565 let content_length = self.items.len();
566 let scroller = Scrollbar::default()
567 .begin_symbol(None)
568 .track_symbol(None)
569 .end_symbol(None)
570 .thumb_symbol("┃")
571 .style(if ui.has_focus {
572 ui.theme.focus_scroll_style
573 } else {
574 ui.theme.scroll_style
575 });
576
577 let mut state = ScrollbarState::default()
578 .content_length(content_length)
579 .viewport_content_length(1)
580 .position(state.internal.offset());
581
582 frame.render_stateful_widget(scroller, scroller_area, &mut state);
583 }
584 } else if let Some(message) = self.empty_message {
585 let center = layout::centered_rect(area, 50, 10);
586 let hint = Text::from(span::default(&message))
587 .centered()
588 .light_magenta()
589 .dim();
590
591 frame.render_widget(hint, center);
592 }
593
594 *self.selected = state.selected();
595
596 response
597 }
598}
599
600#[derive(Debug)]
601pub struct TreeState<Id>
602where
603 Id: ToString + Clone + Eq + Hash,
604{
605 pub internal: tui_tree_widget::TreeState<Id>,
606}
607
608impl<Id> Clone for TreeState<Id>
609where
610 Id: ToString + Clone + Eq + Hash,
611{
612 fn clone(&self) -> Self {
613 let mut state = tui_tree_widget::TreeState::default();
614 for path in self.internal.opened() {
615 state.open(path.to_vec());
616 }
617 state.select(self.internal.selected().to_vec());
618
619 Self { internal: state }
620 }
621}
622
623pub struct Tree<'a, R, Id>
624where
625 R: ToTree<Id> + Clone,
626 Id: ToString + Clone + Eq + Hash,
627{
628 items: &'a Vec<R>,
630 opened: Option<HashSet<Vec<Id>>>,
633 selected: &'a mut Option<Vec<Id>>,
636 show_scrollbar: bool,
638 dim: bool,
641 borders: Option<Borders>,
643}
644
645impl<'a, R, Id> Tree<'a, R, Id>
646where
647 Id: ToString + Clone + Eq + Hash,
648 R: ToTree<Id> + Clone,
649{
650 pub fn new(
651 items: &'a Vec<R>,
652 opened: &'a Option<HashSet<Vec<Id>>>,
653 selected: &'a mut Option<Vec<Id>>,
654 borders: Option<Borders>,
655 dim: bool,
656 ) -> Self {
657 Self {
658 items,
659 selected,
660 opened: opened.clone(),
661 borders,
662 show_scrollbar: true,
663 dim,
664 }
665 }
666}
667
668impl<R, Id> Widget for Tree<'_, R, Id>
669where
670 R: ToTree<Id> + Clone,
671 Id: ToString + Clone + Eq + Hash,
672{
673 fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response
674 where
675 M: Clone,
676 {
677 let mut response = Response::default();
678 let mut state = TreeState {
679 internal: {
680 let mut state = tui_tree_widget::TreeState::default();
681
682 if let Some(opened) = &self.opened {
683 if opened != state.opened() {
684 state.close_all();
685 for path in opened {
686 state.open(path.to_vec());
687 }
688 }
689 }
690 if let Some(selected) = self.selected {
691 state.select(selected.clone());
692 }
693 state
694 },
695 };
696
697 let mut items = vec![];
698 for item in self.items {
699 items.extend(item.rows());
700 }
701
702 let (area, area_focus) = ui.next_area().unwrap_or_default();
703 let border_style = if area_focus && ui.has_focus {
704 ui.theme.focus_border_style
705 } else {
706 ui.theme.border_style
707 };
708 let area = render_block(frame, area, self.borders, border_style);
709
710 let tree_style = if !area_focus && self.dim {
711 Style::default().dim()
712 } else {
713 Style::default()
714 };
715
716 let show_scrollbar = self.show_scrollbar && self.items.len() >= area.height.into();
717 let tree = if show_scrollbar {
718 tui_tree_widget::Tree::new(&items)
719 .expect("all item identifiers are unique")
720 .block(
721 Block::default()
722 .borders(ratatui::widgets::Borders::RIGHT)
723 .border_set(border::Set {
724 vertical_right: " ",
725 ..Default::default()
726 })
727 .border_style(if area_focus {
728 Style::default()
729 } else {
730 Style::default().dim()
731 }),
732 )
733 .experimental_scrollbar(Some(
734 Scrollbar::new(ScrollbarOrientation::VerticalRight)
735 .begin_symbol(None)
736 .track_symbol(None)
737 .end_symbol(None)
738 .thumb_symbol("┃")
739 .style(if area_focus {
740 ui.theme.focus_scroll_style
741 } else {
742 ui.theme.scroll_style
743 }),
744 ))
745 .highlight_style(ui.theme.highlight(ui.has_focus))
746 .style(tree_style)
747 } else {
748 tui_tree_widget::Tree::new(&items)
749 .expect("all item identifiers are unique")
750 .style(tree_style)
751 .highlight_style(ui.theme.highlight(ui.has_focus))
752 };
753
754 frame.render_stateful_widget(tree, area, &mut state.internal);
755
756 if let Some(key) = ui.get_input(|_| true) {
757 match key {
758 Key::Up | Key::Char('k') => {
759 state.internal.key_up();
760 response.changed = true;
761 }
762 Key::Down | Key::Char('j') => {
763 state.internal.key_down();
764 response.changed = true;
765 }
766 Key::Left | Key::Char('h')
767 if !state.internal.selected().is_empty()
768 && !state.internal.opened().is_empty() =>
769 {
770 state.internal.key_left();
771 response.changed = true;
772 }
773 Key::Right | Key::Char('l') => {
774 state.internal.key_right();
775 response.changed = true;
776 }
777 _ => {}
778 }
779 }
780
781 *self.selected = Some(state.internal.selected().to_vec());
782
783 response
784 }
785}
786
787pub struct ColumnBar<'a> {
788 columns: Vec<Column<'a>>,
789 spacing: Spacing,
790 borders: Option<Borders>,
791}
792
793impl<'a> ColumnBar<'a> {
794 pub fn new(columns: Vec<Column<'a>>, spacing: Spacing, borders: Option<Borders>) -> Self {
795 Self {
796 columns,
797 spacing,
798 borders,
799 }
800 }
801}
802
803impl Widget for ColumnBar<'_> {
804 fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response
805 where
806 M: Clone,
807 {
808 let (area, _) = ui.next_area().unwrap_or_default();
809
810 let border_style = if ui.has_focus {
811 ui.theme.focus_border_style
812 } else {
813 ui.theme.border_style
814 };
815
816 let area = render_block(frame, area, self.borders, border_style);
817 let area = Rect {
818 width: area.width.saturating_sub(1),
819 ..area
820 };
821
822 let widths: Vec<Constraint> = self
823 .columns
824 .iter()
825 .filter_map(|c| {
826 if !c.skip && c.displayed(area.width as usize) {
827 Some(c.width)
828 } else {
829 None
830 }
831 })
832 .collect();
833
834 let cells = self
835 .columns
836 .iter()
837 .filter(|c| !c.skip && c.displayed(area.width as usize))
838 .map(|c| c.text.clone())
839 .collect::<Vec<_>>();
840
841 let table = ratatui::widgets::Table::default()
842 .column_spacing(self.spacing.into())
843 .rows([Row::new(cells)])
844 .widths(widths);
845 frame.render_widget(table, area);
846
847 Response::default()
848 }
849}
850
851pub struct Bar<'a> {
852 columns: Vec<Column<'a>>,
853 borders: Option<Borders>,
854}
855
856impl<'a> Bar<'a> {
857 pub fn new(columns: Vec<Column<'a>>, borders: Option<Borders>) -> Self {
858 Self { columns, borders }
859 }
860}
861
862impl Widget for Bar<'_> {
863 fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response
864 where
865 M: Clone,
866 {
867 let (area, area_focus) = ui.next_area().unwrap_or_default();
868
869 let border_style = if area_focus {
870 ui.theme.focus_border_style
871 } else {
872 ui.theme.border_style
873 };
874
875 let widths = self.columns.iter().map(|c| c.width).collect::<Vec<_>>();
876 let cells = self
877 .columns
878 .iter()
879 .map(|c| c.text.clone())
880 .collect::<Vec<_>>();
881
882 let area = render_block(frame, area, self.borders, border_style);
883 let table = ratatui::widgets::Table::default()
884 .header(Row::new(cells))
885 .widths(widths)
886 .column_spacing(0);
887 frame.render_widget(table, area);
888
889 Response::default()
890 }
891}
892
893#[derive(Clone, Debug, Serialize, Deserialize)]
894pub struct TextViewState {
895 cursor: Position,
896}
897
898impl TextViewState {
899 pub fn new(cursor: Position) -> Self {
900 Self { cursor }
901 }
902
903 pub fn cursor(&self) -> Position {
904 self.cursor
905 }
906}
907
908impl TextViewState {
909 fn scroll_up(&mut self) {
910 self.cursor.x = self.cursor.x.saturating_sub(1);
911 }
912
913 fn scroll_down(&mut self, len: usize, page_size: usize) {
914 let end = len.saturating_sub(page_size);
915 self.cursor.x = std::cmp::min(self.cursor.x.saturating_add(1), end as u16);
916 }
917
918 fn scroll_left(&mut self) {
919 self.cursor.y = self.cursor.y.saturating_sub(3);
920 }
921
922 fn scroll_right(&mut self, max_line_length: usize) {
923 self.cursor.y = std::cmp::min(
924 self.cursor.y.saturating_add(3),
925 max_line_length.saturating_add(3) as u16,
926 );
927 }
928
929 fn prev_page(&mut self, page_size: usize) {
930 self.cursor.x = self.cursor.x.saturating_sub(page_size as u16);
931 }
932
933 fn next_page(&mut self, len: usize, page_size: usize) {
934 let end = len.saturating_sub(page_size);
935
936 self.cursor.x = std::cmp::min(self.cursor.x.saturating_add(page_size as u16), end as u16);
937 }
938
939 fn begin(&mut self) {
940 self.cursor.x = 0;
941 }
942
943 fn end(&mut self, len: usize, page_size: usize) {
944 self.cursor.x = len.saturating_sub(page_size) as u16;
945 }
946}
947
948pub struct TextView<'a> {
949 text: Text<'a>,
950 footer: Option<Text<'a>>,
951 borders: Option<Borders>,
952 cursor: &'a mut Position,
953}
954
955impl<'a> TextView<'a> {
956 pub fn new(
957 text: impl Into<Text<'a>>,
958 footer: Option<impl Into<Text<'a>>>,
959 cursor: &'a mut Position,
960 borders: Option<Borders>,
961 ) -> Self {
962 Self {
963 text: text.into(),
964 footer: footer.map(|f| f.into()),
965 borders,
966 cursor,
967 }
968 }
969}
970
971impl Widget for TextView<'_> {
972 fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response
973 where
974 M: Clone,
975 {
976 let mut response = Response::default();
977
978 let (area, area_focus) = ui.next_area().unwrap_or_default();
979
980 let show_scrollbar = true;
981 let border_style = if area_focus && ui.has_focus() {
982 ui.theme.focus_border_style
983 } else {
984 ui.theme.border_style
985 };
986 let length = self.text.lines.len();
987 let content_length = area.height as usize;
988
989 let area = render_block(frame, area, self.borders, border_style);
990 let area = Rect {
991 x: area.x.saturating_add(1),
992 width: area.width.saturating_sub(1),
993 ..area
994 };
995 let [text_area, scroller_area] = Layout::horizontal([
996 Constraint::Min(1),
997 if show_scrollbar {
998 Constraint::Length(1)
999 } else {
1000 Constraint::Length(0)
1001 },
1002 ])
1003 .areas(area);
1004 let [text_area, footer_area] = Layout::vertical([
1005 Constraint::Min(1),
1006 if self.footer.is_some() {
1007 Constraint::Length(1)
1008 } else {
1009 Constraint::Length(0)
1010 },
1011 ])
1012 .areas(text_area);
1013
1014 let scroller = Scrollbar::default()
1015 .begin_symbol(None)
1016 .track_symbol(None)
1017 .end_symbol(None)
1018 .thumb_symbol("┃")
1019 .style(if area_focus {
1020 ui.theme.focus_scroll_style
1021 } else {
1022 ui.theme.scroll_style
1023 });
1024
1025 let mut scroller_state = ScrollbarState::default()
1026 .content_length(length.saturating_sub(content_length))
1027 .viewport_content_length(1)
1028 .position(self.cursor.x as usize);
1029
1030 frame.render_stateful_widget(scroller, scroller_area, &mut scroller_state);
1031 frame.render_widget(
1032 Paragraph::new(self.text.clone()).scroll((self.cursor.x, self.cursor.y)),
1033 text_area,
1034 );
1035 if let Some(footer) = self.footer {
1036 frame.render_widget(Paragraph::new(footer.clone()), footer_area);
1037 }
1038
1039 let mut state = TextViewState::new(*self.cursor);
1040
1041 if let Some(key) = ui.get_input(|_| true) {
1042 let lines = self.text.lines.clone();
1043 let len = lines.clone().len();
1044 let max_line_len = lines
1045 .into_iter()
1046 .map(|l| l.to_string().chars().count())
1047 .max()
1048 .unwrap_or_default();
1049 let page_size = area.height as usize;
1050
1051 match key {
1052 Key::Up | Key::Char('k') => {
1053 state.scroll_up();
1054 }
1055 Key::Down | Key::Char('j') => {
1056 state.scroll_down(len, page_size);
1057 }
1058 Key::Left | Key::Char('h') => {
1059 state.scroll_left();
1060 }
1061 Key::Right | Key::Char('l') => {
1062 state.scroll_right(max_line_len.saturating_sub(area.height.into()));
1063 }
1064 Key::PageUp => {
1065 state.prev_page(page_size);
1066 }
1067 Key::PageDown => {
1068 state.next_page(len, page_size);
1069 }
1070 Key::Home => {
1071 state.begin();
1072 }
1073 Key::End => {
1074 state.end(len, page_size);
1075 }
1076 _ => {}
1077 }
1078 *self.cursor = state.cursor;
1079 response.changed = true;
1080 }
1081
1082 response
1083 }
1084}
1085
1086pub struct CenteredTextView<'a> {
1087 content: Text<'a>,
1088 borders: Option<Borders>,
1089}
1090
1091impl<'a> CenteredTextView<'a> {
1092 pub fn new(content: impl Into<Text<'a>>, borders: Option<Borders>) -> Self {
1093 Self {
1094 content: content.into(),
1095 borders,
1096 }
1097 }
1098}
1099
1100impl Widget for CenteredTextView<'_> {
1101 fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response {
1102 let (area, area_focus) = ui.next_area().unwrap_or_default();
1103
1104 let border_style = if area_focus && ui.has_focus() {
1105 ui.theme.focus_border_style
1106 } else {
1107 ui.theme.border_style
1108 };
1109
1110 let area = render_block(frame, area, self.borders, border_style);
1111 let area = Rect {
1112 x: area.x.saturating_add(1),
1113 width: area.width.saturating_sub(1),
1114 ..area
1115 };
1116 let center = layout::centered_rect(area, 50, 10);
1117
1118 frame.render_widget(self.content.centered(), center);
1119
1120 Response::default()
1121 }
1122}
1123
1124#[derive(Clone, Debug, Serialize, Deserialize)]
1125pub struct TextEditState {
1126 pub text: String,
1127 pub cursor: usize,
1128}
1129
1130impl TextEditState {
1131 fn move_cursor_left(&mut self) {
1132 let cursor_moved_left = self.cursor.saturating_sub(1);
1133 self.cursor = self.clamp_cursor(cursor_moved_left);
1134 }
1135
1136 fn move_cursor_right(&mut self) {
1137 let cursor_moved_right = self.cursor.saturating_add(1);
1138 self.cursor = self.clamp_cursor(cursor_moved_right);
1139 }
1140
1141 fn enter_char(&mut self, new_char: char) {
1142 self.text = self.text.clone();
1143 self.text.insert(self.cursor, new_char);
1144 self.move_cursor_right();
1145 }
1146
1147 fn delete_char_right(&mut self) {
1148 self.text = self.text.clone();
1149
1150 let current_index = self.cursor;
1155 let from_left_to_current_index = current_index;
1156
1157 let before_char_to_delete = self.text.chars().take(from_left_to_current_index);
1159 let after_char_to_delete = self.text.chars().skip(current_index.saturating_add(1));
1161
1162 self.text = before_char_to_delete.chain(after_char_to_delete).collect();
1165 }
1166
1167 fn delete_char_left(&mut self) {
1168 self.text = self.text.clone();
1169
1170 let is_not_cursor_leftmost = self.cursor != 0;
1171 if is_not_cursor_leftmost {
1172 let current_index = self.cursor;
1177 let from_left_to_current_index = current_index - 1;
1178
1179 let before_char_to_delete = self.text.chars().take(from_left_to_current_index);
1181 let after_char_to_delete = self.text.chars().skip(current_index);
1183
1184 self.text = before_char_to_delete.chain(after_char_to_delete).collect();
1187
1188 self.move_cursor_left();
1189 }
1190 }
1191
1192 fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
1193 new_cursor_pos.clamp(0, self.text.len())
1194 }
1195}
1196
1197pub struct TextEditOutput {
1198 pub response: Response,
1199 pub state: TextEditState,
1200}
1201
1202pub struct TextEdit<'a> {
1203 text: &'a mut String,
1204 cursor: &'a mut usize,
1205 borders: Option<Borders>,
1206 label: Option<String>,
1207 inline_label: bool,
1208 show_cursor: bool,
1209 dim: bool,
1210}
1211
1212impl<'a> TextEdit<'a> {
1213 pub fn new(text: &'a mut String, cursor: &'a mut usize, borders: Option<Borders>) -> Self {
1214 Self {
1215 text,
1216 cursor,
1217 label: None,
1218 borders,
1219 inline_label: true,
1220 show_cursor: true,
1221 dim: true,
1222 }
1223 }
1224
1225 pub fn with_label(mut self, label: impl ToString) -> Self {
1226 self.label = Some(label.to_string());
1227 self
1228 }
1229}
1230
1231impl TextEdit<'_> {
1232 pub fn show<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> TextEditOutput
1233 where
1234 M: Clone,
1235 {
1236 let mut response = Response::default();
1237
1238 let (area, area_focus) = ui.next_area().unwrap_or_default();
1239
1240 let border_style = if area_focus && ui.has_focus() {
1241 ui.theme.focus_border_style
1242 } else {
1243 ui.theme.border_style
1244 };
1245
1246 let area = render_block(frame, area, self.borders, border_style);
1247
1248 let layout = Layout::vertical(Constraint::from_lengths([1, 1])).split(area);
1249
1250 let mut state = TextEditState {
1251 text: self.text.to_string(),
1252 cursor: *self.cursor,
1253 };
1254
1255 let label_content = format!(" {} ", self.label.unwrap_or_default());
1256 let overline = String::from("▔").repeat(area.width as usize);
1257 let cursor_pos = *self.cursor as u16;
1258
1259 let (label, input, overline) = if !area_focus && self.dim {
1260 (
1261 Span::from(label_content.clone()).magenta().dim().reversed(),
1262 Span::from(state.text.clone()).reset().dim(),
1263 Span::raw(overline).magenta().dim(),
1264 )
1265 } else {
1266 (
1267 Span::from(label_content.clone()).magenta().reversed(),
1268 Span::from(state.text.clone()).reset(),
1269 Span::raw(overline).magenta(),
1270 )
1271 };
1272
1273 if self.inline_label {
1274 let top_layout = Layout::horizontal([
1275 Constraint::Length(label_content.chars().count() as u16),
1276 Constraint::Length(1),
1277 Constraint::Min(1),
1278 ])
1279 .split(layout[0]);
1280
1281 let overline = Line::from([overline].to_vec());
1282
1283 frame.render_widget(label, top_layout[0]);
1284 frame.render_widget(input, top_layout[2]);
1285 frame.render_widget(overline, layout[1]);
1286
1287 if self.show_cursor {
1288 let position = Position::new(top_layout[2].x + cursor_pos, top_layout[2].y);
1289 frame.set_cursor_position(position)
1290 }
1291 } else {
1292 let top = Line::from([input].to_vec());
1293 let bottom = Line::from([label, overline].to_vec());
1294
1295 frame.render_widget(top, layout[0]);
1296 frame.render_widget(bottom, layout[1]);
1297
1298 if self.show_cursor {
1299 let position = Position::new(area.x + cursor_pos, area.y);
1300 frame.set_cursor_position(position);
1301 }
1302 }
1303
1304 if let Some(key) = ui.get_input(|_| true) {
1305 match key {
1306 Key::Char(to_insert)
1307 if (key != Key::Alt('\n'))
1308 && (key != Key::Char('\n'))
1309 && (key != Key::Ctrl('\n')) =>
1310 {
1311 state.enter_char(to_insert);
1312 }
1313 Key::Backspace => {
1314 state.delete_char_left();
1315 }
1316 Key::Delete => {
1317 state.delete_char_right();
1318 }
1319 Key::Left => {
1320 state.move_cursor_left();
1321 }
1322 Key::Right => {
1323 state.move_cursor_right();
1324 }
1325 _ => {}
1326 }
1327 response.changed = true;
1328 }
1329
1330 *self.text = state.text.clone();
1331 *self.cursor = state.cursor;
1332
1333 TextEditOutput { response, state }
1334 }
1335}
1336
1337impl Widget for TextEdit<'_> {
1338 fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response
1339 where
1340 M: Clone,
1341 {
1342 self.show(ui, frame).response
1343 }
1344}
1345
1346pub struct Shortcuts {
1347 pub shortcuts: Vec<(String, String)>,
1348 pub divider: char,
1349 pub alignment: Alignment,
1350}
1351
1352impl Shortcuts {
1353 pub fn new(shortcuts: &[(&str, &str)], divider: char, alignment: Alignment) -> Self {
1354 Self {
1355 shortcuts: shortcuts
1356 .iter()
1357 .map(|(s, a)| (s.to_string(), a.to_string()))
1358 .collect(),
1359 divider,
1360 alignment,
1361 }
1362 }
1363}
1364
1365impl Widget for Shortcuts {
1366 fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response
1367 where
1368 M: Clone,
1369 {
1370 use ratatui::widgets::Table;
1371
1372 let (area, _) = ui.next_area().unwrap_or_default();
1373
1374 let mut shortcuts = self.shortcuts.iter().peekable();
1375 let mut row = vec![];
1376
1377 while let Some(shortcut) = shortcuts.next() {
1378 let short = Text::from(shortcut.0.clone())
1379 .style(ui.theme.shortcuts_keys_style)
1380 .bold();
1381 let long = Text::from(shortcut.1.clone()).style(ui.theme.shortcuts_action_style);
1382 let spacer = Text::from(String::new());
1383 let divider = Text::from(format!(" {} ", self.divider)).style(style::gray().dim());
1384
1385 row.push((shortcut.0.chars().count(), short));
1386 row.push((1, spacer));
1387 row.push((shortcut.1.chars().count(), long));
1388
1389 if shortcuts.peek().is_some() {
1390 row.push((3, divider));
1391 }
1392 }
1393
1394 let row_copy = row.clone();
1395 let row: Vec<Text<'_>> = row_copy
1396 .clone()
1397 .iter()
1398 .map(|(_, text)| text.clone())
1399 .collect();
1400 let widths: Vec<Constraint> = row_copy
1401 .clone()
1402 .iter()
1403 .map(|(width, _)| Constraint::Length(*width as u16))
1404 .collect();
1405
1406 let (row, widths) = match self.alignment {
1407 Alignment::Left => ([row.as_slice(), &[Text::from("")]].concat(), widths),
1408 Alignment::Center => (
1409 [&[Text::from("")], row.as_slice(), &[Text::from("")]].concat(),
1410 [
1411 &[Constraint::Fill(1)],
1412 widths.as_slice(),
1413 &[Constraint::Fill(1)],
1414 ]
1415 .concat(),
1416 ),
1417 Alignment::Right => (
1418 [&[Text::from("")], row.as_slice()].concat(),
1419 [&[Constraint::Fill(1)], widths.as_slice()].concat(),
1420 ),
1421 };
1422
1423 let table = Table::new([Row::new(row)], widths).column_spacing(0);
1424
1425 frame.render_widget(table, area);
1426
1427 Response::default()
1428 }
1429}
1430
1431fn render_block(frame: &mut Frame, area: Rect, borders: Option<Borders>, style: Style) -> Rect {
1432 if let Some(border) = borders {
1433 match border {
1434 Borders::None => area,
1435 Borders::Spacer { top, left } => {
1436 let areas = Layout::horizontal([Constraint::Fill(1)])
1437 .vertical_margin(top as u16)
1438 .horizontal_margin(left as u16)
1439 .split(area);
1440
1441 areas[0]
1442 }
1443 Borders::All => {
1444 let block = Block::default()
1445 .border_style(style)
1446 .border_type(BorderType::Rounded)
1447 .borders(ratatui::widgets::Borders::ALL);
1448 frame.render_widget(block.clone(), area);
1449
1450 block.inner(area)
1451 }
1452 Borders::Top => {
1453 let block = HeaderBlock::default()
1454 .border_style(style)
1455 .border_type(BorderType::Rounded)
1456 .borders(ratatui::widgets::Borders::ALL);
1457 frame.render_widget(block, area);
1458
1459 let areas = Layout::default()
1460 .direction(Direction::Vertical)
1461 .constraints(vec![Constraint::Min(1)])
1462 .vertical_margin(1)
1463 .horizontal_margin(1)
1464 .split(area);
1465
1466 areas[0]
1467 }
1468 Borders::Sides => {
1469 let block = Block::default()
1470 .border_style(style)
1471 .border_type(BorderType::Rounded)
1472 .borders(ratatui::widgets::Borders::LEFT | ratatui::widgets::Borders::RIGHT);
1473 frame.render_widget(block.clone(), area);
1474
1475 block.inner(area)
1476 }
1477 Borders::Bottom => {
1478 let areas = Layout::default()
1479 .direction(Direction::Vertical)
1480 .constraints(vec![Constraint::Min(1)])
1481 .vertical_margin(1)
1482 .horizontal_margin(1)
1483 .split(area);
1484
1485 let footer_block = FooterBlock::default()
1486 .border_style(style)
1487 .block_type(FooterBlockType::Single { top: true });
1488 frame.render_widget(footer_block, area);
1489
1490 areas[0]
1491 }
1492 Borders::BottomSides => {
1493 let areas = Layout::default()
1494 .direction(Direction::Vertical)
1495 .constraints(vec![Constraint::Min(1)])
1496 .horizontal_margin(1)
1497 .split(area);
1498
1499 let footer_block = FooterBlock::default()
1500 .border_style(style)
1501 .block_type(FooterBlockType::Single { top: false });
1502 frame.render_widget(footer_block, area);
1503
1504 Rect {
1505 height: areas[0].height.saturating_sub(1),
1506 ..areas[0]
1507 }
1508 }
1509 }
1510 } else {
1511 area
1512 }
1513}