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 execute!(
90 terminal.backend_mut(),
91 crossterm::terminal::EnterAlternateScreen,
92 crossterm::event::EnableMouseCapture
93 )?;
94 enable_raw_mode()?;
95 terminal.clear()?;
96
97 Ok(())
98 }
99
100 fn exit<B: Backend + Write>(&self, terminal: &mut Terminal<B>) -> Result<()> {
101 execute!(
102 terminal.backend_mut(),
103 crossterm::terminal::LeaveAlternateScreen,
104 crossterm::event::DisableMouseCapture
105 )?;
106
107 disable_raw_mode()?;
108 ratatui::restore();
109
110 Ok(())
111 }
112}
113
114#[derive(Clone)]
115pub struct TuiConfig<F>
116where
117 F: Fn(&KeyEvent) -> Action + Clone,
118{
119 viewport: Viewport,
120 use_tty: bool,
121 selecting: char,
122 no_selecting: char,
123 keybinder: F,
124}
125
126impl<F> TuiConfig<F>
127where
128 F: Fn(&KeyEvent) -> Action + Clone,
129{
130 pub fn new(
131 viewport: Viewport,
132 use_tty: bool,
133 selecting: char,
134 no_selecting: char,
135 keybinder: F,
136 ) -> Self {
137 Self {
138 viewport,
139 use_tty,
140 selecting,
141 no_selecting,
142 keybinder,
143 }
144 }
145}
146
147type StyledText = (String, Style);
148
149pub struct TuiEntry {
152 pub text: StyledText,
153}
154
155struct App<F>
157where
158 F: Fn(&KeyEvent) -> Action + Clone,
159{
160 config: TuiConfig<F>,
161
162 exit: bool,
163 selecting_i: usize,
165 input: Input,
166 cursor_pos: RwLock<Option<(u16, u16)>>,
167 buffer: Buffer<(TuiEntry, usize)>,
168 has_more: bool,
169 tx: Option<mpsc::Sender<Event>>,
170 selected: bool,
171}
172
173impl<F> App<F>
174where
175 F: Fn(&KeyEvent) -> Action + Clone,
176{
177 fn new(config: TuiConfig<F>) -> Self {
178 Self {
179 has_more: true,
180 config,
181 exit: false,
182 selecting_i: 0,
183 input: Input::default(),
184 buffer: Buffer::default(),
185 tx: None,
186 cursor_pos: None.into(),
187 selected: false,
188 }
189 }
190}
191
192#[derive(Debug)]
193enum Event {
194 Key(KeyEvent),
195 Refresh,
196 Input,
197}
198
199#[derive(Debug, Clone)]
200pub enum Action {
201 Select,
202 ExitWithoutSelect,
203 Up,
204 Down,
205 Input,
206}
207
208impl Event {
209 async fn terminal_event_listener(tx: mpsc::Sender<Event>) {
210 let mut reader = crossterm::event::EventStream::new();
211
212 loop {
213 let crossterm_event = reader.next().fuse();
214 std::thread::sleep(std::time::Duration::from_millis(10));
215
216 if let Some(Ok(CEvent::Key(key))) = crossterm_event.await
217 && key.kind == KeyEventKind::Press
218 {
219 tx.send(Event::Key(key)).await.unwrap();
220 }
221 }
222 }
223}
224
225impl<F> App<F>
226where
227 F: Fn(&KeyEvent) -> Action + Clone,
228{
229 async fn run<Cusion: Send, B: Backend>(
230 &mut self,
231 terminal: &mut Terminal<B>,
232 batcher: &mut Batcher<Cusion, TuiEntry>,
233 ) -> Result<Option<usize>> {
234 let (tx, mut rx) = mpsc::channel(100);
235
236 tokio::spawn(Event::terminal_event_listener(tx.clone()));
237 self.tx = Some(tx.clone());
238
239 while !self.exit {
240 let prepare = async {
241 if self.has_more {
242 batcher.prepare().await
243 } else {
244 info!("No more items. Sleeping");
246 tokio::time::sleep(std::time::Duration::from_secs(100)).await;
247 batcher.prepare().await
248 }
249 };
250
251 select! {
252 from = prepare.fuse( ) => {
255 info!("Merging");
256 let has_more =
257 batcher.merge(&mut self.buffer, from);
258
259 let _ = tx.send(Event::Refresh).await;
260
261 self.has_more = has_more?;
262 info!("Merged");
263 }
264 event_like = rx.recv().fuse() => {
265 info!("Caught event-like");
266 debug!("{event_like:?}");
267
268 match event_like {
269 Some(event) => {
270 self.handle_events(event, batcher)
271 .await
272 .wrap_err("handle events failed")?;
273
274 terminal.draw(|frame| self.draw(frame))?;
275 }
276 _ => bail!("the communication channel for event was unexpectedly closed.")
277 }
278 }
279 }
280 }
281
282 Ok(if self.selected {
283 let mut pos = Position(self.buffer.len() - 1 - self.selecting_i);
284 Some(self.buffer.next(&mut pos).unwrap().1)
285 } else {
286 None
287 })
288 }
289
290 fn draw(&self, frame: &mut Frame) {
291 frame.render_widget(self, frame.area());
292 frame.set_cursor_position(ratatui::layout::Position::from(
293 self.cursor_pos.read().unwrap().unwrap(),
294 ))
295 }
296
297 async fn handle_events<Cusion: Send>(
298 &mut self,
299 event: Event,
300 batcher: &mut Batcher<Cusion, TuiEntry>,
301 ) -> Result<()> {
302 match event {
303 Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
306 info!("Handling KeyInput");
307 self.handle_key_event(key_event).await?
308 }
309 Event::Input => {
310 info!("Handling Input");
311 batcher.input(&mut self.buffer, self.input.value());
312 self.selecting_i = 0;
314 self.has_more = true;
315 }
316 _ => {}
317 };
318 Ok(())
319 }
320
321 async fn handle_key_event(&mut self, key_event: KeyEvent) -> Result<()> {
322 match (self.config.keybinder)(&key_event) {
323 Action::Select => {
324 self.selected = true;
325 self.exit();
326 }
327 Action::ExitWithoutSelect => self.exit(),
328 Action::Up => {
329 self.selecting_i = (self.selecting_i + 1).min(self.buffer.len().saturating_sub(1));
330 }
331 Action::Down => {
332 self.selecting_i = self.selecting_i.saturating_sub(1);
333 }
334 _ => {
335 if !(self.input.cursor() == 0
336 && (key_event.code == KeyCode::Backspace || key_event.code == KeyCode::Left)
337 || self.input.cursor() == self.input.value().len()
338 && (key_event.code == KeyCode::Delete || key_event.code == KeyCode::Right))
339 {
340 self.input
341 .handle_event(&crossterm::event::Event::Key(key_event))
342 .ok_or_eyre("Failed to handle input")?;
343
344 self.tx
345 .as_mut()
346 .unwrap()
347 .send(Event::Input)
348 .await
349 .wrap_err("Failed to send Refresh")?;
350 }
351 }
352 }
353 Ok(())
354 }
355
356 fn exit(&mut self) {
357 self.exit = true;
358 }
359}
360
361pub fn sample_keyconfig(key: &KeyEvent) -> Action {
362 match (key.code, key.modifiers) {
363 (KeyCode::Enter, _) => Action::Select,
364 (KeyCode::Char('c'), KeyModifiers::CONTROL)
365 | (KeyCode::Char('d'), KeyModifiers::CONTROL)
366 | (KeyCode::Esc, _) => Action::ExitWithoutSelect,
367 (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::CONTROL) => Action::Up,
368 (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::CONTROL) => Action::Down,
369 _ => Action::Input,
370 }
371}
372
373impl<F> Widget for &App<F>
374where
375 F: Fn(&KeyEvent) -> Action + Clone,
376{
377 fn render(self, area: ratatui::prelude::Rect, buffer: &mut ratatui::prelude::Buffer) {
378 let chunks = Layout::default()
379 .direction(Direction::Vertical)
380 .constraints([Constraint::Min(0), Constraint::Length(2)].as_ref())
381 .split(area);
382
383 if !self.buffer.is_empty() {
385 let list_area = chunks[0];
386
387 let items_count = self.buffer.len();
388 let mut items = Vec::with_capacity(items_count);
389
390 let mut pos = Position::default();
391
392 while let Some((entry, _)) = self.buffer.next(&mut pos) {
393 let is_selected = pos.0 - 1 == items_count - self.selecting_i - 1;
394
395 let selecting_status = if is_selected {
396 self.config.selecting
397 } else {
398 self.config.no_selecting
399 };
400
401 let entry_text = format!("{} {}", selecting_status, entry.text.0);
402 let style = entry.text.1;
403
404 items.push(ratatui::widgets::ListItem::new(entry_text).style(style));
406 }
407
408 let visible_height = list_area.height as usize;
409 let reversed_selecting_index = items_count - 1 - self.selecting_i;
410
411 let margin_below = 2;
413 let scroll_offset =
414 reversed_selecting_index.saturating_sub(visible_height - margin_below - 1);
415
416 let start_index = scroll_offset;
417 let end_index = (scroll_offset + visible_height).min(items_count);
418
419 let items: Vec<_> = items
420 .into_iter()
421 .skip(start_index)
422 .take(end_index - start_index)
423 .collect();
424
425 List::new(items)
426 .block(Block::default())
427 .render(list_area, buffer);
428 } else {
429 let list_area = chunks[0];
430
431 Clear.render(list_area, buffer);
432 }
433 {
435 let input_area = chunks[1];
436 let input_text = self.input.to_string();
437
438 Paragraph::new(input_text)
439 .block(Block::default().borders(Borders::TOP))
440 .render(input_area, buffer);
441
442 *self.cursor_pos.write().unwrap() = Some((
443 input_area.x + self.input.visual_cursor() as u16,
444 input_area.y + 1,
445 ));
446 }
447 }
448}