ltrait_ui_tui/
lib.rs

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
164/// `<SelectingStatus> <icon> <title> <sub_string>`
165/// SelectingStatus in above is a char
166pub struct TuiEntry {
167    pub text: StyledText,
168}
169
170// なんのArc, Mutex, RwLockを使うか検討する必要がある。renderの中で使えないと意味ないし
171struct App<F>
172where
173    F: Fn(&KeyEvent) -> Action + Clone,
174{
175    config: TuiConfig<F>,
176
177    exit: bool,
178    // 上が0
179    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                    // HACK: もうeventだけ気にしていればいいから
260                    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                // TODO: 毎回futureを生成し直していると
268                // dropした場合にバグるかも。あと必ず、rx.recvが早い場合何も表示されなくなっちゃうかも
269                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            // it's important to check that the event is a key press event as
319            // crossterm also emits key release and repeat events on Windows.
320            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                // 一回一番上に戻す
328                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        // エントリーの部分
399        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                // リストアイテムを追加
420                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            // 選択されたアイテムが常に表示されるようにスクロール位置を計算
427            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        // テキスト入力部分
449        {
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}