process_terminal/
terminal.rs

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) = &regex {
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) = &regex {
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}