whiz/actors/
console.rs

1use actix::prelude::*;
2use ansi_to_tui::IntoText;
3use chrono::prelude::*;
4use crossterm::event::KeyEvent;
5use ratatui::backend::Backend;
6use ratatui::layout::Rect;
7use ratatui::text::Line;
8use ratatui::Frame;
9use std::rc::Rc;
10use std::str;
11use std::{cmp::min, collections::HashMap, io};
12use subprocess::ExitStatus;
13
14use ratatui::{
15    backend::CrosstermBackend,
16    layout::{Constraint, Direction, Layout},
17    style::{Color, Modifier, Style},
18    text::Span,
19    widgets::{Block, Borders, Paragraph, Tabs, Wrap},
20    Terminal,
21};
22
23use crossterm::{
24    cursor,
25    event::{self, Event, KeyCode, KeyModifiers, MouseEventKind},
26    execute,
27    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
28};
29
30use super::command::{CommandActor, PoisonPill, Reload};
31
32pub struct Panel {
33    logs: Vec<(String, Style)>,
34    lines: u16,
35    shift: u16,
36    command: Addr<CommandActor>,
37    status: Option<ExitStatus>,
38}
39
40impl Panel {
41    pub fn new(command: Addr<CommandActor>) -> Self {
42        Self {
43            logs: Vec::default(),
44            lines: 0,
45            shift: 0,
46            command,
47            status: None,
48        }
49    }
50}
51
52pub struct ConsoleActor {
53    terminal: Terminal<CrosstermBackend<io::Stdout>>,
54    index: String,
55    order: Vec<String>,
56    arbiter: Arbiter,
57    panels: HashMap<String, Panel>,
58    timestamp: bool,
59}
60
61pub fn chunks<T: Backend>(f: &Frame<T>) -> Rc<[Rect]> {
62    Layout::default()
63        .direction(Direction::Vertical)
64        .constraints([Constraint::Min(0), Constraint::Length(3)].as_ref())
65        .split(f.size())
66}
67
68impl ConsoleActor {
69    pub fn new(order: Vec<String>, timestamp: bool) -> Self {
70        let stdout = io::stdout();
71        let backend = CrosstermBackend::new(stdout);
72        let terminal = Terminal::new(backend).unwrap();
73        Self {
74            terminal,
75            index: order[0].clone(),
76            order,
77            arbiter: Arbiter::new(),
78            panels: HashMap::default(),
79            timestamp,
80        }
81    }
82
83    pub fn up(&mut self, shift: u16) {
84        let log_height = self.get_log_height();
85        if let Some(focused_panel) = self.panels.get_mut(&self.index) {
86            // maximum_scroll is the number of lines
87            // overflowing in the current focused panel
88            let maximum_scroll = focused_panel.lines - min(focused_panel.lines, log_height);
89
90            // `focused_panel.shift` goes from 0 until maximum_scroll
91            focused_panel.shift = min(focused_panel.shift + shift, maximum_scroll);
92        }
93    }
94
95    pub fn down(&mut self, shift: u16) {
96        if let Some(focused_panel) = self.panels.get_mut(&self.index) {
97            if focused_panel.shift >= shift {
98                focused_panel.shift -= shift;
99            } else {
100                focused_panel.shift = 0;
101            }
102        }
103    }
104
105    pub fn get_log_height(&mut self) -> u16 {
106        let frame = self.terminal.get_frame();
107        chunks(&frame)[0].height
108    }
109
110    pub fn go_to(&mut self, panel_index: usize) {
111        if panel_index < self.order.len() {
112            self.index = self.order[panel_index].clone();
113        }
114    }
115
116    pub fn idx(&self) -> usize {
117        self.order
118            .iter()
119            .position(|e| e == &self.index)
120            .unwrap_or(0)
121    }
122
123    pub fn next(&mut self) {
124        self.index = self.order[(self.idx() + 1) % self.order.len()].clone();
125    }
126
127    pub fn previous(&mut self) {
128        self.index = self.order[(self.idx() + self.order.len() - 1) % self.order.len()].clone();
129    }
130
131    fn clean(&mut self) {
132        self.terminal
133            .draw(|f| {
134                let clean =
135                    Block::default().style(Style::default().bg(Color::White).fg(Color::Black));
136                f.render_widget(clean, f.size());
137            })
138            .unwrap();
139    }
140
141    fn draw(&mut self) {
142        let idx = self.idx();
143        if let Some(focused_panel) = &self.panels.get(&self.index) {
144            self.terminal
145                .draw(|f| {
146                    let chunks = chunks(f);
147                    let logs = &focused_panel.logs;
148
149                    let log_height = chunks[0].height;
150                    let maximum_scroll = focused_panel.lines - min(focused_panel.lines, log_height);
151
152                    let lines: Vec<Line> = logs
153                        .iter()
154                        .flat_map(|l| {
155                            let mut t = l.0.into_text().unwrap();
156                            t.patch_style(l.1);
157                            t.lines
158                        })
159                        .collect();
160
161                    let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
162
163                    // scroll by default until the last line
164                    let paragraph = paragraph
165                        .scroll((maximum_scroll - min(maximum_scroll, focused_panel.shift), 0));
166                    f.render_widget(paragraph, chunks[0]);
167
168                    let /*mut*/ titles: Vec<Line> = self
169                        .order
170                        .iter()
171                        .map(|panel| {
172                            let span = self.panels.get(panel).map(|p| match p.status {
173                                Some(ExitStatus::Exited(0)) => Span::styled(format!("{}.", panel), Style::default().fg(Color::Green)),
174                                Some(_) => Span::styled(format!("{}!", panel), Style::default().fg(Color::Red)),
175                                None => Span::styled(format!("{}*", panel), Style::default()),
176                            }).unwrap_or_else(|| Span::styled(panel, Style::default()));
177                            Line::from(span)
178                        })
179                        .collect();
180                    /*
181                    titles.push(Spans::from(Span::raw(format!(
182                        "shift {} / window {} / lines {} / max {} / compute {}",
183                        focus.shift,
184                        log_height,
185                        logs.len(),
186                        focus.lines,
187                        f.size().width,
188                    ))));
189                    */
190                    let tabs = Tabs::new(titles)
191                        .block(Block::default().borders(Borders::ALL))
192                        .select(idx)
193                        .highlight_style(
194                            Style::default()
195                                .add_modifier(Modifier::BOLD)
196                                .bg(Color::DarkGray),
197                        );
198                    f.render_widget(tabs, chunks[1]);
199                })
200                .unwrap();
201        }
202    }
203}
204
205impl Actor for ConsoleActor {
206    type Context = Context<Self>;
207
208    fn started(&mut self, ctx: &mut Context<Self>) {
209        enable_raw_mode().unwrap();
210        execute!(
211            self.terminal.backend_mut(),
212            cursor::Hide,
213            EnterAlternateScreen,
214        )
215        .unwrap();
216
217        let addr = ctx.address();
218        self.arbiter.spawn(async move {
219            loop {
220                addr.do_send(TermEvent(event::read().unwrap()));
221            }
222        });
223
224        self.clean();
225        self.draw();
226    }
227
228    fn stopped(&mut self, _: &mut Self::Context) {
229        self.arbiter.stop();
230        self.clean();
231
232        execute!(
233            self.terminal.backend_mut(),
234            LeaveAlternateScreen,
235            cursor::Show,
236        )
237        .unwrap();
238        disable_raw_mode().unwrap();
239    }
240}
241
242#[derive(Message, Debug)]
243#[rtype(result = "()")]
244pub struct TermEvent(Event);
245
246impl TermEvent {
247    pub fn quit() -> Self {
248        Self(Event::Key(KeyEvent::new(
249            KeyCode::Char('q'),
250            KeyModifiers::NONE,
251        )))
252    }
253}
254
255impl Handler<TermEvent> for ConsoleActor {
256    type Result = ();
257
258    fn handle(&mut self, msg: TermEvent, _: &mut Context<Self>) -> Self::Result {
259        match msg.0 {
260            Event::Key(e) => match (e.modifiers, e.code) {
261                (KeyModifiers::CONTROL, KeyCode::Char('c'))
262                | (KeyModifiers::NONE, KeyCode::Char('q')) => {
263                    self.panels
264                        .values()
265                        .for_each(|e| e.command.do_send(PoisonPill));
266                    System::current().stop();
267                }
268                (KeyModifiers::NONE, KeyCode::Up | KeyCode::Char('k'))
269                | (KeyModifiers::CONTROL, KeyCode::Char('p')) => {
270                    self.up(1);
271                }
272                (KeyModifiers::NONE, KeyCode::Down | KeyCode::Char('j'))
273                | (KeyModifiers::CONTROL, KeyCode::Char('n')) => {
274                    self.down(1);
275                }
276                (KeyModifiers::CONTROL, key_code) => match key_code {
277                    KeyCode::Char('f') => {
278                        let log_height = self.get_log_height();
279                        self.down(log_height);
280                    }
281                    KeyCode::Char('u') => {
282                        let log_height = self.get_log_height();
283                        self.up(log_height / 2);
284                    }
285                    KeyCode::Char('d') => {
286                        let log_height = self.get_log_height();
287                        self.down(log_height / 2);
288                    }
289                    KeyCode::Char('b') => {
290                        let log_height = self.get_log_height();
291                        self.up(log_height);
292                    }
293                    _ => {}
294                },
295                (KeyModifiers::NONE, key_code) => match key_code {
296                    KeyCode::Char('r') => {
297                        if let Some(focused_panel) = self.panels.get(&self.index) {
298                            focused_panel.command.do_send(Reload::Manual);
299                        }
300                    }
301                    KeyCode::Right | KeyCode::Char('l') => {
302                        self.next();
303                    }
304                    KeyCode::Left | KeyCode::Char('h') => {
305                        self.previous();
306                    }
307                    KeyCode::Char(ch) => {
308                        if ch.is_ascii_digit() {
309                            let mut panel_index = ch.to_digit(10).unwrap() as usize;
310                            // first tab is key 1, therefore
311                            // in key 0 go to last tab
312                            if panel_index == 0 {
313                                panel_index = self.order.len() - 1;
314                            } else {
315                                panel_index -= 1;
316                            }
317                            self.go_to(panel_index);
318                        }
319                    }
320                    _ => {}
321                },
322                _ => {}
323            },
324            Event::Resize(width, _) => {
325                for panel in self.panels.values_mut() {
326                    panel.shift = 0;
327                    let new_lines = panel
328                        .logs
329                        .iter()
330                        .fold(0, |agg, l| agg + wrapped_lines(&l.0, width));
331                    panel.lines = new_lines;
332                }
333            }
334            Event::Mouse(e) => match e.kind {
335                MouseEventKind::ScrollUp => {
336                    self.up(1);
337                }
338                MouseEventKind::ScrollDown => {
339                    self.down(1);
340                }
341                _ => {}
342            },
343            _ => {}
344        }
345        self.draw();
346    }
347}
348
349#[derive(Message)]
350#[rtype(result = "()")]
351pub struct Output {
352    panel_name: String,
353    pub message: String,
354    service: bool,
355    timestamp: DateTime<Local>,
356}
357
358impl Output {
359    pub fn now(panel_name: String, message: String, service: bool) -> Self {
360        Self {
361            panel_name,
362            message,
363            service,
364            timestamp: Local::now(),
365        }
366    }
367}
368
369fn wrapped_lines(message: &String, width: u16) -> u16 {
370    let clean = strip_ansi_escapes::strip(message);
371    textwrap::wrap(str::from_utf8(&clean).unwrap(), width as usize).len() as u16
372}
373
374/// Formats a message with a timestamp in `"{timestamp}  {message}"`.
375fn format_message(message: &str, timestamp: &DateTime<Local>) -> String {
376    format!("{}  {}", timestamp.format("%H:%M:%S%.3f"), message)
377}
378
379impl Handler<Output> for ConsoleActor {
380    type Result = ();
381
382    fn handle(&mut self, msg: Output, _: &mut Context<Self>) -> Self::Result {
383        let panel = self.panels.get_mut(&msg.panel_name).unwrap();
384        let style = match msg.service {
385            true => Style::default().bg(Color::DarkGray),
386            false => Style::default(),
387        };
388
389        let message = match self.timestamp {
390            true => format_message(&msg.message, &msg.timestamp),
391            false => msg.message,
392        };
393        let width = self.terminal.get_frame().size().width;
394        panel.lines += wrapped_lines(&message, width);
395        panel.logs.push((message, style));
396        self.draw();
397    }
398}
399
400#[derive(Message)]
401#[rtype(result = "()")]
402pub struct RegisterPanel {
403    pub name: String,
404    pub addr: Addr<CommandActor>,
405}
406
407impl Handler<RegisterPanel> for ConsoleActor {
408    type Result = ();
409
410    fn handle(&mut self, msg: RegisterPanel, _: &mut Context<Self>) -> Self::Result {
411        if !self.panels.contains_key(&msg.name) {
412            self.panels.insert(msg.name.clone(), Panel::new(msg.addr));
413        }
414        if !self.order.contains(&msg.name) {
415            self.order.push(msg.name);
416        }
417        self.draw();
418    }
419}
420
421#[derive(Message)]
422#[rtype(result = "()")]
423pub struct PanelStatus {
424    pub panel_name: String,
425    pub status: Option<ExitStatus>,
426}
427
428impl Handler<PanelStatus> for ConsoleActor {
429    type Result = ();
430
431    fn handle(&mut self, msg: PanelStatus, ctx: &mut Context<Self>) -> Self::Result {
432        let focused_panel = self.panels.get_mut(&msg.panel_name).unwrap();
433        focused_panel.status = msg.status;
434
435        if let Some(message) = msg.status.map(|c| format!("Status: {:?}", c)) {
436            ctx.address()
437                .do_send(Output::now(msg.panel_name, message, true));
438        }
439
440        self.draw();
441    }
442}