• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jzombie / term-wm / 20796173160

07 Jan 2026 08:53PM UTC coverage: 38.847% (+2.9%) from 35.923%
20796173160

Pull #5

github

web-flow
Merge 6220a88ab into bbd85351a
Pull Request #5: Add splash screen, debug logging, and unified scroll support

603 of 1185 new or added lines in 16 files covered. (50.89%)

8 existing lines in 6 files now uncovered.

3018 of 7769 relevant lines covered (38.85%)

2.96 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

60.53
/src/components/debug_log.rs
1
use std::collections::VecDeque;
2
use std::io::{self, Write};
3
use std::sync::{Arc, Mutex, OnceLock};
4

5
use crossterm::event::Event;
6
use ratatui::layout::Rect;
7
use ratatui::text::{Line, Text};
8

9
use crate::components::{Component, TextRendererComponent};
10
use crate::ui::UiFrame;
11

12
const DEFAULT_MAX_LINES: usize = 2000;
13
static GLOBAL_LOG: OnceLock<DebugLogHandle> = OnceLock::new();
14
static PANIC_HOOK_INSTALLED: OnceLock<()> = OnceLock::new();
15
use std::sync::atomic::{AtomicBool, Ordering};
16
static PANIC_PENDING: AtomicBool = AtomicBool::new(false);
17

18
pub fn set_global_debug_log(handle: DebugLogHandle) -> bool {
×
19
    GLOBAL_LOG.set(handle).is_ok()
×
20
}
×
21

22
pub fn global_debug_log() -> Option<DebugLogHandle> {
×
23
    GLOBAL_LOG.get().cloned()
×
24
}
×
25

26
pub fn log_line(line: impl Into<String>) {
×
27
    if let Some(handle) = GLOBAL_LOG.get() {
×
28
        handle.push(line);
×
29
    }
×
30
}
×
31

32
pub fn install_panic_hook() {
×
33
    if PANIC_HOOK_INSTALLED.get().is_some() {
×
34
        return;
×
35
    }
×
36
    let _ = PANIC_HOOK_INSTALLED.set(());
×
37
    let prev = std::panic::take_hook();
×
38
    std::panic::set_hook(Box::new(move |info| {
×
39
        if let Some(handle) = GLOBAL_LOG.get() {
×
40
            handle.push("".to_string());
×
41
            handle.push("=== PANIC ===".to_string());
×
42
            if let Some(location) = info.location() {
×
43
                handle.push(format!(
×
44
                    "{}:{}:{}",
×
45
                    location.file(),
×
46
                    location.line(),
×
47
                    location.column()
×
48
                ));
×
49
            }
×
50
            if let Some(msg) = info.payload().downcast_ref::<&str>() {
×
51
                handle.push(format!("message: {msg}"));
×
52
            } else if let Some(msg) = info.payload().downcast_ref::<String>() {
×
53
                handle.push(format!("message: {msg}"));
×
54
            } else {
×
55
                handle.push("message: <non-string panic>".to_string());
×
56
            }
×
57
            let backtrace = std::backtrace::Backtrace::force_capture();
×
58
            for line in backtrace.to_string().lines() {
×
59
                handle.push(line.to_string());
×
60
            }
×
61
            handle.push("============".to_string());
×
62
        }
×
63
        // Mark that a panic occurred so the UI can react in the next frame.
64
        PANIC_PENDING.store(true, Ordering::SeqCst);
×
65
        prev(info);
×
66
    }));
×
67
}
×
68

69
pub fn take_panic_pending() -> bool {
×
70
    PANIC_PENDING.swap(false, Ordering::SeqCst)
×
71
}
×
72

73
#[derive(Debug)]
74
struct DebugLogBuffer {
75
    lines: VecDeque<String>,
76
    max_lines: usize,
77
}
78

79
impl DebugLogBuffer {
80
    fn new(max_lines: usize) -> Self {
3✔
81
        Self {
3✔
82
            lines: VecDeque::new(),
3✔
83
            max_lines: max_lines.max(1),
3✔
84
        }
3✔
85
    }
3✔
86

87
    fn push_line(&mut self, line: String) {
27✔
88
        self.lines.push_back(line);
27✔
89
        while self.lines.len() > self.max_lines {
38✔
90
            self.lines.pop_front();
11✔
91
        }
11✔
92
    }
27✔
93
}
94

95
#[derive(Clone, Debug)]
96
pub struct DebugLogHandle {
97
    inner: Arc<Mutex<DebugLogBuffer>>,
98
}
99

100
impl DebugLogHandle {
101
    pub fn push(&self, line: impl Into<String>) {
27✔
102
        if let Ok(mut buffer) = self.inner.lock() {
27✔
103
            buffer.push_line(line.into());
27✔
104
        }
27✔
105
    }
27✔
106

107
    pub fn writer(&self) -> DebugLogWriter {
1✔
108
        DebugLogWriter::new(self.clone())
1✔
109
    }
1✔
110
}
111

