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        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
149/// `<SelectingStatus> <icon> <title> <sub_string>`
150/// SelectingStatus in above is a char
151pub struct TuiEntry {
152    pub text: StyledText,
153}
154
155// なんのArc, Mutex, RwLockを使うか検討する必要がある。renderの中で使えないと意味ないし
156struct App<F>
157where
158    F: Fn(&KeyEvent) -> Action + Clone,
159{
160    config: TuiConfig<F>,
161
162    exit: bool,
163    // 上が0
164    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                    // HACK: もうeventだけ気にしていればいいから
245                    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                // TODO: 毎回futureを生成し直していると
253                // dropした場合にバグるかも。あと必ず、rx.recvが早い場合何も表示されなくなっちゃうかも
254                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            // it's important to check that the event is a key press event as
304            // crossterm also emits key release and repeat events on Windows.
305            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                // 一回一番上に戻す
313                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        // エントリーの部分
384        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                // リストアイテムを追加
405                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            // 選択されたアイテムが常に表示されるようにスクロール位置を計算
412            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        // テキスト入力部分
434        {
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}