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(
127 stdout,
128 _out_messages,
129 process.search_message,
130 process.settings.clear_regex
131 ));
132
133 vec![pre_count + 1]
134 }
135 MessageSettings::Error => {
136 let stderr = child.stderr.take().ok_or_else(|| {
137 anyhow::anyhow!("Failed to get stderr on process: {name}")
138 })?;
139
140 let _err_messages = process.err_messages.clone();
141
142 spawn_thread!(thread_error(
143 stderr,
144 _err_messages,
145 process.settings.clear_regex
146 ));
147
148 vec![pre_count + 1]
149 }
150 MessageSettings::All => {
151 let stdout = child.stdout.take().ok_or_else(|| {
152 anyhow::anyhow!("Failed to get stdout on process: {name}")
153 })?;
154
155 let stderr = child.stderr.take().ok_or_else(|| {
156 anyhow::anyhow!("Failed to get stderr on process: {name}")
157 })?;
158
159 let _out_messages = process.out_messages.clone();
160 let _err_messages = process.err_messages.clone();
161
162 spawn_thread!(thread_output(
163 stdout,
164 _out_messages,
165 process.search_message,
166 process.settings.clear_regex
167 ));
168 spawn_thread!(thread_error(
169 stderr,
170 _err_messages,
171 process.settings.clear_regex
172 ));
173
174 vec![pre_count + 1, pre_count + 2]
175 }
176 MessageSettings::None => vec![],
177 };
178
179 let main_messages = self.main_messages.clone();
180 let name = name.to_string();
181
182 spawn_thread!(thread_exit(name, child, main_messages));
183
184 if let ScrollSettings::Enable {
185 up: up_right,
186 down: down_left,
187 } = process.settings.scroll
188 {
189 for (scroll_status, messages) in [
190 (
191 process.scroll_status_out.clone(),
192 process.out_messages.clone(),
193 ),
194 (
195 process.scroll_status_err.clone(),
196 process.err_messages.clone(),
197 ),
198 ] {
199 let action_scroll = ActionScroll {
200 status: scroll_status.clone(),
201 messages: messages.clone(),
202 };
203
204 self.inputs.write_with(|mut inputs| {
205 inputs.push(Action::new(
206 up_right.into_event_no_modifier(),
207 ActionType::ScrollUp(action_scroll.clone()),
208 ));
209 inputs.push(Action::new(
210 down_left.into_event_no_modifier(),
211 ActionType::ScrollDown(action_scroll.clone()),
212 ));
213 inputs.push(Action::new(
214 down_left.into_event(KeyModifiers::SHIFT),
215 ActionType::StopScrolling(process.scroll_status_out.clone()),
216 ));
217 inputs.push(Action::new(
218 down_left.into_event(KeyModifiers::SHIFT),
219 ActionType::StopScrolling(process.scroll_status_err.clone()),
220 ));
221 });
222 }
223 }
224
225 if !focus_indexes.is_empty() {
226 self.inputs
227 .write_with(|mut inputs| inputs.push_focus(&focus_indexes))?;
228 }
229
230 Ok(())
231 }
232
233 pub fn add_message<M>(&self, message: M)
234 where
235 M: ToString,
236 {
237 self.main_messages.write_with(|mut messages| {
238 messages.push(message.to_string());
239 });
240 }
241
242 pub(crate) fn block_search_message<S, P>(&self, process: P, submsg: S) -> Result<String>
243 where
244 S: ToString,
245 P: ToString,
246 {
247 let process = process.to_string();
248
249 let process = self
250 .processes
251 .read_access()
252 .clone()
253 .into_iter()
254 .find(|p| p.name == process)
255 .ok_or(anyhow!("Process not found."))?;
256
257 process.search_message.write_with(|mut process| {
258 *process = Some(SearchMessage::new(submsg.to_string()));
259 });
260
261 loop {
262 let message = process.search_message.write_with(|mut search_message| {
263 let message = search_message.as_ref().unwrap().message.clone();
264 if message.is_some() {
265 *search_message = None;
266 }
267 message
268 });
269
270 if let Some(message) = message {
271 return Ok(message);
272 }
273
274 sleep_thread();
275 }
276 }
277
278 pub(crate) fn with_exit_callback<F: Fn() + Send + Sync + 'static>(&self, closure: F) {
279 self.exit_callback.write_with(|mut terminal| {
280 *terminal = Some(Box::new(closure));
281 });
282 }
283
284 pub(crate) fn kill(&self) {
285 ratatui::restore();
286 if let Some(callback) = self.exit_callback.read_access().as_ref() {
287 callback();
288 }
289
290 std::process::exit(0);
291 }
292}
293
294impl Drop for Terminal {
295 fn drop(&mut self) {
296 ratatui::restore();
297 }
298}
299
300fn thread_output(
301 stdout: ChildStdout,
302 messages: SharedMessages,
303 search_message: Shared<Option<SearchMessage>>,
304 clear_regex: bool,
305) {
306 let regex = if clear_regex {
307 Some(Regex::new())
308 } else {
309 None
310 };
311
312 for line in BufReader::new(stdout).lines() {
313 let line = line.expect("Failed to read line from stdout.");
314 let line = if let Some(regex) = ®ex {
315 regex.clear(line)
316 } else {
317 line
318 };
319
320 messages.write_with(|mut messages| {
321 messages.push(line.clone());
322 });
323
324 search_message.write_with(|mut maybe_search_message| {
325 if let Some(search_message) = maybe_search_message.as_mut() {
326 if line.contains(&search_message.submsg) {
327 search_message.message = Some(line);
328 }
329 }
330 });
331 }
332}
333
334fn thread_error(stderr: ChildStderr, messages: SharedMessages, clear_regex: bool) {
335 let regex: Option<Regex> = if clear_regex {
336 Some(Regex::new())
337 } else {
338 None
339 };
340
341 for line in BufReader::new(stderr).lines() {
342 let line = line.expect("Failed to read line from stderr.");
343 let line = if let Some(regex) = ®ex {
344 regex.clear(line)
345 } else {
346 line
347 };
348
349 messages.write_with(|mut messages| {
350 messages.push(line);
351 });
352 }
353}
354
355fn thread_exit(process_name: String, mut child: Child, main_messages: SharedMessages) {
356 let exit_status = match child.wait() {
357 Ok(status) => format!("ok: {status}."),
358
359 Err(err) => format!("fail with error: {err}."),
360 };
361
362 main_messages.write_with(|mut messages| {
363 messages.push(format!("Process '{process_name}' exited: {exit_status}"));
364 });
365}
366
367fn thread_input(inputs: Shared<KeyBoardActions>) {
368 loop {
369 let event = crossterm::event::read().expect("Failed to read event.");
370
371 inputs.read_with(|inputs| {
372 inputs.apply_event(event);
373 });
374 }
375}
376
377fn thread_draw(main_messages: SharedMessages, main_scroll: BaseStatus, processes: SharedProcesses) {
378 let mut terminal = ratatui::init();
379
380 let data = DrawCache::new(main_messages, main_scroll, processes);
381
382 let mut cache = DrawCache::default_detach();
383
384 loop {
385 let read = data.detach();
386
387 if read == cache {
388 sleep_thread();
389 continue;
390 } else {
391 cache = read.clone();
392 }
393
394 let DrawCache {
395 main_messages,
396 main_scroll,
397 processes,
398 } = read;
399
400 terminal
401 .draw(|frame| {
402 if let Some(focus) = main_scroll.focus {
403 if focus == 0 {
404 render_frame(
405 frame,
406 frame.area(),
407 "",
408 BlockType::Main,
409 BlockFocus::Exit,
410 main_messages,
411 &main_scroll.main_scroll,
412 );
413 } else {
414 let mut index = 0;
415 for i in processes {
416 if let Some((ty, messages, scroll)) = match i.settings.messages {
417 MessageSettings::Output => {
418 index += 1;
419
420 if index == focus {
421 Some((BlockType::Out, i.out_messages, i.scroll_status_out))
422 } else {
423 None
424 }
425 }
426 MessageSettings::Error => {
427 index += 1;
428
429 if index == focus {
430 Some((BlockType::Err, i.err_messages, i.scroll_status_err))
431 } else {
432 None
433 }
434 }
435 MessageSettings::All => {
436 index += 1;
437
438 if index == focus {
439 Some((BlockType::Out, i.out_messages, i.scroll_status_out))
440 } else if index + 1 == focus {
441 Some((BlockType::Err, i.err_messages, i.scroll_status_err))
442 } else {
443 index += 1;
444 None
445 }
446 }
447 MessageSettings::None => None,
448 } {
449 render_frame(
450 frame,
451 frame.area(),
452 i.name,
453 ty,
454 BlockFocus::Exit,
455 messages,
456 &scroll,
457 );
458 break;
459 }
460 }
461 }
462 } else {
463 let main_chunks = Layout::default()
464 .direction(Direction::Horizontal)
465 .constraints(if processes.is_empty() {
466 vec![Constraint::Percentage(100)]
467 } else {
468 vec![Constraint::Percentage(30), Constraint::Percentage(70)]
469 })
470 .split(frame.area());
471
472 render_frame(
473 frame,
474 main_chunks[0],
475 "",
476 BlockType::Main,
477 BlockFocus::Enter(0),
478 main_messages,
479 &main_scroll.main_scroll,
480 );
481
482 if processes.is_empty() {
483 return;
484 }
485
486 let processes_chunks = Layout::default()
487 .direction(Direction::Horizontal)
488 .constraints(vec![
489 Constraint::Ratio(1, processes.len() as u32);
490 processes.len()
491 ])
492 .split(main_chunks[1]);
493
494 let mut focus = 0;
495
496 for (index, process) in processes.into_iter().enumerate() {
497 match process.settings.messages {
498 MessageSettings::Output => {
499 focus += 1;
500
501 render_frame(
502 frame,
503 processes_chunks[index],
504 process.name,
505 BlockType::Out,
506 BlockFocus::Enter(focus),
507 process.out_messages,
508 &process.scroll_status_out,
509 );
510 }
511 MessageSettings::Error => {
512 focus += 1;
513
514 render_frame(
515 frame,
516 processes_chunks[index],
517 process.name,
518 BlockType::Err,
519 BlockFocus::Enter(focus),
520 process.err_messages,
521 &process.scroll_status_err,
522 );
523 }
524 MessageSettings::All => {
525 let process_chunks = Layout::default()
526 .direction(Direction::Vertical)
527 .constraints([
528 Constraint::Percentage(70),
529 Constraint::Percentage(30),
530 ])
531 .split(processes_chunks[index]);
532
533 focus += 1;
534 render_frame(
535 frame,
536 process_chunks[0],
537 &process.name,
538 BlockType::Out,
539 BlockFocus::Enter(focus),
540 process.out_messages,
541 &process.scroll_status_out,
542 );
543
544 focus += 1;
545 render_frame(
546 frame,
547 process_chunks[1],
548 process.name,
549 BlockType::Err,
550 BlockFocus::Enter(focus),
551 process.err_messages,
552 &process.scroll_status_err,
553 );
554 }
555 MessageSettings::None => {}
556 }
557 }
558 }
559 })
560 .unwrap();
561
562 sleep_thread();
563 }
564}
565
566fn render_frame<N>(
567 frame: &mut Frame,
568 chunk: Rect,
569 name: N,
570 ty: BlockType,
571 focus: BlockFocus,
572 messages: Vec<String>,
573 scroll: &ScrollStatus,
574) where
575 N: ToString,
576{
577 let select_message = if messages.len() == 0 {
578 None
579 } else {
580 Some(messages.len() - 1)
581 };
582
583 let mut state = ListState::default().with_selected(select_message);
584
585 let sub_title = match ty {
586 BlockType::Main => Line::from("Main").cyan().bold(),
587 BlockType::Out => Line::from("Out").light_green().bold(),
588 BlockType::Err => Line::from("Err").light_red().bold(),
589 };
590
591 let focus_txt = match focus {
592 BlockFocus::Enter(index) => format!("full screen: '{index}'"),
593 BlockFocus::Exit => format!("press 'Esc' to exit full screen"),
594 };
595
596 let mut block = Block::default()
597 .title(Line::from(name.to_string()).gray().bold().centered())
598 .title(sub_title.centered())
599 .title(Line::from(focus_txt).right_aligned().italic().dark_gray())
600 .borders(Borders::ALL);
601
602 let is_scrolling = if let Some(y) = scroll.y {
603 let offset = messages.len().saturating_sub(y as usize);
604
605 state.scroll_up_by(offset as u16);
606
607 block = block.title(
608 Line::from(format!(
609 "Scrolling: offset {offset} - press 'shift + scroll_down' key to stop scrolling."
610 ))
611 .bold()
612 .left_aligned()
613 .yellow(),
614 );
615
616 true
617 } else {
618 false
619 };
620
621 let messages = messages
622 .into_iter()
623 .flat_map(|message| {
624 let messages = textwrap::wrap(&message, chunk.width.saturating_sub(3) as usize);
625
626 let leading_spaces = messages
627 .first()
628 .map(|first_message| {
629 " ".repeat(first_message.chars().take_while(|&c| c == ' ').count())
630 })
631 .unwrap_or_default();
632
633 messages
634 .into_iter()
635 .enumerate()
636 .map(|(i, message)| {
637 let mut message = message.into_owned();
638
639 if i != 0 {
640 message.insert_str(0, &leading_spaces);
641 }
642
643 Text::from(message)
644 })
645 .collect::<Vec<_>>()
646 })
647 .collect::<Vec<_>>();
648
649 let mut list = List::new(messages).block(block);
650
651 if is_scrolling {
652 list = list.highlight_style(Style::default().yellow().bold());
653 }
654
655 frame.render_stateful_widget(list, chunk, &mut state);
656}
657
658fn sleep_thread() {
659 sleep(Duration::from_millis(50));
660}
661
662enum BlockType {
663 Main,
664 Out,
665 Err,
666}
667
668enum BlockFocus {
669 Enter(usize),
670 Exit,
671}
672
673#[derive(Clone, PartialEq)]
674struct Process<
675 O = SharedMessages,
676 E = SharedMessages,
677 S = Shared<ScrollStatus>,
678 SM = Shared<Option<SearchMessage>>,
679> {
680 pub name: String,
681 pub out_messages: O,
682 pub err_messages: E,
683 pub settings: ProcessSettings,
684 pub scroll_status_out: S,
685 pub scroll_status_err: S,
686 pub search_message: SM,
687}
688
689impl Process {
690 pub fn new(name: String, settings: ProcessSettings) -> Process {
691 Process {
692 name,
693 settings,
694 out_messages: Default::default(),
695 err_messages: Default::default(),
696 scroll_status_out: Default::default(),
697 scroll_status_err: Default::default(),
698 search_message: Default::default(),
699 }
700 }
701
702 pub fn detach(&self) -> DetachProcess {
703 Process {
704 name: self.name.clone(),
705 settings: self.settings.clone(),
706 out_messages: self.out_messages.read_access().clone(),
707 err_messages: self.err_messages.read_access().clone(),
708 scroll_status_out: self.scroll_status_out.read_access().clone(),
709 scroll_status_err: self.scroll_status_err.read_access().clone(),
710 search_message: (),
711 }
712 }
713}
714
715#[derive(PartialEq)]
716struct SearchMessage {
717 pub submsg: String,
718 pub message: Option<String>,
719}
720
721impl SearchMessage {
722 pub fn new(submsg: String) -> Self {
723 Self {
724 submsg,
725 message: None,
726 }
727 }
728}
729
730#[derive(Clone, PartialEq)]
731struct DrawCache<MM = SharedMessages, MS = BaseStatus, P = SharedProcesses> {
732 pub main_messages: MM,
733 pub main_scroll: MS,
734 pub processes: P,
735}
736
737impl DrawCache {
738 pub fn new(
739 main_messages: SharedMessages,
740 main_scroll: BaseStatus,
741 processes: SharedProcesses,
742 ) -> Self {
743 Self {
744 main_messages,
745 main_scroll,
746 processes,
747 }
748 }
749
750 pub fn default_detach() -> DrawCacheDetach {
751 DrawCache {
752 main_messages: Default::default(),
753 main_scroll: Default::default(),
754 processes: Default::default(),
755 }
756 }
757
758 pub fn detach(&self) -> DrawCacheDetach {
759 DrawCache {
760 main_messages: self.main_messages.read_access().clone(),
761 main_scroll: self.main_scroll.detach(),
762 processes: self
763 .processes
764 .read_access()
765 .iter()
766 .map(Process::detach)
767 .collect::<Vec<_>>(),
768 }
769 }
770}
771
772struct Regex(regex::Regex);
773
774impl Regex {
775 pub fn new() -> Self {
776 Self(regex::Regex::new(r"\x1b\[([\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e])").unwrap())
777 }
778
779 pub fn clear(&self, line: String) -> String {
780 self.0.replace_all(&line, "").to_string()
781 }
782}
783
784pub struct Focus {
785 pub index: usize,
786 pub at: usize,
787}