1use ltrait::{
2 color_eyre::eyre::{OptionExt, Result, WrapErr, bail},
3 launcher::batcher::Batcher,
4 tokio_stream::StreamExt as _,
5 ui::{Buffer, Position, UI},
6};
7
8use crossterm::{
9 event::{Event as CEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
10 execute,
11 terminal::{disable_raw_mode, enable_raw_mode},
12};
13use ratatui::{
14 Frame, Terminal, TerminalOptions,
15 layout::{Constraint, Direction, Layout},
16 prelude::{Backend, CrosstermBackend},
17 style::Style,
18 widgets::{Block, Borders, Clear, List, Paragraph, Widget},
19};
20use tracing::{debug, info};
21use tui_input::{Input, backend::crossterm::EventHandler};
22
23pub use ratatui::{Viewport, style};
24
25use futures::{FutureExt as _, select};
26use tokio::sync::mpsc;
27
28use std::{io::Write, sync::RwLock};
29
30pub struct Tui<F>
31where
32 F: Fn(&KeyEvent) -> Action + Clone,
33{
34 config: TuiConfig<F>,
35}
36
37impl<Cushion, F> UI<Cushion> for Tui<F>
38where
39 F: Fn(&KeyEvent) -> Action + Send + Sync + Clone,
40 Cushion: Sync + Send + 'static,
41{
42 type Context = TuiEntry;
43
44 async fn run(&self, mut batcher: Batcher<Cushion, Self::Context>) -> Result<Option<Cushion>> {
45 let writer: Box<dyn Write + Send> = if self.config.use_tty {
46 let tty = std::fs::OpenOptions::new()
47 .read(true)
48 .write(true)
49 .open("/dev/tty")?;
50 Box::new(tty)
51 } else {
52 Box::new(std::io::stdout())
53 };
54
55 let backend = CrosstermBackend::new(writer);
56
57 let mut terminal = Terminal::with_options(
58 backend,
59 TerminalOptions {
60 viewport: self.config.viewport.clone(),
61 },
62 )?;
63
64 self.enter(&mut terminal)?;
65
66 let i = App::new(self.config.clone())
67 .run(&mut terminal, &mut batcher)
68 .await;
69
70 self.exit(&mut terminal)?;
71
72 Ok(if let Some(id) = i? {
73 Some(batcher.compute_cushion(id)?)
74 } else {
75 None
76 })
77 }
78}
79
80impl<F> Tui<F>
81where
82 F: Fn(&KeyEvent) -> Action + Clone,
83{
84 pub fn new(config: TuiConfig<F>) -> Self {
85 Self { config }
86 }
87
88 fn enter<B: Backend + Write>(&self, terminal: &mut Terminal<B>) -> Result<()> {
89 use ratatui::Viewport;
90
91 match &self.config.viewport {
92 Viewport::Fullscreen => {
93 execute!(
94 terminal.backend_mut(),
95 crossterm::terminal::EnterAlternateScreen,
96 crossterm::event::EnableMouseCapture
97 )?;
98 enable_raw_mode()?;
99 terminal.clear()?;
100 }
101 Viewport::Inline(_) | Viewport::Fixed(_) => {
102 enable_raw_mode()?;
103 }
104 }
105
106 Ok(())
107 }
108
109 fn exit<B: Backend + Write>(&self, terminal: &mut Terminal<B>) -> Result<()> {
110 match &self.config.viewport {
111 Viewport::Fullscreen => {
112 execute!(
113 terminal.backend_mut(),
114 crossterm::terminal::LeaveAlternateScreen,
115 crossterm::event::DisableMouseCapture
116 )?;
117 disable_raw_mode()?;
118 ratatui::restore();
119 }
120 Viewport::Inline(_) | Viewport::Fixed(_) => {
121 disable_raw_mode()?;
122 }
123 }
124
125 Ok(())
126 }
127}
128
129#[derive(Clone)]
130pub struct TuiConfig<F>
131where
132 F: Fn(&KeyEvent) -> Action + Clone,
133{
134 viewport: Viewport,
135 use_tty: bool,
136 selecting: char,
137 no_selecting: char,
138 keybinder: F,
139}
140
141impl<F> TuiConfig<F>
142where
143 F: Fn(&KeyEvent) -> Action + Clone,
144{
145 pub fn new(
146 viewport: Viewport,
147 use_tty: bool,
148 selecting: char,
149 no_selecting: char,
150 keybinder: F,
151 ) -> Self {
152 Self {
153 viewport,
154 use_tty,
155 selecting,
156 no_selecting,
157 keybinder,
158 }
159 }
160}
161
162type StyledText = (String, Style);
163
164pub struct TuiEntry {
167 pub text: StyledText,
168}
169
170struct App<F>
172where
173 F: Fn(&KeyEvent) -> Action + Clone,
174{
175 config: TuiConfig<F>,
176
177 exit: bool,
178 selecting_i: usize,
180 input: Input,
181 cursor_pos: RwLock<Option<(u16, u16)>>,
182 buffer: Buffer<(TuiEntry, usize)>,
183 has_more: bool,
184 tx: Option<mpsc::Sender<Event>>,
185 selected: bool,
186}
187
188impl<F> App<F>
189where
190 F: Fn(&KeyEvent) -> Action + Clone,
191{
192 fn new(config: TuiConfig<F>) -> Self {
193 Self {
194 has_more: true,
195 config,
196 exit: false,
197 selecting_i: 0,
198 input: Input::default(),
199 buffer: Buffer::default(),
200 tx: None,
201 cursor_pos: None.into(),
202 selected: false,
203 }
204 }
205}
206
207#[derive(Debug)]
208enum Event {
209 Key(KeyEvent),
210 Refresh,
211 Input,
212}
213
214#[derive(Debug, Clone)]
215pub enum Action {
216 Select,
217 ExitWithoutSelect,
218 Up,
219 Down,
220 Input,
221}
222
223impl Event {
224 async fn terminal_event_listener(tx: mpsc::Sender<Event>) {
225 let mut reader = crossterm::event::EventStream::new();
226
227 loop {
228 let crossterm_event = reader.next().fuse();
229 std::thread::sleep(std::time::Duration::from_millis(10));
230
231 if let Some(Ok(CEvent::Key(key))) = crossterm_event.await
232 && key.kind == KeyEventKind::Press
233 {
234 tx.send(Event::Key(key)).await.unwrap();
235 }
236 }
237 }
238}
239
240impl<F> App<F>
241where
242 F: Fn(&KeyEvent) -> Action + Clone,
243{
244 async fn run<Cusion: Send, B: Backend>(
245 &mut self,
246 terminal: &mut Terminal<B>,
247 batcher: &mut Batcher<Cusion, TuiEntry>,
248 ) -> Result<Option<usize>> {
249 let (tx, mut rx) = mpsc::channel(100);
250
251 tokio::spawn(Event::terminal_event_listener(tx.clone()));
252 self.tx = Some(tx.clone());
253
254 while !self.exit {
255 let prepare = async {
256 if self.has_more {
257 batcher.prepare().await
258 } else {
259 info!("No more items. Sleeping");
261 tokio::time::sleep(std::time::Duration::from_secs(100)).await;
262 batcher.prepare().await
263 }
264 };
265
266 select! {
267 from = prepare.fuse( ) => {
270 info!("Merging");
271 let has_more =
272 batcher.merge(&mut self.buffer, from);
273
274 let _ = tx.send(Event::Refresh).await;
275
276 self.has_more = has_more?;
277 info!("Merged");
278 }
279 event_like = rx.recv().fuse() => {
280 info!("Caught event-like");
281 debug!("{event_like:?}");
282
283 match event_like {
284 Some(event) => {
285 self.handle_events(event, batcher)
286 .await
287 .wrap_err("handle events failed")?;
288
289 terminal.draw(|frame| self.draw(frame))?;
290 }
291 _ => bail!("the communication channel for event was unexpectedly closed.")
292 }
293 }
294 }
295 }
296
297 Ok(if self.selected {
298 let mut pos = Position(self.buffer.len() - 1 - self.selecting_i);
299 Some(self.buffer.next(&mut pos).unwrap().1)
300 } else {
301 None
302 })
303 }
304
305 fn draw(&self, frame: &mut Frame) {
306 frame.render_widget(self, frame.area());
307 frame.set_cursor_position(ratatui::layout::Position::from(
308 self.cursor_pos.read().unwrap().unwrap(),
309 ))
310 }
311
312 async fn handle_events<Cusion: Send>(
313 &mut self,
314 event: Event,
315 batcher: &mut Batcher<Cusion, TuiEntry>,
316 ) -> Result<()> {
317 match event {
318 Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
321 info!("Handling KeyInput");
322 self.handle_key_event(key_event).await?
323 }
324 Event::Input => {
325 info!("Handling Input");
326 batcher.input(&mut self.buffer, self.input.value());
327 self.selecting_i = 0;
329 self.has_more = true;
330 }
331 _ => {}
332 };
333 Ok(())
334 }
335
336 async fn handle_key_event(&mut self, key_event: KeyEvent) -> Result<()> {
337 match (self.config.keybinder)(&key_event) {
338 Action::Select => {
339 self.selected = true;
340 self.exit();
341 }
342 Action::ExitWithoutSelect => self.exit(),
343 Action::Up => {
344 self.selecting_i = (self.selecting_i + 1).min(self.buffer.len().saturating_sub(1));
345 }
346 Action::Down => {
347 self.selecting_i = self.selecting_i.saturating_sub(1);
348 }
349 _ => {
350 if !(self.input.cursor() == 0
351 && (key_event.code == KeyCode::Backspace || key_event.code == KeyCode::Left)
352 || self.input.cursor() == self.input.value().len()
353 && (key_event.code == KeyCode::Delete || key_event.code == KeyCode::Right))
354 {
355 self.input
356 .handle_event(&crossterm::event::Event::Key(key_event))
357 .ok_or_eyre("Failed to handle input")?;
358
359 self.tx
360 .as_mut()
361 .unwrap()
362 .send(Event::Input)
363 .await
364 .wrap_err("Failed to send Refresh")?;
365 }
366 }
367 }
368 Ok(())
369 }
370
371 fn exit(&mut self) {
372 self.exit = true;
373 }
374}
375
376pub fn sample_keyconfig(key: &KeyEvent) -> Action {
377 match (key.code, key.modifiers) {
378 (KeyCode::Enter, _) => Action::Select,
379 (KeyCode::Char('c'), KeyModifiers::CONTROL)
380 | (KeyCode::Char('d'), KeyModifiers::CONTROL)
381 | (KeyCode::Esc, _) => Action::ExitWithoutSelect,
382 (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::CONTROL) => Action::Up,
383 (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::CONTROL) => Action::Down,
384 _ => Action::Input,
385 }
386}
387
388impl<F> Widget for &App<F>
389where
390 F: Fn(&KeyEvent) -> Action + Clone,
391{
392 fn render(self, area: ratatui::prelude::Rect, buffer: &mut ratatui::prelude::Buffer) {
393 let chunks = Layout::default()
394 .direction(Direction::Vertical)
395 .constraints([Constraint::Min(0), Constraint::Length(2)].as_ref())
396 .split(area);
397
398 if !self.buffer.is_empty() {
400 let list_area = chunks[0];
401
402 let items_count = self.buffer.len();
403 let mut items = Vec::with_capacity(items_count);
404
405 let mut pos = Position::default();
406
407 while let Some((entry, _)) = self.buffer.next(&mut pos) {
408 let is_selected = pos.0 - 1 == items_count - self.selecting_i - 1;
409
410 let selecting_status = if is_selected {
411 self.config.selecting
412 } else {
413 self.config.no_selecting
414 };
415
416 let entry_text = format!("{} {}", selecting_status, entry.text.0);
417 let style = entry.text.1;
418
419 items.push(ratatui::widgets::ListItem::new(entry_text).style(style));
421 }
422
423 let visible_height = list_area.height as usize;
424 let reversed_selecting_index = items_count - 1 - self.selecting_i;
425
426 let margin_below = 2;
428 let scroll_offset =
429 reversed_selecting_index.saturating_sub(visible_height - margin_below - 1);
430
431 let start_index = scroll_offset;
432 let end_index = (scroll_offset + visible_height).min(items_count);
433
434 let items: Vec<_> = items
435 .into_iter()
436 .skip(start_index)
437 .take(end_index - start_index)
438 .collect();
439
440 List::new(items)
441 .block(Block::default())
442 .render(list_area, buffer);
443 } else {
444 let list_area = chunks[0];
445
446 Clear.render(list_area, buffer);
447 }
448 {
450 let input_area = chunks[1];
451 let input_text = self.input.to_string();
452
453 Paragraph::new(input_text)
454 .block(Block::default().borders(Borders::TOP))
455 .render(input_area, buffer);
456
457 *self.cursor_pos.write().unwrap() = Some((
458 input_area.x + self.input.visual_cursor() as u16,
459 input_area.y + 1,
460 ));
461 }
462 }
463}