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