1use crossterm::{
2 event::{self, Event, KeyCode, MouseEventKind},
3 execute,
4 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
5};
6use ratatui::{
7 backend::{Backend, CrosstermBackend},
8 layout::{Alignment, Constraint, Direction, Layout},
9 style::{Color, Modifier, Style},
10 widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
11 Frame, Terminal,
12};
13use std::{
14 io,
15 time::{Duration, Instant},
16};
17use tracing::error;
18
19static CMDS: [(&str, &str); 6] = [
21 ("q", "Quit"),
22 ("↑", "Entry above"),
23 ("↓", "Entry below"),
24 ("←", "Previous page"),
25 ("→", "Next page"),
26 ("G", "Go to a specific page"),
27];
28
29#[derive(Default, Eq, PartialEq)]
30pub(crate) enum ViewMode {
31 #[default]
33 Normal,
34 GoToPage,
36}
37
38pub(crate) type PageFetcher<'a> = Box<dyn FnMut(usize, usize) -> Vec<(String, String)> + 'a>;
40
41pub(crate) struct DbListTUI<'a> {
42 fetch: PageFetcher<'a>,
47 skip: usize,
49 count: usize,
51 total_entries: usize,
53 mode: ViewMode,
55 input: String,
57 list_state: ListState,
59 entries: Vec<(String, String)>,
61}
62
63impl<'a> DbListTUI<'a> {
64 pub(crate) fn new(
66 fetch: PageFetcher<'a>,
67 skip: usize,
68 count: usize,
69 total_entries: usize,
70 ) -> Self {
71 Self {
72 fetch,
73 skip,
74 count,
75 total_entries,
76 mode: ViewMode::Normal,
77 input: String::new(),
78 list_state: ListState::default(),
79 entries: Vec::new(),
80 }
81 }
82
83 fn next(&mut self) {
85 self.list_state.select(Some(
86 self.list_state
87 .selected()
88 .map(|i| if i >= self.entries.len() - 1 { 0 } else { i + 1 })
89 .unwrap_or(0),
90 ));
91 }
92
93 fn previous(&mut self) {
95 self.list_state.select(Some(
96 self.list_state
97 .selected()
98 .map(|i| if i == 0 { self.entries.len() - 1 } else { i - 1 })
99 .unwrap_or(0),
100 ));
101 }
102
103 fn reset(&mut self) {
104 self.list_state.select(Some(0));
105 }
106
107 fn next_page(&mut self) {
109 if self.skip + self.count < self.total_entries {
110 self.skip += self.count;
111 self.fetch_page();
112 }
113 }
114
115 fn previous_page(&mut self) {
117 if self.skip > 0 {
118 self.skip = self.skip.saturating_sub(self.count);
119 self.fetch_page();
120 }
121 }
122
123 fn go_to_page(&mut self, page: usize) {
125 self.skip = (self.count * page).min(self.total_entries - self.count);
126 self.fetch_page();
127 }
128
129 fn fetch_page(&mut self) {
131 self.entries = (self.fetch)(self.skip, self.count);
132 self.reset();
133 }
134
135 pub(crate) fn run(mut self) -> eyre::Result<()> {
137 enable_raw_mode()?;
139 let mut stdout = io::stdout();
140 execute!(stdout, EnterAlternateScreen)?;
141 let backend = CrosstermBackend::new(stdout);
142 let mut terminal = Terminal::new(backend)?;
143
144 self.fetch_page();
146
147 let tick_rate = Duration::from_millis(250);
149 let res = event_loop(&mut terminal, &mut self, tick_rate);
150
151 disable_raw_mode()?;
153 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
154 terminal.show_cursor()?;
155
156 if let Err(err) = res {
158 error!("{err}");
159 }
160
161 Ok(())
162 }
163}
164
165fn event_loop<B: Backend>(
167 terminal: &mut Terminal<B>,
168 app: &mut DbListTUI<'_>,
169 tick_rate: Duration,
170) -> io::Result<()>
171where
172 io::Error: From<B::Error>,
173{
174 let mut last_tick = Instant::now();
175 let mut running = true;
176 while running {
177 terminal.draw(|f| ui(f, app))?;
179
180 let timeout =
182 tick_rate.checked_sub(last_tick.elapsed()).unwrap_or_else(|| Duration::from_secs(0));
183
184 if crossterm::event::poll(timeout)? {
186 running = !handle_event(app, event::read()?)?;
187 }
188
189 if last_tick.elapsed() >= tick_rate {
190 last_tick = Instant::now();
191 }
192 }
193
194 Ok(())
195}
196
197fn handle_event(app: &mut DbListTUI<'_>, event: Event) -> io::Result<bool> {
199 if app.mode == ViewMode::GoToPage {
200 if let Event::Key(key) = event {
201 match key.code {
202 KeyCode::Enter => {
203 let input = std::mem::take(&mut app.input);
204 if let Ok(page) = input.parse() {
205 app.go_to_page(page);
206 }
207 app.mode = ViewMode::Normal;
208 }
209 KeyCode::Char(c) => {
210 app.input.push(c);
211 }
212 KeyCode::Backspace => {
213 app.input.pop();
214 }
215 KeyCode::Esc => app.mode = ViewMode::Normal,
216 _ => {}
217 }
218 }
219
220 return Ok(false)
221 }
222
223 match event {
224 Event::Key(key) if key.kind == event::KeyEventKind::Press => match key.code {
225 KeyCode::Char('q') | KeyCode::Char('Q') => return Ok(true),
226 KeyCode::Down => app.next(),
227 KeyCode::Up => app.previous(),
228 KeyCode::Right => app.next_page(),
229 KeyCode::Left => app.previous_page(),
230 KeyCode::Char('G') => {
231 app.mode = ViewMode::GoToPage;
232 }
233 _ => {}
234 },
235 Event::Key(_) => {}
236 Event::Mouse(e) => match e.kind {
237 MouseEventKind::ScrollDown => app.next(),
238 MouseEventKind::ScrollUp => app.previous(),
239 MouseEventKind::Down(_) => {
241 let new_idx = (e.row as usize + app.list_state.offset()).saturating_sub(1);
242 if new_idx < app.entries.len() {
243 app.list_state.select(Some(new_idx));
244 }
245 }
246 _ => {}
247 },
248 _ => {}
249 }
250
251 Ok(false)
252}
253
254fn ui(f: &mut Frame<'_>, app: &mut DbListTUI<'_>) {
256 let outer_chunks = Layout::default()
257 .direction(Direction::Vertical)
258 .constraints([Constraint::Percentage(95), Constraint::Percentage(5)].as_ref())
259 .split(f.area());
260
261 {
263 let inner_chunks = Layout::default()
264 .direction(Direction::Horizontal)
265 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
266 .split(outer_chunks[0]);
267
268 let key_length = format!("{}", (app.skip + app.count).saturating_sub(1)).len();
269
270 let formatted_keys = app
271 .entries
272 .iter()
273 .enumerate()
274 .map(|(i, (k, _))| {
275 ListItem::new(format!("[{:0>width$}]: {k}", i + app.skip, width = key_length))
276 })
277 .collect::<Vec<_>>();
278
279 let key_list = List::new(formatted_keys)
280 .block(Block::default().borders(Borders::ALL).title(format!(
281 "Keys (Showing entries {}-{} out of {} entries)",
282 app.skip,
283 (app.skip + app.entries.len()).saturating_sub(1),
284 app.total_entries
285 )))
286 .style(Style::default().fg(Color::White))
287 .highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::ITALIC))
288 .highlight_symbol("➜ ");
289 f.render_stateful_widget(key_list, inner_chunks[0], &mut app.list_state);
290
291 let value_display = Paragraph::new(
292 app.list_state
293 .selected()
294 .and_then(|selected| app.entries.get(selected))
295 .map_or("No value selected", |(_, value)| value),
296 )
297 .block(Block::default().borders(Borders::ALL).title("Value (JSON)"))
298 .wrap(Wrap { trim: false })
299 .alignment(Alignment::Left);
300 f.render_widget(value_display, inner_chunks[1]);
301 }
302
303 let footer = match app.mode {
305 ViewMode::Normal => Paragraph::new(
306 CMDS.iter().map(|(k, v)| format!("[{k}] {v}")).collect::<Vec<_>>().join(" | "),
307 ),
308 ViewMode::GoToPage => Paragraph::new(format!(
309 "Go to page (max {}): {}",
310 app.total_entries / app.count,
311 app.input
312 )),
313 }
314 .block(Block::default().borders(Borders::ALL))
315 .alignment(match app.mode {
316 ViewMode::Normal => Alignment::Center,
317 ViewMode::GoToPage => Alignment::Left,
318 })
319 .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
320 f.render_widget(footer, outer_chunks[1]);
321}