Skip to main content

cu_consolemon/
lib.rs

1#[cfg(feature = "debug_pane")]
2use arboard::Clipboard;
3use color_eyre::config::HookBuilder;
4#[cfg(feature = "debug_pane")]
5use cu_tuimon::MonitorLogCapture;
6pub use cu_tuimon::{
7    MonitorModel, MonitorScreen, MonitorUi, MonitorUiAction, MonitorUiEvent, MonitorUiKey,
8    MonitorUiOptions, ScrollDirection,
9};
10use cu29::context::CuContext;
11use cu29::monitoring::{
12    ComponentId, CopperListIoStats, CopperListView, CuComponentState, CuMonitor,
13    CuMonitoringMetadata, CuMonitoringRuntime, Decision,
14};
15use cu29::{CuError, CuResult};
16use ratatui::backend::CrosstermBackend;
17use ratatui::crossterm::event::{
18    DisableMouseCapture, EnableMouseCapture, Event, KeyCode, MouseButton, MouseEventKind,
19};
20use ratatui::crossterm::terminal::{
21    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
22};
23use ratatui::crossterm::tty::IsTty;
24use ratatui::crossterm::{event, execute};
25use ratatui::{Terminal, TerminalOptions, Viewport};
26use std::backtrace::Backtrace;
27use std::io::{Write, stdin, stdout};
28use std::process;
29#[cfg(feature = "debug_pane")]
30use std::sync::Mutex;
31use std::sync::atomic::{AtomicBool, Ordering};
32use std::sync::{Arc, OnceLock};
33use std::thread::JoinHandle;
34use std::time::Duration;
35use std::{io, thread};
36
37/// A TUI based realtime console for Copper.
38pub struct CuConsoleMon {
39    model: MonitorModel,
40    ui_handle: Option<JoinHandle<()>>,
41    quitting: Arc<AtomicBool>,
42    #[cfg(feature = "debug_pane")]
43    log_capture: Option<Mutex<MonitorLogCapture>>,
44}
45
46impl CuConsoleMon {
47    pub fn model(&self) -> MonitorModel {
48        self.model.clone()
49    }
50}
51
52impl Drop for CuConsoleMon {
53    fn drop(&mut self) {
54        self.quitting.store(true, Ordering::SeqCst);
55        let _ = restore_terminal();
56        if let Some(handle) = self.ui_handle.take() {
57            let _ = handle.join();
58        }
59    }
60}
61
62struct UI {
63    monitor_ui: MonitorUi,
64    quitting: Arc<AtomicBool>,
65    #[cfg(feature = "debug_pane")]
66    clipboard: Option<Clipboard>,
67}
68
69impl UI {
70    fn new(model: MonitorModel, quitting: Arc<AtomicBool>) -> Self {
71        init_error_hooks();
72        Self {
73            monitor_ui: MonitorUi::new(
74                model,
75                MonitorUiOptions {
76                    show_quit_hint: true,
77                },
78            ),
79            quitting,
80            #[cfg(feature = "debug_pane")]
81            clipboard: None,
82        }
83    }
84
85    fn draw(&mut self, frame: &mut ratatui::Frame) {
86        self.monitor_ui.draw(frame);
87    }
88
89    fn handle_action(&mut self, action: MonitorUiAction) -> bool {
90        match action {
91            MonitorUiAction::None => false,
92            MonitorUiAction::QuitRequested => {
93                self.quitting.store(true, Ordering::SeqCst);
94                true
95            }
96            #[cfg(feature = "debug_pane")]
97            MonitorUiAction::CopyLogSelection(text) => {
98                self.copy_text(text);
99                false
100            }
101        }
102    }
103
104    fn handle_key(&mut self, key: KeyCode) -> bool {
105        let action = match key {
106            KeyCode::Char(ch) => {
107                self.monitor_ui
108                    .handle_event(MonitorUiEvent::Key(MonitorUiKey::Char(
109                        ch.to_ascii_lowercase(),
110                    )))
111            }
112            KeyCode::Left => self
113                .monitor_ui
114                .handle_event(MonitorUiEvent::Key(MonitorUiKey::Left)),
115            KeyCode::Right => self
116                .monitor_ui
117                .handle_event(MonitorUiEvent::Key(MonitorUiKey::Right)),
118            KeyCode::Up => self
119                .monitor_ui
120                .handle_event(MonitorUiEvent::Key(MonitorUiKey::Up)),
121            KeyCode::Down => self
122                .monitor_ui
123                .handle_event(MonitorUiEvent::Key(MonitorUiKey::Down)),
124            _ => MonitorUiAction::None,
125        };
126
127        self.handle_action(action)
128    }
129
130    fn handle_mouse_event(&mut self, mouse: event::MouseEvent) {
131        let action = match mouse.kind {
132            MouseEventKind::Down(MouseButton::Left) => {
133                self.monitor_ui.handle_event(MonitorUiEvent::MouseDown {
134                    col: mouse.column,
135                    row: mouse.row,
136                })
137            }
138            #[cfg(feature = "debug_pane")]
139            MouseEventKind::Drag(MouseButton::Left) => {
140                self.monitor_ui.handle_event(MonitorUiEvent::MouseDrag {
141                    col: mouse.column,
142                    row: mouse.row,
143                })
144            }
145            #[cfg(feature = "debug_pane")]
146            MouseEventKind::Up(MouseButton::Left) => {
147                self.monitor_ui.handle_event(MonitorUiEvent::MouseUp {
148                    col: mouse.column,
149                    row: mouse.row,
150                })
151            }
152            MouseEventKind::ScrollDown => self.monitor_ui.handle_event(MonitorUiEvent::Scroll {
153                direction: ScrollDirection::Down,
154                steps: 1,
155            }),
156            MouseEventKind::ScrollUp => self.monitor_ui.handle_event(MonitorUiEvent::Scroll {
157                direction: ScrollDirection::Up,
158                steps: 1,
159            }),
160            MouseEventKind::ScrollLeft => self.monitor_ui.handle_event(MonitorUiEvent::Scroll {
161                direction: ScrollDirection::Left,
162                steps: 5,
163            }),
164            MouseEventKind::ScrollRight => self.monitor_ui.handle_event(MonitorUiEvent::Scroll {
165                direction: ScrollDirection::Right,
166                steps: 5,
167            }),
168            _ => MonitorUiAction::None,
169        };
170
171        let _ = self.handle_action(action);
172    }
173
174    #[cfg(feature = "debug_pane")]
175    fn copy_text(&mut self, text: String) {
176        if text.is_empty() {
177            return;
178        }
179        if self.clipboard.is_none() {
180            match Clipboard::new() {
181                Ok(clipboard) => self.clipboard = Some(clipboard),
182                Err(err) => {
183                    eprintln!("CuConsoleMon clipboard init failed: {err}");
184                    return;
185                }
186            }
187        }
188        if let Some(clipboard) = self.clipboard.as_mut()
189            && let Err(err) = clipboard.set_text(text)
190        {
191            eprintln!("CuConsoleMon clipboard copy failed: {err}");
192        }
193    }
194
195    fn run_app<B: ratatui::prelude::Backend<Error = io::Error>>(
196        &mut self,
197        terminal: &mut Terminal<B>,
198    ) -> io::Result<()> {
199        loop {
200            if self.quitting.load(Ordering::SeqCst) {
201                break;
202            }
203
204            terminal.draw(|frame| {
205                self.draw(frame);
206            })?;
207
208            if event::poll(Duration::from_millis(50))? {
209                match event::read()? {
210                    Event::Key(key) if self.handle_key(key.code) => {
211                        break;
212                    }
213                    Event::Mouse(mouse) => self.handle_mouse_event(mouse),
214                    Event::Resize(_, _) => self.monitor_ui.mark_graph_dirty(),
215                    _ => {}
216                }
217            }
218        }
219        Ok(())
220    }
221}
222
223impl CuMonitor for CuConsoleMon {
224    fn new(metadata: CuMonitoringMetadata, _runtime: CuMonitoringRuntime) -> CuResult<Self> {
225        Ok(Self {
226            model: MonitorModel::from_metadata(&metadata),
227            ui_handle: None,
228            quitting: Arc::new(AtomicBool::new(false)),
229            #[cfg(feature = "debug_pane")]
230            log_capture: None,
231        })
232    }
233
234    fn observe_copperlist_io(&self, stats: CopperListIoStats) {
235        self.model.observe_copperlist_io(stats);
236    }
237
238    fn start(&mut self, _ctx: &CuContext) -> CuResult<()> {
239        #[cfg(feature = "debug_pane")]
240        {
241            self.log_capture = Some(Mutex::new(if should_start_ui() {
242                MonitorLogCapture::to_model(self.model.clone())
243            } else {
244                MonitorLogCapture::to_stdout()
245            }));
246        }
247
248        if !should_start_ui() {
249            return Ok(());
250        }
251
252        let model = self.model.clone();
253        let quitting = self.quitting.clone();
254        let handle = thread::spawn(move || {
255            let backend = CrosstermBackend::new(stdout());
256            let _terminal_guard = TerminalRestoreGuard;
257
258            if let Err(err) = setup_terminal() {
259                eprintln!("Failed to prepare terminal UI: {err}");
260                return;
261            }
262
263            let mut terminal = match Terminal::with_options(
264                backend,
265                TerminalOptions {
266                    viewport: Viewport::Fullscreen,
267                },
268            ) {
269                Ok(terminal) => terminal,
270                Err(err) => {
271                    eprintln!("Failed to initialize terminal backend: {err}");
272                    return;
273                }
274            };
275
276            let mut ui = UI::new(model, quitting.clone());
277            if let Err(err) = ui.run_app(&mut terminal) {
278                let _ = restore_terminal();
279                eprintln!("CuConsoleMon UI exited with error: {err}");
280                return;
281            }
282
283            quitting.store(true, Ordering::SeqCst);
284            let _ = restore_terminal();
285        });
286
287        self.ui_handle = Some(handle);
288        Ok(())
289    }
290
291    fn process_copperlist(&self, ctx: &CuContext, view: CopperListView<'_>) -> CuResult<()> {
292        #[cfg(feature = "debug_pane")]
293        if let Some(log_capture) = &self.log_capture {
294            let mut log_capture = log_capture.lock().unwrap_or_else(|err| err.into_inner());
295            log_capture.poll();
296        }
297
298        self.model.process_copperlist(ctx.cl_id(), view);
299        if self.quitting.load(Ordering::SeqCst) {
300            return Err("Exiting...".into());
301        }
302        Ok(())
303    }
304
305    fn process_error(
306        &self,
307        component_id: ComponentId,
308        step: CuComponentState,
309        error: &CuError,
310    ) -> Decision {
311        self.model
312            .set_component_error(component_id, error.to_string());
313        match step {
314            CuComponentState::Start => Decision::Shutdown,
315            CuComponentState::Preprocess => Decision::Abort,
316            CuComponentState::Process => Decision::Ignore,
317            CuComponentState::Postprocess => Decision::Ignore,
318            CuComponentState::Stop => Decision::Shutdown,
319        }
320    }
321
322    fn stop(&mut self, _ctx: &CuContext) -> CuResult<()> {
323        self.quitting.store(true, Ordering::SeqCst);
324        let _ = restore_terminal();
325
326        if let Some(handle) = self.ui_handle.take() {
327            let _ = handle.join();
328        }
329
330        #[cfg(feature = "debug_pane")]
331        {
332            self.log_capture = None;
333        }
334
335        self.model.reset_latency();
336        Ok(())
337    }
338}
339
340struct TerminalRestoreGuard;
341
342impl Drop for TerminalRestoreGuard {
343    fn drop(&mut self) {
344        let _ = restore_terminal();
345    }
346}
347
348fn init_error_hooks() {
349    static ONCE: OnceLock<()> = OnceLock::new();
350    if ONCE.get().is_some() {
351        return;
352    }
353
354    let (_panic_hook, error) = HookBuilder::default().into_hooks();
355    let error = error.into_eyre_hook();
356    color_eyre::eyre::set_hook(Box::new(move |err| {
357        let _ = restore_terminal();
358        error(err)
359    }))
360    .unwrap();
361
362    std::panic::set_hook(Box::new(move |info| {
363        let _ = restore_terminal();
364        let backtrace = Backtrace::force_capture();
365        println!("CuConsoleMon panic: {info}");
366        println!("Backtrace:\n{backtrace}");
367        let _ = stdout().flush();
368        process::exit(1);
369    }));
370
371    let _ = ONCE.set(());
372}
373
374fn setup_terminal() -> io::Result<()> {
375    enable_raw_mode()?;
376    execute!(stdout(), EnterAlternateScreen, EnableMouseCapture)?;
377    Ok(())
378}
379
380fn restore_terminal() -> io::Result<()> {
381    execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
382    disable_raw_mode()
383}
384
385fn should_start_ui() -> bool {
386    if !stdout().is_tty() || !stdin().is_tty() {
387        return false;
388    }
389
390    #[cfg(unix)]
391    {
392        use std::os::unix::io::AsRawFd;
393
394        let stdin_fd = stdin().as_raw_fd();
395        let fg_pgrp = unsafe { libc::tcgetpgrp(stdin_fd) };
396        if fg_pgrp == -1 {
397            return false;
398        }
399
400        let pgrp = unsafe { libc::getpgrp() };
401        if fg_pgrp != pgrp {
402            return false;
403        }
404    }
405
406    true
407}