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

jzombie / term-wm / 20788957288

07 Jan 2026 04:43PM UTC coverage: 35.923% (+2.3%) from 33.582%
20788957288

push

github

web-flow
General UI improvements (#3)

* Rename to `DefaultDecorator`

* Add theme.rs

* Rename to term_color.rs

* Use static display order

* Add pill-like labels

* Migrate more state to state.rs

* Add `HeaderAction` struct

* Use same window title in title bar and window list

* Simplify window creation

* Prototype centralized output driver

* Prototype crash reporting

* Fix issue where dual image example would crash when moving windows

* Remove inner decorative frame in dual image example

* Preliminary support for window titles

* Extract window manager

* Fix issue where debug window would not auto-snap when other window snaps

* Fix issue where vertical resizing of tiles would become unresponsive

* Simplify window focusing

* Window header double-click toggles maximize/restore state

* Simplify io drivers

496 of 2597 new or added lines in 28 files covered. (19.1%)

19 existing lines in 8 files now uncovered.

2515 of 7001 relevant lines covered (35.92%)

2.06 hits per line

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

52.8
/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, KeyCode, MouseEventKind};
6
use ratatui::layout::Rect;
7
use ratatui::style::Style;
8
use ratatui::text::{Line, Text};
9
use ratatui::widgets::Paragraph;
10

11
use crate::components::Component;
12
use crate::ui::UiFrame;
13

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

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

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

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

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

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

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

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

89
    fn push_line(&mut self, line: String) {
7✔
90
        self.lines.push_back(line);
7✔
91
        while self.lines.len() > self.max_lines {
8✔
92
            self.lines.pop_front();
1✔
93
        }
1✔
94
    }
7✔
95
}
96

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

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

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

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

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

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

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

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

168
#[derive(Debug)]
169
pub struct DebugLogComponent {
170
    handle: DebugLogHandle,
171
    scroll_from_bottom: usize,
172
}
173

174
impl DebugLogComponent {
175
    pub fn new(max_lines: usize) -> (Self, DebugLogHandle) {
3✔
176
        let handle = DebugLogHandle {
3✔
177
            inner: Arc::new(Mutex::new(DebugLogBuffer::new(max_lines))),
3✔
178
        };
3✔
179
        (
3✔
180
            Self {
3✔
181
                handle: handle.clone(),
3✔
182
                scroll_from_bottom: 0,
3✔
183
            },
3✔
184
            handle,
3✔
185
        )
3✔
186
    }
3✔
187

188
    pub fn new_default() -> (Self, DebugLogHandle) {
×
189
        Self::new(DEFAULT_MAX_LINES)
×
190
    }
×
191

192
    fn clamp_scroll(&mut self, line_count: usize, area: Rect) -> usize {
×
193
        let max_scroll = line_count.saturating_sub(area.height as usize);
×
194
        if self.scroll_from_bottom > max_scroll {
×
195
            self.scroll_from_bottom = max_scroll;
×
196
        }
×
197
        max_scroll
×
198
    }
×
199
}
200

201
impl Component for DebugLogComponent {
NEW
202
    fn render(&mut self, frame: &mut UiFrame<'_>, area: Rect, focused: bool) {
×
203
        if area.width == 0 || area.height == 0 {
×
204
            return;
×
205
        }
×
206
        let buffer = frame.buffer_mut();
×
207
        let bounds = area.intersection(buffer.area);
×
208
        if bounds.width == 0 || bounds.height == 0 {
×
209
            return;
×
210
        }
×
211
        for y in bounds.y..bounds.y.saturating_add(bounds.height) {
×
212
            for x in bounds.x..bounds.x.saturating_add(bounds.width) {
×
213
                if let Some(cell) = buffer.cell_mut((x, y)) {
×
214
                    cell.reset();
×
215
                    cell.set_symbol(" ");
×
216
                }
×
217
            }
218
        }
219
        let lines = if let Ok(buffer) = self.handle.inner.lock() {
×
220
            buffer.lines.iter().cloned().collect::<Vec<_>>()
×
221
        } else {
222
            Vec::new()
×
223
        };
224
        let max_scroll = self.clamp_scroll(lines.len(), area);
×
225
        let scroll_top = max_scroll.saturating_sub(self.scroll_from_bottom);
×
226
        let text = Text::from(lines.into_iter().map(Line::from).collect::<Vec<_>>());
×
227
        let mut paragraph = Paragraph::new(text).scroll((scroll_top as u16, 0));
×
228
        if focused {
×
NEW
229
            paragraph = paragraph.style(Style::default().fg(crate::theme::debug_highlight()));
×
230
        }
×
231
        frame.render_widget(paragraph, area);
×
232
    }
×
233

234
    fn handle_event(&mut self, event: &Event) -> bool {
2✔
235
        match event {
2✔
236
            Event::Key(key) => match key.code {
1✔
237
                KeyCode::PageUp => {
238
                    self.scroll_from_bottom = self.scroll_from_bottom.saturating_add(5);
1✔
239
                    true
1✔
240
                }
241
                KeyCode::PageDown => {
242
                    self.scroll_from_bottom = self.scroll_from_bottom.saturating_sub(5);
×
243
                    true
×
244
                }
245
                KeyCode::Home => {
246
                    self.scroll_from_bottom = usize::MAX;
×
247
                    true
×
248
                }
249
                KeyCode::End => {
250
                    self.scroll_from_bottom = 0;
×
251
                    true
×
252
                }
253
                _ => false,
×
254
            },
255
            Event::Mouse(mouse) => match mouse.kind {
1✔
256
                MouseEventKind::ScrollUp => {
257
                    self.scroll_from_bottom = self.scroll_from_bottom.saturating_add(2);
×
258
                    true
×
259
                }
260
                MouseEventKind::ScrollDown => {
261
                    self.scroll_from_bottom = self.scroll_from_bottom.saturating_sub(2);
1✔
262
                    true
1✔
263
                }
264
                _ => false,
×
265
            },
266
            _ => false,
×
267
        }
268
    }
2✔
269
}
270

271
#[cfg(test)]
272
mod tests {
273
    use super::*;
274
    use crossterm::event::{Event, KeyCode, MouseEvent, MouseEventKind};
275
    use std::io::Write;
276

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

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

307
    #[test]
308
    fn debug_log_component_handle_event_scrolls() {
1✔
309
        let (mut comp, _handle) = DebugLogComponent::new(10);
1✔
310
        assert_eq!(comp.scroll_from_bottom, 0);
1✔
311
        comp.handle_event(&Event::Key(crossterm::event::KeyEvent::new(
1✔
312
            KeyCode::PageUp,
1✔
313
            crossterm::event::KeyModifiers::NONE,
1✔
314
        )));
1✔
315
        assert!(comp.scroll_from_bottom >= 5);
1✔
316
        let before = comp.scroll_from_bottom;
1✔
317
        comp.handle_event(&Event::Mouse(MouseEvent {
1✔
318
            kind: MouseEventKind::ScrollDown,
1✔
319
            column: 0,
1✔
320
            row: 0,
1✔
321
            modifiers: crossterm::event::KeyModifiers::NONE,
1✔
322
        }));
1✔
323
        // scroll_from_bottom should have decreased or stayed at zero
324
        assert!(comp.scroll_from_bottom <= before);
1✔
325
    }
1✔
326
}
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