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(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}