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