112
#[derive(Debug)]
113
pub struct DebugLogWriter {
114
    handle: DebugLogHandle,
115
    pending: Vec<u8>,
116
}
117

118
impl DebugLogWriter {
119
    pub fn new(handle: DebugLogHandle) -> Self {
1✔
120
        Self {
1✔
121
            handle,
1✔
122
            pending: Vec::new(),
1✔
123
        }
1✔
124
    }
1✔
125

126
    fn flush_pending(&mut self, force: bool) {
2✔
127
        if self.pending.is_empty() {
2✔
128
            return;
×
129
        }
2✔
130
        if force {
2✔
131
            let text = String::from_utf8_lossy(&self.pending).to_string();
1✔
132
            self.pending.clear();
1✔
133
            for line in text.split('\n') {
1✔
134
                if !line.is_empty() || force {
1✔
135
                    self.handle.push(line.to_string());
1✔
136
                }
1✔
137
            }
138
            return;
1✔
139
        }
1✔
140
        let Some(pos) = self.pending.iter().rposition(|b| *b == b'\n') else {
8✔
141
            return;
×
142
        };
143
        let drained: Vec<u8> = self.pending.drain(..=pos).collect();
1✔
144
        let text = String::from_utf8_lossy(&drained).to_string();
1✔
145
        for line in text.split('\n') {
3✔
146
            if !line.is_empty() {
3✔
147
                self.handle.push(line.to_string());
2✔
148
            }
2✔
149
        }
150
    }
2✔
151
}
152

153
impl Write for DebugLogWriter {
154
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1✔
155
        self.pending.extend_from_slice(buf);
1✔
156
        self.flush_pending(false);
1✔
157
        Ok(buf.len())
1✔
158
    }
1✔
159

160
    fn flush(&mut self) -> io::Result<()> {
1✔
161
        self.flush_pending(true);
1✔
162
        Ok(())
1✔
163
    }
1✔
164
}
165

166
#[derive(Debug)]
167
pub struct DebugLogComponent {
168
    handle: DebugLogHandle,
169
    renderer: TextRendererComponent,
170
    follow_tail: bool,
171
    last_total: usize,
172
    last_view: usize,
173
}
174

175
impl Component for DebugLogComponent {
176
    fn render(&mut self, frame: &mut UiFrame<'_>, area: Rect, focused: bool) {
×
177
        if area.width == 0 || area.height == 0 {
×
178
            return;
×
179
        }
×
180
        let buffer = frame.buffer_mut();
×
181
        let bounds = area.intersection(buffer.area);
×
182
        if bounds.width == 0 || bounds.height == 0 {
×
183
            return;
×
184
        }
×
185
        for y in bounds.y..bounds.y.saturating_add(bounds.height) {
×
186
            for x in bounds.x..bounds.x.saturating_add(bounds.width) {
×
187
                if let Some(cell) = buffer.cell_mut((x, y)) {
×
188
                    cell.reset();
×
189
                    cell.set_symbol(" ");
×
190
                }
×
191
            }
192
        }
193
        // Build text from the handle buffer
194
        let lines = if let Ok(buffer) = self.handle.inner.lock() {
×
195
            buffer.lines.iter().cloned().collect::<Vec<_>>()
×
196
        } else {
197
            Vec::new()
×
198
        };
NEW
199
        let total = lines.len();
×
NEW
200
        let view = area.height as usize;
×
NEW
201
        self.last_total = total;
×
NEW
202
        self.last_view = view;
×
203

204
        // Prepare Text for the renderer
205
        let text = Text::from(lines.into_iter().map(Line::from).collect::<Vec<_>>());
×
NEW
206
        self.renderer.set_text(text);
×
NEW
207
        self.renderer.set_wrap(false);
×
208
        // Follow tail behavior: set renderer offset to bottom when enabled
NEW
209
        if self.follow_tail {
×
NEW
210
            // renderer will position itself to the bottom during render via its internal scroll
×
NEW
211
            let max_off = total.saturating_sub(view);
×
NEW
212
            self.renderer.set_offset(max_off);
×
213
        }
×
NEW
214
        self.follow_tail = self.is_at_bottom();
×
215

NEW
216
        self.renderer.render(frame, area, focused);
×
UNCOV
217
    }
×
218

219
    fn handle_event(&mut self, event: &Event) -> bool {
2✔
220
        // Delegate to the renderer which handles scroll/key/mouse
221
        let handled = self.renderer.handle_event(event);
2✔
222
        if handled {
2✔
223
            self.follow_tail = self.is_at_bottom();
2✔
224
        }
2✔
225
        handled
2✔
226
    }
2✔
227
}
228

