1use ratatui::{
2 layout::Flex,
3 prelude::*,
4 widgets::{Block, Paragraph, Row, Table, TableState},
5};
6use ratatui_macros::{constraints, horizontal, vertical};
7use tui_big_text::{BigTextBuilder, PixelSize};
8
9pub type AppResult<T> = std::result::Result<T, Box<dyn std::error::Error>>;
11
12#[derive(Debug, Default, Clone)]
14pub struct Dashboard<'a> {
15 general_title: String,
16 subtitle: String,
17 avatar: String,
18 avatar_block: Option<Block<'a>>,
19 table: Vec<TableItem>,
20 table_block: Option<Block<'a>>,
21 footer: Vec<String>,
22}
23
24#[derive(Debug, Default, Clone)]
25pub struct TableItem {
26 left: String,
27 right: String,
28}
29
30impl TableItem {
31 pub fn new<I: Into<String>>(i: (I, I)) -> Self {
32 Self {
33 left: i.0.into(),
34 right: i.1.into(),
35 }
36 }
37}
38
39impl<T> From<(T, T)> for TableItem
40where
41 T: Into<String>,
42{
43 fn from(value: (T, T)) -> Self {
44 Self::new(value)
45 }
46}
47
48#[derive(Default)]
49pub struct DashboardBuilder<'a>(Dashboard<'a>);
50
51impl<'a> DashboardBuilder<'a> {
52 pub fn new() -> Self {
53 Self::default()
54 }
55
56 pub fn general_title(mut self, general_title: impl Into<String>) -> Self {
57 self.0.general_title = general_title.into();
58 self
59 }
60
61 pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
62 self.0.subtitle = subtitle.into();
63 self
64 }
65
66 pub fn avatar(mut self, avatar: impl Into<String>) -> Self {
67 self.0.avatar = avatar.into();
68 self
69 }
70
71 pub fn avatar_block(mut self, block: impl Into<Option<Block<'a>>>) -> Self {
72 self.0.avatar_block = block.into();
73 self
74 }
75
76 pub fn table(mut self, table: Vec<impl Into<TableItem>>) -> Self {
77 self.0.table = table.into_iter().map(|i| i.into()).collect();
78 self
79 }
80
81 pub fn table_block(mut self, block: impl Into<Option<Block<'a>>>) -> Self {
82 self.0.table_block = block.into();
83 self
84 }
85
86 pub fn footer(mut self, footer: Vec<impl Into<String>>) -> Self {
87 self.0.footer = footer.into_iter().map(|i| i.into()).collect();
88 self
89 }
90
91 pub fn build(self) -> Dashboard<'a> {
92 self.0
94 }
95}
96
97impl Dashboard<'_> {
98 fn render_header(&self, area: Rect, buf: &mut Buffer) {
99 let [top_1, top_2] = vertical![==10%, ==5%]
100 .vertical_margin(1)
101 .split(area)
102 .to_vec()
103 .try_into()
104 .unwrap();
105
106 let top_center_1 = horizontal![==(self.general_title.chars().count() as u16 * 4)]
107 .flex(Flex::Center)
108 .split(top_1)[0];
109
110 let top_center_2 = horizontal![==(self.subtitle.chars().count() as u16)]
111 .flex(Flex::Center)
112 .split(top_2)[0];
113
114 let title = Line::raw(&self.general_title).style(Style::new().light_red());
115 let title = BigTextBuilder::default()
116 .pixel_size(PixelSize::Quadrant)
117 .lines(vec![title])
118 .build()
119 .unwrap();
120
121 let subtitle = Line::raw(&self.subtitle).style(Style::new().blue().bold());
122 title.render(top_center_1, buf);
123 subtitle.render(top_center_2, buf);
124 }
125
126 fn render_table<'a, 'b: 'a>(&'b mut self, area: Rect, buf: &mut Buffer, state: &mut TableState) {
127 let [_, main] = horizontal![==36%, ==57%]
128 .horizontal_margin(3)
129 .split(area)
130 .to_vec()
131 .try_into()
132 .unwrap();
133
134 let lines = {
135 let to_line = |(idx, a): (usize, &'a TableItem)| {
136 let style = match state.selected() {
137 Some(i) if i == idx => Style::default().italic().bold().underlined(),
138 _ => Style::default(),
139 };
140 let description = a.left.as_str().blue().style(style);
141 let keyboard = a.right.as_str().to_uppercase().light_red().bold();
142 Line::default().spans(vec![description, keyboard]).style(style)
143 };
144 self.table.iter().enumerate().map(to_line)
145 };
146
147 let mut table_width = 0;
148 let mut table_height = 0;
149 for i in lines.clone() {
150 table_width = table_width.max(i.width() + 2);
151 table_height += 1;
152 }
153
154 let table = {
155 let rows = lines.map(Row::new);
156 let widths = constraints![==95%, ==5%];
157 let block = self.table_block.clone().unwrap_or(Block::bordered());
158 Table::new(rows, widths).highlight_symbol(" >> ").cyan().block(block)
159 };
160
161 let [_, main] = vertical![==20%, ==table_height + 2].split(main).to_vec().try_into().unwrap();
162 StatefulWidget::render(table, main, buf, state);
163 }
164
165 fn render_avatar(&self, area: Rect, buf: &mut Buffer) {
166 let lines = self.avatar.lines().map(|i| Line::from(i.light_blue())).collect::<Vec<_>>();
167 let mut avatar_width = 0;
168 let mut avatar_height = 0;
169 for line in lines.iter() {
170 avatar_width = avatar_width.max(line.width());
171 avatar_height += 1;
172 }
173 let [_, left] = horizontal![==8%, ==avatar_width as u16]
174 .split(area)
175 .to_vec()
176 .try_into()
177 .unwrap();
178 let [_, left] = vertical![==20%, ==avatar_height as u16]
179 .split(left)
180 .to_vec()
181 .try_into()
182 .unwrap();
183 let block = self.avatar_block.clone().unwrap_or(Block::bordered());
184 let avatar = Paragraph::new(lines).block(block);
185 avatar.render(left, buf);
186 }
187 fn render_footer(&self, area: Rect, buf: &mut Buffer) {
188 let [_, bottom] = vertical![==95%, ==5%]
189 .vertical_margin(1)
190 .split(area)
191 .to_vec()
192 .try_into()
193 .unwrap();
194 let lines = self.footer.iter().map(Line::raw).collect::<Vec<_>>();
195 let footer = Paragraph::new(lines).centered().bold().light_cyan().italic();
196 footer.render(bottom, buf);
197 }
198}
199
200impl StatefulWidget for Dashboard<'_> {
201 type State = TableState;
202
203 fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
204 self.render_header(area, buf);
205 self.render_table(area, buf, state);
206 self.render_avatar(area, buf);
207 self.render_footer(area, buf);
208 }
209}