1use std::io::Write;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::mpsc;
16use std::sync::{Arc, Mutex};
17use std::time::Duration;
18
19use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
20use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
21use ratatui::Terminal;
22use ratatui::backend::CrosstermBackend;
23use ratatui::style::{Color, Style};
24use ratatui::text::{Line, Span};
25use ratatui::widgets::{Paragraph, Widget};
26use ratatui::{TerminalOptions, Viewport};
27
28const PROCESS_COLORS: [&str; 8] = [
29 "#8be9fd", "#ff79c6", "#ffb86c", "#bd93f9", "#a4e9ff", "#ffaadd", "#ffcc88", "#ccb3ff", ];
38
39enum TuiEvent {
43 Println(String),
44 Quit,
45}
46
47#[derive(Default)]
54pub struct Tui {
55 filter: Mutex<String>,
56 tx: Mutex<Option<mpsc::Sender<TuiEvent>>>,
57 ready: AtomicBool,
58 done: AtomicBool,
59 output: Mutex<Option<Box<dyn Write + Send>>>,
60}
61
62impl Tui {
63 pub fn new() -> Self {
65 Self::default()
66 }
67
68 pub fn start(&self, out: Box<dyn Write + Send>) -> Result<(), TuiError> {
85 let (tx, rx) = mpsc::channel();
86 *self.tx.lock().unwrap() = Some(tx);
87
88 let result = self.run_event_loop(out, &rx);
89
90 self.done.store(true, Ordering::SeqCst);
91 self.ready.store(false, Ordering::SeqCst);
92 *self.tx.lock().unwrap() = None;
93
94 result
95 }
96
97 fn run_event_loop(
98 &self,
99 out: Box<dyn Write + Send>,
100 rx: &mpsc::Receiver<TuiEvent>,
101 ) -> Result<(), TuiError> {
102 let backend = CrosstermBackend::new(out);
103 let mut terminal = Terminal::with_options(
104 backend,
105 TerminalOptions {
106 viewport: Viewport::Inline(1),
107 },
108 )
109 .map_err(TuiError)?;
110
111 enable_raw_mode().map_err(TuiError)?;
112 self.ready.store(true, Ordering::SeqCst);
113
114 let outcome = self.drive(&mut terminal, rx);
115
116 let _ = disable_raw_mode();
117
118 outcome
119 }
120
121 fn drive(
122 &self,
123 terminal: &mut Terminal<CrosstermBackend<Box<dyn Write + Send>>>,
124 rx: &mpsc::Receiver<TuiEvent>,
125 ) -> Result<(), TuiError> {
126 loop {
127 self.draw_status(terminal)?;
128
129 if event::poll(Duration::from_millis(50)).map_err(TuiError)?
130 && let Event::Key(key) = event::read().map_err(TuiError)?
131 {
132 let current = self.filter();
133 match filter_action(¤t, key) {
134 FilterAction::SetFilter(new_filter) => self.set_filter(&new_filter),
135 FilterAction::Quit => return Ok(()),
136 FilterAction::Unchanged => {}
137 }
138 }
139
140 while let Ok(event) = rx.try_recv() {
141 match event {
142 TuiEvent::Println(line) => {
143 terminal
144 .insert_before(1, |buf| {
145 Paragraph::new(colorized_line(&line)).render(buf.area, buf);
146 })
147 .map_err(TuiError)?;
148 }
149 TuiEvent::Quit => return Ok(()),
150 }
151 }
152 }
153 }
154
155 fn draw_status(
156 &self,
157 terminal: &mut Terminal<CrosstermBackend<Box<dyn Write + Send>>>,
158 ) -> Result<(), TuiError> {
159 let filter = self.filter();
160 let status = status_line(&filter);
161 terminal
162 .draw(|frame| {
163 frame.render_widget(Paragraph::new(status), frame.area());
164 })
165 .map_err(TuiError)?;
166 Ok(())
167 }
168
169 pub fn quit(&self) {
177 if !self.ready.load(Ordering::SeqCst) {
178 return;
179 }
180 if let Some(tx) = self.tx.lock().unwrap().as_ref() {
181 let _ = tx.send(TuiEvent::Quit);
182 }
183 }
184
185 pub fn println(&self, line: &str) {
191 if self.done.load(Ordering::SeqCst) {
192 if let Some(out) = self.output.lock().unwrap().as_mut() {
193 let _ = writeln!(out, "{line}");
194 }
195 return;
196 }
197
198 if !self.ready.load(Ordering::SeqCst) {
199 return;
200 }
201
202 if let Some(tx) = self.tx.lock().unwrap().as_ref() {
203 let _ = tx.send(TuiEvent::Println(colorize_process(line)));
204 }
205 }
206
207 pub fn filter(&self) -> String {
209 self.filter.lock().unwrap().clone()
210 }
211
212 fn set_filter(&self, filter: &str) {
214 *self.filter.lock().unwrap() = filter.to_string();
215 }
216
217 pub fn matches(&self, line: &str) -> bool {
219 let filter = self.filter();
220 if filter.is_empty() {
221 return true;
222 }
223
224 extract_process(line).contains(&filter)
225 }
226
227 pub fn new_writer(self: &Arc<Self>) -> TuiWriter {
232 TuiWriter { tui: self.clone() }
233 }
234}
235
236pub struct TuiWriter {
238 tui: Arc<Tui>,
239}
240
241impl Write for TuiWriter {
242 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
243 let text = String::from_utf8_lossy(buf);
244 self.tui.println(text.strip_suffix('\n').unwrap_or(&text));
245 Ok(buf.len())
246 }
247
248 fn flush(&mut self) -> std::io::Result<()> {
249 Ok(())
250 }
251}
252
253#[derive(Debug, PartialEq, Eq)]
258enum FilterAction {
259 SetFilter(String),
260 Quit,
261 Unchanged,
262}
263
264fn filter_action(current: &str, key: KeyEvent) -> FilterAction {
266 if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
267 return FilterAction::Quit;
268 }
269
270 match key.code {
271 KeyCode::Esc => FilterAction::SetFilter(String::new()),
272 KeyCode::Backspace | KeyCode::Delete => {
273 if current.is_empty() {
274 FilterAction::Unchanged
275 } else {
276 let mut chars: Vec<char> = current.chars().collect();
277 chars.pop();
278 FilterAction::SetFilter(chars.into_iter().collect())
279 }
280 }
281 KeyCode::Char(c) => FilterAction::SetFilter(format!("{current}{c}")),
282 _ => FilterAction::Unchanged,
283 }
284}
285
286fn status_line(filter: &str) -> String {
288 if filter.is_empty() {
289 "filter: (none) — type a process name to filter | ESC to clear".to_string()
290 } else {
291 format!("filter: {filter}▌")
292 }
293}
294
295#[derive(Debug, thiserror::Error)]
297#[error("TUI exited with error: {0}")]
298pub struct TuiError(#[from] std::io::Error);
299
300fn hex_to_rgb(hex: &str) -> (u8, u8, u8) {
304 let hex = hex.strip_prefix('#').unwrap_or(hex);
305 let r = u8::from_str_radix(&hex[0..2], 16).unwrap();
306 let g = u8::from_str_radix(&hex[2..4], 16).unwrap();
307 let b = u8::from_str_radix(&hex[4..6], 16).unwrap();
308 (r, g, b)
309}
310
311fn color_for_name(name: &str) -> (u8, u8, u8) {
317 let first_byte = name.as_bytes()[0];
318 let hex = PROCESS_COLORS[first_byte as usize % PROCESS_COLORS.len()];
319 hex_to_rgb(hex)
320}
321
322fn split_bracket(line: &str) -> Option<(&str, &str)> {
327 if !line.starts_with('[') {
328 return None;
329 }
330 let end = line.find(']')?;
331 let name = &line[1..end];
332 if name.is_empty() {
333 return None;
334 }
335 Some((name, &line[end + 1..]))
336}
337
338pub fn colorize_process(line: &str) -> String {
352 let Some((name, rest)) = split_bracket(line) else {
353 return line.to_string();
354 };
355 let (r, g, b) = color_for_name(name);
356 format!("\x1b[38;2;{r};{g};{b}m[{name}]\x1b[0m{rest}")
357}
358
359fn colorized_line(line: &str) -> Line<'static> {
366 let Some((name, rest)) = split_bracket(line) else {
367 return Line::from(line.to_string());
368 };
369 let (r, g, b) = color_for_name(name);
370 Line::from(vec![
371 Span::styled(
372 format!("[{name}]"),
373 Style::default().fg(Color::Rgb(r, g, b)),
374 ),
375 Span::raw(rest.to_string()),
376 ])
377}
378
379fn extract_process(line: &str) -> String {
381 split_bracket(line).map_or_else(String::new, |(name, _)| name.to_string())
382}
383
384#[derive(Clone, Default)]
390pub struct Context {
391 tui: Option<Arc<Tui>>,
392}
393
394pub fn new_context(_ctx: Context, tui: Arc<Tui>) -> Context {
396 Context { tui: Some(tui) }
397}
398
399pub fn from_context(ctx: &Context) -> Option<Arc<Tui>> {
401 ctx.tui.clone()
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use crossterm::event::KeyEventKind;
408
409 fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
410 KeyEvent::new(code, modifiers)
411 }
412
413 fn plain_key(code: KeyCode) -> KeyEvent {
414 let mut k = key(code, KeyModifiers::NONE);
415 k.kind = KeyEventKind::Press;
416 k
417 }
418
419 #[test]
420 fn matches_no_filter() {
421 let tui = Tui::new();
422
423 assert!(tui.matches("[puma] some log"));
424 assert!(tui.matches("[sidekiq] some log"));
425 }
426
427 #[test]
428 fn matches_with_filter() {
429 let tui = Tui::new();
430 tui.set_filter("puma");
431
432 assert!(tui.matches("[puma] some log"));
433 assert!(!tui.matches("[sidekiq] some log"));
434 }
435
436 #[test]
437 fn matches_no_prefix() {
438 let tui = Tui::new();
439 tui.set_filter("puma");
440
441 assert!(!tui.matches("some log without prefix"));
442 }
443
444 #[test]
445 fn extract_process_cases() {
446 assert_eq!(extract_process("[puma] some log"), "puma");
447 assert_eq!(extract_process("no brackets here"), "");
448 assert_eq!(extract_process(""), "");
449 assert_eq!(extract_process("[unclosed bracket"), "");
450 }
451
452 #[test]
453 fn new_context_from_context() {
454 let tui = Arc::new(Tui::new());
455 let ctx = new_context(Context::default(), tui);
456
457 assert!(from_context(&ctx).is_some());
458 }
459
460 #[test]
461 fn from_context_nil_when_not_set() {
462 assert!(from_context(&Context::default()).is_none());
463 }
464
465 #[test]
466 fn quit_before_start_is_a_safe_noop() {
467 let tui = Tui::new();
468 tui.quit();
469 }
470
471 #[test]
472 fn println_before_start_is_a_safe_noop() {
473 let tui = Tui::new();
474 tui.println("[puma] hello");
475 }
476
477 #[test]
478 fn colorize_process_no_bracket_passthrough() {
479 assert_eq!(colorize_process("no brackets here"), "no brackets here");
480 }
481
482 #[test]
483 fn colorize_process_empty_name_passthrough() {
484 assert_eq!(colorize_process("[] rest"), "[] rest");
485 }
486
487 #[test]
488 fn colorize_process_unclosed_bracket_passthrough() {
489 assert_eq!(colorize_process("[unclosed"), "[unclosed");
490 }
491
492 #[test]
493 fn colorize_process_empty_line_passthrough() {
494 assert_eq!(colorize_process(""), "");
495 }
496
497 #[test]
498 fn hex_to_rgb_cyan() {
499 assert_eq!(hex_to_rgb("#8be9fd"), (0x8b, 0xe9, 0xfd));
500 }
501
502 #[test]
503 fn hex_to_rgb_pink() {
504 assert_eq!(hex_to_rgb("#ff79c6"), (0xff, 0x79, 0xc6));
505 }
506
507 #[test]
508 fn colorize_process_contains_expected_escape_for_puma() {
509 assert_eq!(b'p' % 8, 0);
511 let out = colorize_process("[puma] starting server");
512 assert_eq!(out, "\x1b[38;2;139;233;253m[puma]\x1b[0m starting server");
513 }
514
515 #[test]
516 fn colorize_process_different_names_can_pick_different_colors() {
517 assert_eq!(b'a' % 8, 1);
519 let out = colorize_process("[app] booting");
520 assert_eq!(out, "\x1b[38;2;255;121;198m[app]\x1b[0m booting");
521 }
522
523 #[test]
524 fn colorize_process_byte_indexing_matches_go_modulo() {
525 for (name, expected_index) in [
528 ("sidekiq", usize::from(b's' % 8)),
529 ("worker", usize::from(b'w' % 8)),
530 ("db", usize::from(b'd' % 8)),
531 ] {
532 let line = format!("[{name}] x");
533 let out = colorize_process(&line);
534 let (r, g, b) = hex_to_rgb(PROCESS_COLORS[expected_index]);
535 assert!(
536 out.starts_with(&format!("\x1b[38;2;{r};{g};{b}m[{name}]\x1b[0m")),
537 "name={name} expected color index {expected_index}, got {out}"
538 );
539 }
540 }
541
542 #[test]
543 fn status_line_empty_filter() {
544 assert_eq!(
545 status_line(""),
546 "filter: (none) — type a process name to filter | ESC to clear"
547 );
548 }
549
550 #[test]
551 fn status_line_with_filter_has_cursor_block() {
552 assert_eq!(status_line("puma"), "filter: puma▌");
553 }
554
555 #[test]
556 fn filter_action_esc_clears() {
557 assert_eq!(
558 filter_action("puma", plain_key(KeyCode::Esc)),
559 FilterAction::SetFilter(String::new())
560 );
561 }
562
563 #[test]
564 fn filter_action_backspace_on_nonempty_removes_last_char() {
565 assert_eq!(
566 filter_action("puma", plain_key(KeyCode::Backspace)),
567 FilterAction::SetFilter("pum".to_string())
568 );
569 }
570
571 #[test]
572 fn filter_action_delete_on_nonempty_removes_last_char() {
573 assert_eq!(
574 filter_action("puma", plain_key(KeyCode::Delete)),
575 FilterAction::SetFilter("pum".to_string())
576 );
577 }
578
579 #[test]
580 fn filter_action_backspace_on_empty_is_unchanged() {
581 assert_eq!(
582 filter_action("", plain_key(KeyCode::Backspace)),
583 FilterAction::Unchanged
584 );
585 }
586
587 #[test]
588 fn filter_action_ctrl_c_quits() {
589 assert_eq!(
590 filter_action("puma", key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
591 FilterAction::Quit
592 );
593 }
594
595 #[test]
596 fn filter_action_printable_char_appends() {
597 assert_eq!(
598 filter_action("pu", plain_key(KeyCode::Char('m'))),
599 FilterAction::SetFilter("pum".to_string())
600 );
601 }
602
603 #[test]
604 fn filter_action_unhandled_key_is_unchanged() {
605 assert_eq!(
606 filter_action("puma", plain_key(KeyCode::F(5))),
607 FilterAction::Unchanged
608 );
609 }
610}