229
impl DebugLogComponent {
230
    pub fn new(max_lines: usize) -> (Self, DebugLogHandle) {
3✔
231
        let handle = DebugLogHandle {
3✔
232
            inner: Arc::new(Mutex::new(DebugLogBuffer::new(max_lines))),
3✔
233
        };
3✔
234
        let mut renderer = TextRendererComponent::new();
3✔
235
        renderer.set_wrap(false);
3✔
236
        renderer.set_keyboard_enabled(true);
3✔
237
        (
3✔
238
            Self {
3✔
239
                handle: handle.clone(),
3✔
240
                renderer,
3✔
241
                follow_tail: true,
3✔
242
                last_total: 0,
3✔
243
                last_view: 0,
3✔
244
            },
3✔
245
            handle,
3✔
246
        )
3✔
247
    }
3✔
248

NEW
249
    pub fn new_default() -> (Self, DebugLogHandle) {
×
NEW
250
        Self::new(DEFAULT_MAX_LINES)
×
NEW
251
    }
×
252

253
    fn is_at_bottom(&self) -> bool {
2✔
254
        if self.last_view == 0 {
2✔
NEW
255
            true
×
256
        } else {
257
            self.renderer_offset() >= self.last_total.saturating_sub(self.last_view)
2✔
258
        }
259
    }
2✔
260

261
    fn renderer_offset(&self) -> usize {
2✔
262
        self.renderer.offset()
2✔
263
    }
2✔
264
}
265

266
#[cfg(test)]
267
mod tests {
268
    use super::*;
269
    use crossterm::event::{Event, KeyCode, MouseEvent, MouseEventKind};
270
    use ratatui::prelude::Rect;
271
    use std::io::Write;
272

273
    #[test]
274
    fn debug_log_handle_and_buffer_limits() {
1✔
275
        let (_comp, handle) = DebugLogComponent::new(3);
1✔
276
        handle.push("one");
1✔
277
        handle.push("two");
1✔
278
        handle.push("three");
1✔
279
        handle.push("four");
1✔
280
        // internal buffer should be capped at 3
281
        if let Ok(buf) = handle.inner.lock() {
1✔
282
            assert_eq!(buf.lines.len(), 3);
1✔
283
            assert_eq!(buf.lines.front().unwrap().as_str(), "two");
1✔
284
        } else {
285
            panic!("lock failed");
×
286
        }
287
    }
1✔
288

289
    #[test]
290
    fn debug_log_writer_flushes_lines() {
1✔
291
        let (_comp, handle) = DebugLogComponent::new(10);
1✔
292
        let mut writer = handle.writer();
1✔
293
        let _ = writer.write(b"first line\nsecond line\npartial");
1✔
294
        // flush should push pending partial when forced
295
        writer.flush().unwrap();
1✔
296
        if let Ok(buf) = handle.inner.lock() {
1✔
297
            assert!(buf.lines.iter().any(|s| s == "first line"));
1✔
298
            assert!(buf.lines.iter().any(|s| s == "second line"));
2✔
299
            assert!(buf.lines.iter().any(|s| s == "partial"));
3✔
300
        }
×
301
    }
1✔
302

303
    #[test]
304
    fn debug_log_component_handle_event_scrolls() {
1✔
305
        let (mut comp, handle) = DebugLogComponent::new(10);
1✔
306
        for i in 0..20 {
21✔
307
            handle.push(format!("line{i}"));
20✔
308
        }
20✔
309
        let area = Rect {
1✔
310
            x: 0,
1✔
311
            y: 0,
1✔
312
            width: 10,
1✔
313
            height: 5,
1✔
314
        };
1✔
315
        comp.last_total = 20;
1✔
316
        comp.last_view = 5;
1✔
317
        comp.renderer.update(area, comp.last_total, comp.last_view);
1✔
318
        let max_off = comp.last_total.saturating_sub(comp.last_view);
1✔
319
        comp.renderer.set_offset(max_off);
1✔
320
        comp.follow_tail = true;
1✔
321

322
        comp.handle_event(&Event::Key(crossterm::event::KeyEvent::new(
1✔
323
            KeyCode::PageUp,
1✔
324
            crossterm::event::KeyModifiers::NONE,
1✔
325
        )));
1✔
326
        assert!(comp.renderer.offset() < max_off);
1✔
327

328
        let before = comp.renderer.offset();
1✔
329
        comp.handle_event(&Event::Mouse(MouseEvent {
1✔
330
            kind: MouseEventKind::ScrollDown,
1✔
331
            column: 0,
1✔
332
            row: 0,
1✔
333
            modifiers: crossterm::event::KeyModifiers::NONE,
1✔
334
        }));
1✔
335
        assert!(comp.renderer.offset() >= before);
1✔
336
    }
1✔
337
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc