1use {
2 crate::{
3 keyboard_actions::{
4 Action, ActionScroll, ActionType, BaseStatus, DetachBaseStatus, KeyBoardActions,
5 KeyCodeExt, ScrollStatus,
6 },
7 shared::Shared,
8 MessageSettings, ProcessSettings, ScrollSettings,
9 },
10 anyhow::{anyhow, Result},
11 crossterm::event::KeyModifiers,
12 ratatui::{
13 layout::{Constraint, Direction, Layout, Rect},
14 style::{Style, Stylize},
15 text::{Line, Text},
16 widgets::{Block, Borders, List, ListState},
17 Frame,
18 },
19 std::{
20 io::{BufRead, BufReader},
21 process::{Child, ChildStderr, ChildStdout},
22 sync::LazyLock,
23 thread::sleep,
24 time::Duration,
25 },
26};
27
28pub static TERMINAL: LazyLock<Terminal> = LazyLock::new(Terminal::new);
29
30pub(crate) type SharedMessages = Shared<Vec<String>>;
31type SharedProcesses = Shared<Vec<Process>>;
32type DetachProcess = Process<Vec<String>, Vec<String>, ScrollStatus, ()>;
33type DrawCacheDetach = DrawCache<Vec<String>, DetachBaseStatus, Vec<DetachProcess>>;
34pub(crate) type ExitCallback = Option<Box<dyn Fn() + Send + Sync>>;
35
36macro_rules! spawn_thread {
37 ($callback:expr) => {
38 std::thread::spawn(move || $callback);
39 };
40}
41
42macro_rules! let_clone {
43 ($init:expr, $( $name:ident | $($clone:ident)|* : $ty:ty),*) => {
44 $(
45 let $name: $ty = $init;
46 $(
47 let $clone = $name.clone();
48 )*
49 )*
50 };
51}
52
53pub struct Terminal {
54 processes: SharedProcesses,
55 main_messages: SharedMessages,
56 inputs: Shared<KeyBoardActions>,
57 exit_callback: Shared<ExitCallback>,
58}
59
60impl Terminal {
61 fn new() -> Terminal {
62 let_clone!(
63 Default::default(),
64 main_messages | _main_messages: SharedMessages,
65 processes | _processes: SharedProcesses
66 );
67
68 let (inputs, scroll_status, exit_callback) = KeyBoardActions::new(main_messages.clone());
69
70 let_clone!(
71 Shared::new(inputs),
72 inputs | _inputs: Shared<KeyBoardActions>
73 );
74
75 #[cfg(test)]
76 let not_in_test = false;
77 #[cfg(not(test))]
78 let not_in_test = true;
79
80 if std::env::args().any(|arg| arg.starts_with("--exact")) || not_in_test {
81 spawn_thread!(thread_draw(_main_messages, scroll_status, _processes));
82 }
83
84 spawn_thread!(thread_input(_inputs));
85
86 Terminal {
87 processes,
88 main_messages,
89 inputs,
90 exit_callback,
91 }
92 }
93
94 pub(crate) fn add_process(
95 &self,
96 name: &str,
97 mut child: Child,
98 settings: ProcessSettings,
99 ) -> Result<()> {
100 let process = Process::new(name.to_string(), settings);
101
102 let pre_count = self.processes.write_with(|mut processes| {
103 let pre_count = processes.iter().fold(0, |buff, process| {
104 let count = match &process.settings.messages {
105 MessageSettings::Output | MessageSettings::Error => 1,
106 MessageSettings::All => 2,
107 MessageSettings::None => 0,
108 };
109
110 buff + count
111 });
112
113 processes.push(process.clone());
114 pre_count
115 });
116
117 let focus_indexes =
118 match &process.settings.messages {
119 MessageSettings::Output => {
120 let stdout = child.stdout.take().ok_or_else(|| {
121 anyhow::anyhow!("Failed to get stdout on process: {name}")
122 })?;
123
124 let _out_messages = process.out_messages.clone();
125
126 spawn_thread!(thread_output(stdout, _out_messages, process.search_message));
127
128 vec![pre_count + 1]
129 }
130 MessageSettings::Error => {
131 let stderr = child.stderr.take().ok_or_else(|| {
132 anyhow::anyhow!("Failed to get stderr on process: {name}")
133 })?;
134
135 let _err_messages = process.err_messages.clone();
136
137 spawn_thread!(thread_error(stderr, _err_messages));
138
139 vec![pre_count + 1]
140 }
141 MessageSettings::All => {
142 let stdout = child.stdout.take().ok_or_else(|| {
143 anyhow::anyhow!("Failed to get stdout on process: {name}")
144 })?;
145
146 let stderr = child.stderr.take().ok_or_else(|| {
147 anyhow::anyhow!("Failed to get stderr on process: {name}")
148 })?;
149
150 let _out_messages = process.out_messages.clone();
151 let _err_messages = process.err_messages.clone();
152
153 spawn_thread!(thread_output(stdout, _out_messages, process.search_message));
154 spawn_thread!(thread_error(stderr, _err_messages));
155
156 vec![pre_count + 1, pre_count + 2]
157 }
158 MessageSettings::None => vec![],
159 };
160
161 let main_messages = self.main_messages.clone();
162 let name = name.to_string();
163
164 spawn_thread!(thread_exit(name, child, main_messages));
165
166 if let ScrollSettings::Enable {
167 up: up_right,
168 down: down_left,
169 } = process.settings.scroll
170 {
171 for (scroll_status, messages) in [
172 (
173 process.scroll_status_out.clone(),
174 process.out_messages.clone(),
175 ),
176 (
177 process.scroll_status_err.clone(),
178 process.err_messages.clone(),
179 ),
180 ] {
181 let action_scroll = ActionScroll {
182 status: scroll_status.clone(),
183 messages: messages.clone(),
184 };
185
186 self.inputs.write_with(|mut inputs| {
187 inputs.push(Action::new(
188 up_right.into_event_no_modifier(),
189 ActionType::ScrollUp(action_scroll.clone()),
190 ));
191 inputs.push(Action::new(
192 down_left.into_event_no_modifier(),
193 ActionType::ScrollDown(action_scroll.clone()),
194 ));
195 inputs.push(Action::new(
196 down_left.into_event(KeyModifiers::SHIFT),
197 ActionType::StopScrolling(process.scroll_status_out.clone()),
198 ));
199 inputs.push(Action::new(
200 down_left.into_event(KeyModifiers::SHIFT),
201 ActionType::StopScrolling(process.scroll_status_err.clone()),
202 ));
203 });
204 }
205 }
206
207 if !focus_indexes.is_empty() {
208 self.inputs
209 .write_with(|mut inputs| inputs.push_focus(&focus_indexes))?;
210 }
211
212 Ok(())
213 }
214
215 pub fn add_message<M>(&self, message: M)
216 where
217 M: ToString,
218 {
219 self.main_messages.write_with(|mut messages| {
220 messages.push(message.to_string());
221 });
222 }
223
224 pub(crate) fn block_search_message<S, P>(&self, process: P, submsg: S) -> Result<String>
225 where
226 S: ToString,
227 P: ToString,
228 {
229 let process = process.to_string();
230
231 let process = self
232 .processes
233 .read_access()
234 .clone()
235 .into_iter()
236 .find(|p| p.name == process)
237 .ok_or(anyhow!("Process not found."))?;
238
239 process.search_message.write_with(|mut process| {
240 *process = Some(SearchMessage::new(submsg.to_string()));
241 });
242
243 loop {
244 let message = process.search_message.write_with(|mut search_message| {
245 let message = search_message.as_ref().unwrap().message.clone();
246 if message.is_some() {
247 *search_message = None;
248 }
249 message
250 });
251
252 if let Some(message) = message {
253 return Ok(message);
254 }
255
256 sleep_thread();
257 }
258 }
259
260 pub(crate) fn with_exit_callback<F: Fn() + Send + Sync + 'static>(&self, closure: F) {
261 self.exit_callback.write_with(|mut terminal| {
262 *terminal = Some(Box::new(closure));
263 });
264 }
265
266 pub(crate) fn kill(&self) {
267 ratatui::restore();
268 if let Some(callback) = self.exit_callback.read_access().as_ref() {
269 callback();
270 }
271
272 std::process::exit(0);
273 }
274}
275
276impl Drop for Terminal {
277 fn drop(&mut self) {
278 ratatui::restore();
279 }
280}
281
282fn thread_output(
283 stdout: ChildStdout,
284 messages: SharedMessages,
285 search_message: Shared<Option<SearchMessage>>,
286) {
287 let regex = Regex::new();
288
289 for line in BufReader::new(stdout).lines() {
290 let line = regex.clear(line.expect("Failed to read line from stdout."));
291
292 messages.write_with(|mut messages| {
293 messages.push(line.clone());
294 });
295
296 search_message.write_with(|mut maybe_search_message| {
297 if let Some(search_message) = maybe_search_message.as_mut() {
298 if line.contains(&search_message.submsg) {
299 search_message.message = Some(line);
300 }
301 }
302 });
303 }
304}
305
306fn thread_error(stderr: ChildStderr, messages: SharedMessages) {
307 let regex = Regex::new();
308
309 for line in BufReader::new(stderr).lines() {
310 let line = regex.clear(line.expect("Failed to read line from stderr."));
311
312 messages.write_with(|mut messages| {
313 messages.push(line);
314 });
315 }
316}
317
318fn thread_exit(process_name: String, mut child: Child, main_messages: SharedMessages) {
319 let exit_status = match child.wait() {
320 Ok(status) => format!("ok: {status}."),
321
322 Err(err) => format!("fail with error: {err}."),
323 };
324
325 main_messages.write_with(|mut messages| {
326 messages.push(format!("Process '{process_name}' exited: {exit_status}"));
327 });
328}
329
330fn thread_input(inputs: Shared<KeyBoardActions>) {
331 loop {
332 let event = crossterm::event::read().expect("Failed to read event.");
333
334 inputs.read_with(|inputs| {
335 inputs.apply_event(event);
336 });
337 }
338}
339
340fn thread_draw(main_messages: SharedMessages, main_scroll: BaseStatus, processes: SharedProcesses) {
341 let mut terminal = ratatui::init();
342
343 let data = DrawCache::new(main_messages, main_scroll, processes);
344
345 let mut cache = DrawCache::default_detach();
346
347 loop {
348 let read = data.detach();
349
350 if read == cache {
351 sleep_thread();
352 continue;
353 } else {
354 cache = read.clone();
355 }
356
357 let DrawCache {
358 main_messages,
359 main_scroll,
360 processes,
361 } = read;
362
363 terminal
364 .draw(|frame| {
365 if let Some(focus) = main_scroll.focus {
366 if focus == 0 {
367 render_frame(
368 frame,
369 frame.area(),
370 "",
371 BlockType::Main,
372 BlockFocus::Exit,
373 main_messages,
374 &main_scroll.main_scroll,
375 );
376 } else {
377 let mut index = 0;
378 for i in processes {
379 if let Some((ty, messages, scroll)) = match i.settings.messages {
380 MessageSettings::Output => {
381 index += 1;
382
383 if index == focus {
384 Some((BlockType::Out, i.out_messages, i.scroll_status_out))
385 } else {
386 None
387 }
388 }
389 MessageSettings::Error => {
390 index += 1;
391
392 if index == focus {
393 Some((BlockType::Err, i.err_messages, i.scroll_status_err))
394 } else {
395 None
396 }
397 }
398 MessageSettings::All => {
399 index += 1;
400
401 if index == focus {
402 Some((BlockType::Out, i.out_messages, i.scroll_status_out))
403 } else if index + 1 == focus {
404 Some((BlockType::Err, i.err_messages, i.scroll_status_err))
405 } else {
406 index += 1;
407 None
408 }
409 }
410 MessageSettings::None => None,
411 } {
412 render_frame(
413 frame,
414 frame.area(),
415 i.name,
416 ty,
417 BlockFocus::Exit,
418 messages,
419 &scroll,
420 );
421 break;
422 }
423 }
424 }
425 } else {
426 let main_chunks = Layout::default()
427 .direction(Direction::Horizontal)
428 .constraints(if processes.is_empty() {
429 vec![Constraint::Percentage(100)]
430 } else {
431 vec![Constraint::Percentage(30), Constraint::Percentage(70)]
432 })
433 .split(frame.area());
434
435 render_frame(
436 frame,
437 main_chunks[0],
438 "",
439 BlockType::Main,
440 BlockFocus::Enter(0),
441 main_messages,
442 &main_scroll.main_scroll,
443 );
444
445 if processes.is_empty() {
446 return;
447 }
448
449 let processes_chunks = Layout::default()
450 .direction(Direction::Horizontal)
451 .constraints(vec![
452 Constraint::Ratio(1, processes.len() as u32);
453 processes.len()
454 ])
455 .split(main_chunks[1]);
456
457 let mut focus = 0;
458
459 for (index, process) in processes.into_iter().enumerate() {
460 match process.settings.messages {
461 MessageSettings::Output => {
462 focus += 1;
463
464 render_frame(
465 frame,
466 processes_chunks[index],
467 process.name,
468 BlockType::Out,
469 BlockFocus::Enter(focus),
470 process.out_messages,
471 &process.scroll_status_out,
472 );
473 }
474 MessageSettings::Error => {
475 focus += 1;
476
477 render_frame(
478 frame,
479 processes_chunks[index],
480 process.name,
481 BlockType::Err,
482 BlockFocus::Enter(focus),
483 process.err_messages,
484 &process.scroll_status_err,
485 );
486 }
487 MessageSettings::All => {
488 let process_chunks = Layout::default()
489 .direction(Direction::Vertical)
490 .constraints([
491 Constraint::Percentage(70),
492 Constraint::Percentage(30),
493 ])
494 .split(processes_chunks[index]);
495
496 focus += 1;
497 render_frame(
498 frame,
499 process_chunks[0],
500 &process.name,
501 BlockType::Out,
502 BlockFocus::Enter(focus),
503 process.out_messages,
504 &process.scroll_status_out,
505 );
506
507 focus += 1;
508 render_frame(
509 frame,
510 process_chunks[1],
511 process.name,
512 BlockType::Err,
513 BlockFocus::Enter(focus),
514 process.err_messages,
515 &process.scroll_status_err,
516 );
517 }
518 MessageSettings::None => {}
519 }
520 }
521 }
522 })
523 .unwrap();
524
525 sleep_thread();
526 }
527}
528
529fn render_frame<N>(
530 frame: &mut Frame,
531 chunk: Rect,
532 name: N,
533 ty: BlockType,
534 focus: BlockFocus,
535 messages: Vec<String>,
536 scroll: &ScrollStatus,
537) where
538 N: ToString,
539{
540 let select_message = if messages.len() == 0 {
541 None
542 } else {
543 Some(messages.len() - 1)
544 };
545
546 let mut state = ListState::default().with_selected(select_message);
547
548 let sub_title = match ty {
549 BlockType::Main => Line::from("Main").cyan().bold(),
550 BlockType::Out => Line::from("Out").light_green().bold(),
551 BlockType::Err => Line::from("Err").light_red().bold(),
552 };
553
554 let focus_txt = match focus {
555 BlockFocus::Enter(index) => format!("full screen: '{index}'"),
556 BlockFocus::Exit => format!("press 'Esc' to exit full screen"),
557 };
558
559 let mut block = Block::default()
560 .title(Line::from(name.to_string()).gray().bold().centered())
561 .title(sub_title.centered())
562 .title(Line::from(focus_txt).right_aligned().italic().dark_gray())
563 .borders(Borders::ALL);
564
565 let is_scrolling = if let Some(y) = scroll.y {
566 let offset = messages.len().saturating_sub(y as usize);
567
568 state.scroll_up_by(offset as u16);
569
570 block = block.title(
571 Line::from(format!(
572 "Scrolling: offset {offset} - press 'shift + scroll_down' key to stop scrolling."
573 ))
574 .bold()
575 .left_aligned()
576 .yellow(),
577 );
578
579 true
580 } else {
581 false
582 };
583
584 let messages = messages
585 .into_iter()
586 .map(|message| {
587 Text::from(textwrap::fill(&message, chunk.width.saturating_sub(3) as usize).clone())
588 })
589 .collect::<Vec<_>>();
590
591 let mut list = List::new(messages).block(block);
592
593 if is_scrolling {
594 list = list.highlight_style(Style::default().yellow().bold());
595 }
596
597 frame.render_stateful_widget(list, chunk, &mut state);
598}
599
600fn sleep_thread() {
601 sleep(Duration::from_millis(50));
602}
603
604enum BlockType {
605 Main,
606 Out,
607 Err,
608}
609
610enum BlockFocus {
611 Enter(usize),
612 Exit,
613}
614
615#[derive(Clone, PartialEq)]
616struct Process<
617 O = SharedMessages,
618 E = SharedMessages,
619 S = Shared<ScrollStatus>,
620 SM = Shared<Option<SearchMessage>>,
621> {
622 pub name: String,
623 pub out_messages: O,
624 pub err_messages: E,
625 pub settings: ProcessSettings,
626 pub scroll_status_out: S,
627 pub scroll_status_err: S,
628 pub search_message: SM,
629}
630
631impl Process {
632 pub fn new(name: String, settings: ProcessSettings) -> Process {
633 Process {
634 name,
635 settings,
636 out_messages: Default::default(),
637 err_messages: Default::default(),
638 scroll_status_out: Default::default(),
639 scroll_status_err: Default::default(),
640 search_message: Default::default(),
641 }
642 }
643
644 pub fn detach(&self) -> DetachProcess {
645 Process {
646 name: self.name.clone(),
647 settings: self.settings.clone(),
648 out_messages: self.out_messages.read_access().clone(),
649 err_messages: self.err_messages.read_access().clone(),
650 scroll_status_out: self.scroll_status_out.read_access().clone(),
651 scroll_status_err: self.scroll_status_err.read_access().clone(),
652 search_message: (),
653 }
654 }
655}
656
657#[derive(PartialEq)]
658struct SearchMessage {
659 pub submsg: String,
660 pub message: Option<String>,
661}
662
663impl SearchMessage {
664 pub fn new(submsg: String) -> Self {
665 Self {
666 submsg,
667 message: None,
668 }
669 }
670}
671
672#[derive(Clone, PartialEq)]
673struct DrawCache<MM = SharedMessages, MS = BaseStatus, P = SharedProcesses> {
674 pub main_messages: MM,
675 pub main_scroll: MS,
676 pub processes: P,
677}
678
679impl DrawCache {
680 pub fn new(
681 main_messages: SharedMessages,
682 main_scroll: BaseStatus,
683 processes: SharedProcesses,
684 ) -> Self {
685 Self {
686 main_messages,
687 main_scroll,
688 processes,
689 }
690 }
691
692 pub fn default_detach() -> DrawCacheDetach {
693 DrawCache {
694 main_messages: Default::default(),
695 main_scroll: Default::default(),
696 processes: Default::default(),
697 }
698 }
699
700 pub fn detach(&self) -> DrawCacheDetach {
701 DrawCache {
702 main_messages: self.main_messages.read_access().clone(),
703 main_scroll: self.main_scroll.detach(),
704 processes: self
705 .processes
706 .read_access()
707 .iter()
708 .map(Process::detach)
709 .collect::<Vec<_>>(),
710 }
711 }
712}
713
714struct Regex(regex::Regex);
715
716impl Regex {
717 pub fn new() -> Self {
718 Self(regex::Regex::new(r"\x1b\[([\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e])").unwrap())
719 }
720
721 pub fn clear(&self, line: String) -> String {
722 self.0.replace_all(&line, "").to_string()
723 }
724}
725
726pub struct Focus {
727 pub index: usize,
728 pub at: usize,
729}