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
267impl Drop for Terminal {
268 fn drop(&mut self) {
269 ratatui::restore();
270 }
271}
272
273fn thread_output(
274 stdout: ChildStdout,
275 messages: SharedMessages,
276 search_message: Shared<Option<SearchMessage>>,
277) {
278 let regex = Regex::new();
279
280 for line in BufReader::new(stdout).lines() {
281 let line = regex.clear(line.expect("Failed to read line from stdout."));
282
283 messages.write_with(|mut messages| {
284 messages.push(line.clone());
285 });
286
287 search_message.write_with(|mut maybe_search_message| {
288 if let Some(search_message) = maybe_search_message.as_mut() {
289 if line.contains(&search_message.submsg) {
290 search_message.message = Some(line);
291 }
292 }
293 });
294 }
295}
296
297fn thread_error(stderr: ChildStderr, messages: SharedMessages) {
298 let regex = Regex::new();
299
300 for line in BufReader::new(stderr).lines() {
301 let line = regex.clear(line.expect("Failed to read line from stderr."));
302
303 messages.write_with(|mut messages| {
304 messages.push(line);
305 });
306 }
307}
308
309fn thread_exit(process_name: String, mut child: Child, main_messages: SharedMessages) {
310 let exit_status = match child.wait() {
311 Ok(status) => format!("ok: {status}."),
312
313 Err(err) => format!("fail with error: {err}."),
314 };
315
316 main_messages.write_with(|mut messages| {
317 messages.push(format!("Process '{process_name}' exited: {exit_status}"));
318 });
319}
320
321fn thread_input(inputs: Shared<KeyBoardActions>) {
322 loop {
323 let event = crossterm::event::read().expect("Failed to read event.");
324
325 inputs.read_with(|inputs| {
326 inputs.apply_event(event);
327 });
328 }
329}
330
331fn thread_draw(main_messages: SharedMessages, main_scroll: BaseStatus, processes: SharedProcesses) {
332 let mut terminal = ratatui::init();
333
334 let data = DrawCache::new(main_messages, main_scroll, processes);
335
336 let mut cache = DrawCache::default_detach();
337
338 loop {
339 let read = data.detach();
340
341 if read == cache {
342 sleep_thread();
343 continue;
344 } else {
345 cache = read.clone();
346 }
347
348 let DrawCache {
349 main_messages,
350 main_scroll,
351 processes,
352 } = read;
353
354 terminal
355 .draw(|frame| {
356 if let Some(focus) = main_scroll.focus {
357 if focus == 0 {
358 render_frame(
359 frame,
360 frame.area(),
361 "",
362 BlockType::Main,
363 BlockFocus::Exit,
364 main_messages,
365 &main_scroll.main_scroll,
366 );
367 } else {
368 let mut index = 0;
369 for i in processes {
370 if let Some((ty, messages, scroll)) = match i.settings.messages {
371 MessageSettings::Output => {
372 index += 1;
373
374 if index == focus {
375 Some((BlockType::Out, i.out_messages, i.scroll_status_out))
376 } else {
377 None
378 }
379 }
380 MessageSettings::Error => {
381 index += 1;
382
383 if index == focus {
384 Some((BlockType::Err, i.err_messages, i.scroll_status_err))
385 } else {
386 None
387 }
388 }
389 MessageSettings::All => {
390 index += 1;
391
392 if index == focus {
393 Some((BlockType::Out, i.out_messages, i.scroll_status_out))
394 } else if index + 1 == focus {
395 Some((BlockType::Err, i.err_messages, i.scroll_status_err))
396 } else {
397 index += 1;
398 None
399 }
400 }
401 MessageSettings::None => None,
402 } {
403 render_frame(
404 frame,
405 frame.area(),
406 i.name,
407 ty,
408 BlockFocus::Exit,
409 messages,
410 &scroll,
411 );
412 break;
413 }
414 }
415 }
416 } else {
417 let main_chunks = Layout::default()
418 .direction(Direction::Horizontal)
419 .constraints(if processes.is_empty() {
420 vec![Constraint::Percentage(100)]
421 } else {
422 vec![Constraint::Percentage(30), Constraint::Percentage(70)]
423 })
424 .split(frame.area());
425
426 render_frame(
427 frame,
428 main_chunks[0],
429 "",
430 BlockType::Main,
431 BlockFocus::Enter(0),
432 main_messages,
433 &main_scroll.main_scroll,
434 );
435
436 if processes.is_empty() {
437 return;
438 }
439
440 let processes_chunks = Layout::default()
441 .direction(Direction::Horizontal)
442 .constraints(vec![
443 Constraint::Ratio(1, processes.len() as u32);
444 processes.len()
445 ])
446 .split(main_chunks[1]);
447
448 let mut focus = 0;
449
450 for (index, process) in processes.into_iter().enumerate() {
451 match process.settings.messages {
452 MessageSettings::Output => {
453 focus += 1;
454
455 render_frame(
456 frame,
457 processes_chunks[index],
458 process.name,
459 BlockType::Out,
460 BlockFocus::Enter(focus),
461 process.out_messages,
462 &process.scroll_status_out,
463 );
464 }
465 MessageSettings::Error => {
466 focus += 1;
467
468 render_frame(
469 frame,
470 processes_chunks[index],
471 process.name,
472 BlockType::Err,
473 BlockFocus::Enter(focus),
474 process.err_messages,
475 &process.scroll_status_err,
476 );
477 }
478 MessageSettings::All => {
479 let process_chunks = Layout::default()
480 .direction(Direction::Vertical)
481 .constraints([
482 Constraint::Percentage(70),
483 Constraint::Percentage(30),
484 ])
485 .split(processes_chunks[index]);
486
487 focus += 1;
488 render_frame(
489 frame,
490 process_chunks[0],
491 &process.name,
492 BlockType::Out,
493 BlockFocus::Enter(focus),
494 process.out_messages,
495 &process.scroll_status_out,
496 );
497
498 focus += 1;
499 render_frame(
500 frame,
501 process_chunks[1],
502 process.name,
503 BlockType::Err,
504 BlockFocus::Enter(focus),
505 process.err_messages,
506 &process.scroll_status_err,
507 );
508 }
509 MessageSettings::None => {}
510 }
511 }
512 }
513 })
514 .unwrap();
515
516 sleep_thread();
517 }
518}
519
520fn render_frame<N>(
521 frame: &mut Frame,
522 chunk: Rect,
523 name: N,
524 ty: BlockType,
525 focus: BlockFocus,
526 messages: Vec<String>,
527 scroll: &ScrollStatus,
528) where
529 N: ToString,
530{
531 let select_message = if messages.len() == 0 {
532 None
533 } else {
534 Some(messages.len() - 1)
535 };
536
537 let mut state = ListState::default().with_selected(select_message);
538
539 let sub_title = match ty {
540 BlockType::Main => Line::from("Main").cyan().bold(),
541 BlockType::Out => Line::from("Out").light_green().bold(),
542 BlockType::Err => Line::from("Err").light_red().bold(),
543 };
544
545 let focus_txt = match focus {
546 BlockFocus::Enter(index) => format!("full screen: '{index}'"),
547 BlockFocus::Exit => format!("press 'Esc' to exit full screen"),
548 };
549
550 let mut block = Block::default()
551 .title(Line::from(name.to_string()).gray().bold().centered())
552 .title(sub_title.centered())
553 .title(Line::from(focus_txt).right_aligned().italic().dark_gray())
554 .borders(Borders::ALL);
555
556 let is_scrolling = if let Some(y) = scroll.y {
557 let offset = messages.len().saturating_sub(y as usize);
558
559 state.scroll_up_by(offset as u16);
560
561 block = block.title(
562 Line::from(format!(
563 "Scrolling: offset {offset} - press 'shift + scroll_down' key to stop scrolling."
564 ))
565 .bold()
566 .left_aligned()
567 .yellow(),
568 );
569
570 true
571 } else {
572 false
573 };
574
575 let messages = messages
576 .into_iter()
577 .map(|message| {
578 Text::from(textwrap::fill(&message, chunk.width.saturating_sub(3) as usize).clone())
579 })
580 .collect::<Vec<_>>();
581
582 let mut list = List::new(messages).block(block);
583
584 if is_scrolling {
585 list = list.highlight_style(Style::default().yellow().bold());
586 }
587
588 frame.render_stateful_widget(list, chunk, &mut state);
589}
590
591fn sleep_thread() {
592 sleep(Duration::from_millis(50));
593}
594
595enum BlockType {
596 Main,
597 Out,
598 Err,
599}
600
601enum BlockFocus {
602 Enter(usize),
603 Exit,
604}
605
606#[derive(Clone, PartialEq)]
607struct Process<
608 O = SharedMessages,
609 E = SharedMessages,
610 S = Shared<ScrollStatus>,
611 SM = Shared<Option<SearchMessage>>,
612> {
613 pub name: String,
614 pub out_messages: O,
615 pub err_messages: E,
616 pub settings: ProcessSettings,
617 pub scroll_status_out: S,
618 pub scroll_status_err: S,
619 pub search_message: SM,
620}
621
622impl Process {
623 pub fn new(name: String, settings: ProcessSettings) -> Process {
624 Process {
625 name,
626 settings,
627 out_messages: Default::default(),
628 err_messages: Default::default(),
629 scroll_status_out: Default::default(),
630 scroll_status_err: Default::default(),
631 search_message: Default::default(),
632 }
633 }
634
635 pub fn detach(&self) -> DetachProcess {
636 Process {
637 name: self.name.clone(),
638 settings: self.settings.clone(),
639 out_messages: self.out_messages.read_access().clone(),
640 err_messages: self.err_messages.read_access().clone(),
641 scroll_status_out: self.scroll_status_out.read_access().clone(),
642 scroll_status_err: self.scroll_status_err.read_access().clone(),
643 search_message: (),
644 }
645 }
646}
647
648#[derive(PartialEq)]
649struct SearchMessage {
650 pub submsg: String,
651 pub message: Option<String>,
652}
653
654impl SearchMessage {
655 pub fn new(submsg: String) -> Self {
656 Self {
657 submsg,
658 message: None,
659 }
660 }
661}
662
663#[derive(Clone, PartialEq)]
664struct DrawCache<MM = SharedMessages, MS = BaseStatus, P = SharedProcesses> {
665 pub main_messages: MM,
666 pub main_scroll: MS,
667 pub processes: P,
668}
669
670impl DrawCache {
671 pub fn new(
672 main_messages: SharedMessages,
673 main_scroll: BaseStatus,
674 processes: SharedProcesses,
675 ) -> Self {
676 Self {
677 main_messages,
678 main_scroll,
679 processes,
680 }
681 }
682
683 pub fn default_detach() -> DrawCacheDetach {
684 DrawCache {
685 main_messages: Default::default(),
686 main_scroll: Default::default(),
687 processes: Default::default(),
688 }
689 }
690
691 pub fn detach(&self) -> DrawCacheDetach {
692 DrawCache {
693 main_messages: self.main_messages.read_access().clone(),
694 main_scroll: self.main_scroll.detach(),
695 processes: self
696 .processes
697 .read_access()
698 .iter()
699 .map(Process::detach)
700 .collect::<Vec<_>>(),
701 }
702 }
703}
704
705struct Regex(regex::Regex);
706
707impl Regex {
708 pub fn new() -> Self {
709 Self(regex::Regex::new(r"\x1b\[([\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e])").unwrap())
710 }
711
712 pub fn clear(&self, line: String) -> String {
713 self.0.replace_all(&line, "").to_string()
714 }
715}
716
717pub struct Focus {
718 pub index: usize,
719 pub at: usize,
720}