1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
mod ui;
use crate::app::App;
use crossterm::event::{
DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEventKind, KeyModifiers, MouseButton,
MouseEventKind,
};
use crossterm::event::{KeyEvent, MouseEvent};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::backend::Backend;
use ratatui::Terminal;
use std::ffi::{OsStr, OsString};
use std::panic;
use std::str::FromStr;
use std::sync::mpsc;
use std::time::{Duration, Instant};
use std::{io, thread};
use crossterm::event::{self, Event as CrosstermEvent};
use anyhow::Result;
#[derive(Debug)]
pub struct Tui<'a, B: Backend> {
terminal: Terminal<B>,
is_running: bool,
pub sender: mpsc::Sender<Event>,
receiver: mpsc::Receiver<Event>,
ui: ui::UI<'a>,
}
#[derive(Debug)]
pub enum Event {
/// App refresh request.
Refresh,
/// Key press/release/repeat.
Key(KeyEvent),
/// Mouse click/scroll.
Mouse(MouseEvent),
/// Terminal resize.
Resize(u16, u16),
}
impl<B: Backend> Tui<'_, B> {
pub fn new(terminal: Terminal<B>) -> Self {
let (tx, rx) = mpsc::channel();
Self {
terminal,
is_running: false,
sender: tx,
receiver: rx,
ui: ui::UI::new(),
}
}
pub fn run_loop(&mut self, app: &mut App) -> Result<()> {
self.init()?;
self.term()?;
self.is_running = true;
while self.is_running() {
// Render the user interface.
self.draw(app)?;
// Handle events.
match self.receiver.recv()? {
Event::Refresh => self.handle_refresh_event(app)?,
Event::Key(key_event) => self.handle_key_events(key_event, app)?,
Event::Mouse(mouse_event) => self.handle_mouse_events(mouse_event, app)?,
Event::Resize(_, _) => {}
}
}
self.exit()?;
Ok(())
}
/// Initializes the TUI.
///
/// get ready for TUI, enable the raw mode and set terminal props.
pub fn init(&mut self) -> Result<()> {
terminal::enable_raw_mode()?;
// Use stdout instead of stderr for refresh efficiency. (I don't know why stderr is slow)
crossterm::execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
// deal with panic
let panic_hook = panic::take_hook();
panic::set_hook(Box::new(move |panic_info| {
// Ref: https://stackoverflow.com/a/73467496
Self::reset().expect("failed to reset the terminal, double-panic now");
panic_hook(panic_info);
}));
self.terminal.hide_cursor()?;
self.terminal.clear()?;
Ok(())
}
// run crossterm event loop to capture user input, and send it to the tui.
pub fn term(&mut self) -> Result<()> {
const TICK_RATE: Duration = Duration::from_millis(250);
let sender = self.sender.clone();
thread::spawn(move || {
let mut last_tick = Instant::now();
loop {
let timeout = TICK_RATE
.checked_sub(last_tick.elapsed())
.unwrap_or(TICK_RATE);
if event::poll(timeout).expect("failed to poll events") {
match event::read().expect("failed to read the event") {
CrosstermEvent::FocusGained => Ok(()),
CrosstermEvent::FocusLost => Ok(()),
CrosstermEvent::Key(e) => sender.send(Event::Key(e)),
CrosstermEvent::Mouse(e) => sender.send(Event::Mouse(e)),
CrosstermEvent::Paste(_) => Ok(()),
CrosstermEvent::Resize(w, h) => sender.send(Event::Resize(w, h)),
}
.expect("failed to send terminal event")
}
if last_tick.elapsed() >= TICK_RATE {
// it seems that we may not need the tick, just do nothing when user do nothing
// sender.send(Event::Tick).expect("failed to send tick event");
last_tick = Instant::now();
}
}
});
Ok(())
}
/// Render UI with app state.
pub fn draw(&mut self, app: &mut App) -> Result<()> {
self.terminal.draw(|frame| self.ui.render(app, frame))?;
Ok(())
}
pub fn is_running(&self) -> bool {
self.is_running
}
fn quit(&mut self) {
self.is_running = false;
}
/// Resets the TUI, be a static helper method for exit and panic_hook.
fn reset() -> Result<()> {
terminal::disable_raw_mode()?;
// It's the same here for stdout.
crossterm::execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
Ok(())
}
/// Exits the TUI.
///
/// cleanup for TUI, disable the raw mode and set terminal props.
pub fn exit(&mut self) -> Result<()> {
Self::reset()?;
self.terminal.show_cursor()?;
Ok(())
}
pub fn set_search_text(&mut self, text: &str) {
self.ui.set_search_text(text);
}
pub fn handle_refresh_event(&mut self, _app: &mut App) -> Result<()> {
Ok(())
}
pub fn handle_mouse_events(&mut self, mouse_event: MouseEvent, app: &mut App) -> Result<()> {
match mouse_event.kind {
MouseEventKind::Down(MouseButton::Left) => {}
MouseEventKind::Down(MouseButton::Right) => {}
MouseEventKind::ScrollUp => {
self.up(app)?;
}
MouseEventKind::ScrollDown => {
self.down(app)?;
}
_ => {}
}
Ok(())
}
pub fn handle_key_events(&mut self, key_event: KeyEvent, app: &mut App) -> Result<()> {
// ignore key release for windows
if key_event.kind == KeyEventKind::Release {
return Ok(());
}
match key_event.code {
// Quit application on `Esc`
KeyCode::Esc => {
self.quit();
// if self.ui.is_focus_search_bar {
// self.quit();
// } else {
// // self.ui.unselect();
// self.ui.is_focus_search_bar = true;
// }
}
// Quit application on `Ctrl+C`
KeyCode::Char('c') | KeyCode::Char('C')
if key_event.modifiers == KeyModifiers::CONTROL =>
{
self.quit();
}
// Do query on `Enter`
KeyCode::Enter => {
if self.ui.is_focus_search_bar {
let s = self.ui.textarea.lines()[0].as_str();
let is_query_already = if let Ok(results) = app.query_results.try_read() {
results.search == OsString::from_str(s).unwrap()
} else {
false
};
if is_query_already {
self.ui.select_first(app);
self.ui.is_focus_search_bar = false;
} else {
app.send_query(s)?;
self.ui.unselect();
}
} else {
if self.ui.is_selected() {
if let Some(path) = self.ui.get_selected_full_path(app) {
let mut cmd = std::process::Command::new("explorer");
// Ctrl+Enter will open the folder and select the file, if it is.
if key_event.modifiers == KeyModifiers::CONTROL && path.is_file() {
// Ref: https://stackoverflow.com/a/13625225
cmd.arg(OsStr::new("/select,"));
}
cmd.arg(path.as_os_str());
cmd.spawn()
.expect("explorer command failed to start")
.wait()
.expect("failed to wait");
}
}
}
}
KeyCode::Backspace if !self.ui.is_focus_search_bar => {
self.ui.is_focus_search_bar = true;
}
KeyCode::Char('/') if !self.ui.is_focus_search_bar => {
self.ui.is_focus_search_bar = true;
self.ui.textarea.select_all();
}
// Shift focus in different widgets
KeyCode::Tab => {
// TODO: do nothing now, we will support the results list selection for it.
if self.ui.is_focus_search_bar {
self.ui.is_focus_search_bar = false;
if !self.ui.is_selected() {
self.ui.select_first(app);
}
} else {
self.ui.is_focus_search_bar = true;
}
}
KeyCode::Up => {
self.up(app)?;
}
KeyCode::Down => {
self.down(app)?;
}
KeyCode::PageUp => {
self.page_up(app)?;
}
KeyCode::PageDown => {
self.page_down(app)?;
}
KeyCode::Char('.') | KeyCode::Char('d') | KeyCode::Char('D')
if key_event.modifiers == KeyModifiers::CONTROL =>
{
self.ui.is_popup_show = !self.ui.is_popup_show;
}
// Other handlers passthrough to tui-textarea
_ => {
if self.ui.is_focus_search_bar {
ui::key_map_for_textarea(key_event.into(), &mut self.ui.textarea);
}
}
}
Ok(())
}
fn up(&mut self, app: &mut App) -> Result<()> {
if !self.ui.is_focus_search_bar {
if self.ui.is_first_selected() {
self.ui.unselect();
self.ui.is_focus_search_bar = true;
} else {
self.ui.select_previous_n(1, app);
}
}
Ok(())
}
fn down(&mut self, app: &mut App) -> Result<()> {
if self.ui.is_focus_search_bar && app.query_results.try_read().is_ok_and(|x| x.number > 0) {
self.ui.select_first(app);
self.ui.is_focus_search_bar = false;
} else {
if self.ui.is_selected() {
self.ui.select_next_n(1, app);
} else {
self.ui.select_first(app);
}
}
Ok(())
}
fn page_up(&mut self, app: &mut App) -> Result<()> {
if !self.ui.is_focus_search_bar {
self.ui.select_previous_page(app);
// let old_offset = self.ui.list_state.offset();
// let page_offset = self.ui.last_page_height.unwrap() as usize;
// let new_offset = old_offset.saturating_sub(page_offset);
// *self.ui.list_state.offset_mut() = new_offset;
// if new_offset == old_offset {
// self.ui.select_first(app);
// } else {
// self.ui.select_previous_n(old_offset - new_offset, app);
// }
}
Ok(())
}
fn page_down(&mut self, app: &mut App) -> Result<()> {
if !self.ui.is_focus_search_bar {
self.ui.select_next_page(app);
// let old_offset = self.ui.list_state.offset();
// let page_offset = self.ui.last_page_height.unwrap() as usize;
// let new_offset = old_offset.saturating_add(page_offset);
// *self.ui.list_state.offset_mut() = new_offset;
// if new_offset == old_offset {
// self.ui.select_last(app);
// } else {
// self.ui.select_next_n(new_offset - old_offset, app);
// }
}
Ok(())
}
}