• 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

69.14
/src/components/scroll_view.rs
1
use crossterm::event::{Event, MouseEvent, MouseEventKind};
2
use ratatui::prelude::Rect;
3
use ratatui::widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState};
4

5
use crate::ui::UiFrame;
6
use crate::window::ScrollState;
7

8
#[derive(Debug, Default, Clone)]
9
pub struct ScrollbarDrag {
10
    dragging: bool,
11
}
12

13
pub struct ScrollbarDragResponse {
14
    pub handled: bool,
15
    pub offset: Option<usize>,
16
}
17

18
impl ScrollbarDrag {
19
    pub fn new() -> Self {
8✔
20
        Self { dragging: false }
8✔
21
    }
8✔
22

23
    pub fn handle_mouse(
3✔
24
        &mut self,
3✔
25
        mouse: &MouseEvent,
3✔
26
        area: Rect,
3✔
27
        total: usize,
3✔
28
        view: usize,
3✔
29
    ) -> ScrollbarDragResponse {
3✔
30
        if total <= view || view == 0 || area.height == 0 || area.width == 0 {
3✔
31
            self.dragging = false;
×
32
            return ScrollbarDragResponse {
×
33
                handled: false,
×
34
                offset: None,
×
35
            };
×
36
        }
3✔
37
        let scrollbar_x = area.x.saturating_add(area.width.saturating_sub(1));
3✔
38
        let on_scrollbar =
3✔
39
            rect_contains(area, mouse.column, mouse.row) && mouse.column == scrollbar_x;
3✔
40
        match mouse.kind {
1✔
41
            MouseEventKind::Down(_) if on_scrollbar => {
1✔
42
                self.dragging = true;
1✔
43
                ScrollbarDragResponse {
1✔
44
                    handled: true,
1✔
45
                    offset: Some(scrollbar_offset_from_row(mouse.row, area, total, view)),
1✔
46
                }
1✔
47
            }
48
            MouseEventKind::Drag(_) if self.dragging => ScrollbarDragResponse {
1✔
49
                handled: true,
1✔
50
                offset: Some(scrollbar_offset_from_row(mouse.row, area, total, view)),
1✔
51
            },
1✔
52
            MouseEventKind::Up(_) if self.dragging => {
1✔
53
                self.dragging = false;
1✔
54
                ScrollbarDragResponse {
1✔
55
                    handled: true,
1✔
56
                    offset: None,
1✔
57
                }
1✔
58
            }
59
            _ => ScrollbarDragResponse {
×
60
                handled: false,
×
61
                offset: None,
×
62
            },
×
63
        }
64
    }
3✔
65
}
66

67
pub struct ScrollEvent {
68
    pub handled: bool,
69
    pub offset: Option<usize>,
70
}
71

72
#[derive(Debug)]
73
pub struct ScrollView {
74
    state: ScrollState,
75
    drag: ScrollbarDrag,
76
    area: Rect,
77
    total: usize,
78
    view: usize,
79
    fixed_height: Option<u16>,
80
}
81

82
impl ScrollView {
83
    pub fn new() -> Self {
7✔
84
        Self {
7✔
85
            state: ScrollState::default(),
7✔
86
            drag: ScrollbarDrag::new(),
7✔
87
            area: Rect::default(),
7✔
88
            total: 0,
7✔
89
            view: 0,
7✔
90
            fixed_height: None,
7✔
91
        }
7✔
92
    }
7✔
93

94
    pub fn set_fixed_height(&mut self, height: Option<u16>) {
×
95
        self.fixed_height = height;
×
96
    }
×
97

98
    pub fn update(&mut self, area: Rect, total: usize, view: usize) {
1✔
99
        let mut area = area;
1✔
100
        if let Some(height) = self.fixed_height {
1✔
101
            area.height = area.height.min(height);
×
102
        }
1✔
103
        self.area = area;
1✔
104
        self.total = total;
1✔
105
        self.view = view.min(area.height as usize);
1✔
106
        self.state.apply(total, view);
1✔
107
    }
1✔
108

109
    pub fn set_total_view(&mut self, total: usize, view: usize) {
×
110
        self.total = total;
×
111
        self.view = view.min(self.area.height as usize);
×
112
        self.state.apply(total, view);
×
113
    }
×
114

115
    pub fn offset(&self) -> usize {
2✔
116
        self.state.offset
2✔
117
    }
2✔
118

119
    pub fn set_offset(&mut self, offset: usize) {
2✔
120
        self.state.offset = offset.min(self.max_offset());
2✔
121
    }
2✔
122

123
    pub fn bump(&mut self, delta: isize) {
×
124
        self.state.bump(delta);
×
125
        self.state.apply(self.total, self.view);
×
126
    }
×
127

128
    pub fn reset(&mut self) {
×
129
        self.state.reset();
×
130
    }
×
131

132
    pub fn view(&self) -> usize {
×
133
        self.view
×
134
    }
×
135

NEW
136
    pub fn render(&self, frame: &mut UiFrame<'_>) {
×
137
        render_scrollbar(frame, self.area, self.total, self.view, self.offset());
×
138
    }
×
139

140
    pub fn handle_event(&mut self, event: &Event) -> ScrollEvent {
×
141
        if self.total == 0 || self.view == 0 {
×
142
            return ScrollEvent {
×
143
                handled: false,
×
144
                offset: None,
×
145
            };
×
146
        }
×
147
        let Event::Mouse(mouse) = event else {
×
148
            return ScrollEvent {
×
149
                handled: false,
×
150
                offset: None,
×
151
            };
×
152
        };
153
        let response = self
×
154
            .drag
×
155
            .handle_mouse(mouse, self.area, self.total, self.view);
×
156
        if let Some(offset) = response.offset {
×
157
            self.set_offset(offset);
×
158
        }
×
159
        ScrollEvent {
×
160
            handled: response.handled,
×
161
            offset: response.offset,
×
162
        }
×
163
    }
×
164

165
    fn max_offset(&self) -> usize {
2✔
166
        self.total.saturating_sub(self.view)
2✔
167
    }
2✔
168
}
169

170
impl Default for ScrollView {
171
    fn default() -> Self {
×
172
        Self::new()
×
173
    }
×
174
}
175

NEW
176
pub fn render_scrollbar(
×
NEW
177
    frame: &mut UiFrame<'_>,
×
NEW
178
    area: Rect,
×
NEW
179
    total: usize,
×
NEW
180
    view: usize,
×
NEW
181
    offset: usize,
×
NEW
182
) {
×
183
    if total <= view || view == 0 || area.height == 0 {
×
184
        return;
×
185
    }
×
186
    let content_len = total.saturating_sub(view).saturating_add(1).max(1);
×
187
    let mut state = ScrollbarState::new(content_len)
×
188
        .position(offset.min(content_len.saturating_sub(1)))
×
189
        .viewport_content_length(view.max(1));
×
190
    let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
×
191
    frame.render_stateful_widget(scrollbar, area, &mut state);
×
192
}
×
193

194
fn scrollbar_offset_from_row(row: u16, area: Rect, total: usize, view: usize) -> usize {
4✔
195
    let content_len = total.saturating_sub(view).saturating_add(1).max(1);
4✔
196
    let max_offset = content_len.saturating_sub(1);
4✔
197
    if max_offset == 0 || area.height <= 1 {
4✔
198
        return 0;
×
199
    }
4✔
200
    let rel = row
4✔
201
        .saturating_sub(area.y)
4✔
202
        .min(area.height.saturating_sub(1));
4✔
203
    let ratio = rel as f64 / (area.height.saturating_sub(1)) as f64;
4✔
204
    (ratio * max_offset as f64).round() as usize
4✔
205
}
4✔
206

207
fn rect_contains(rect: Rect, column: u16, row: u16) -> bool {
6✔
208
    if rect.width == 0 || rect.height == 0 {
6✔
209
        return false;
1✔
210
    }
5✔
211
    let max_x = rect.x.saturating_add(rect.width);
5✔
212
    let max_y = rect.y.saturating_add(rect.height);
5✔
213
    column >= rect.x && column < max_x && row >= rect.y && row < max_y
5✔
214
}
6✔
215

216
#[cfg(test)]
217
mod tests {
218
    use super::*;
219
    use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
220
    use ratatui::prelude::Rect;
221

222
    #[test]
223
    fn scrollbar_offset_from_row_edges() {
1✔
224
        let area = Rect {
1✔
225
            x: 0,
1✔
226
            y: 0,
1✔
227
            width: 5,
1✔
228
            height: 10,
1✔
229
        };
1✔
230
        let total = 100usize;
1✔
231
        let view = 10usize;
1✔
232
        let top = scrollbar_offset_from_row(0, area, total, view);
1✔
233
        let bottom = scrollbar_offset_from_row(area.y + area.height - 1, area, total, view);
1✔
234
        assert_eq!(top, 0);
1✔
235
        let max_offset = total
1✔
236
            .saturating_sub(view)
1✔
237
            .saturating_add(1)
1✔
238
            .saturating_sub(1);
1✔
239
        assert!(bottom <= max_offset);
1✔
240
    }
1✔
241

242
    #[test]
243
    fn drag_handle_mouse_lifecycle() {
1✔
244
        let mut drag = ScrollbarDrag::new();
1✔
245
        let area = Rect {
1✔
246
            x: 0,
1✔
247
            y: 0,
1✔
248
            width: 4,
1✔
249
            height: 6,
1✔
250
        };
1✔
251
        let total = 20usize;
1✔
252
        let view = 5usize;
1✔
253
        let scrollbar_x = area.x.saturating_add(area.width.saturating_sub(1));
1✔
254
        use crossterm::event::KeyModifiers;
255
        let down = MouseEvent {
1✔
256
            kind: MouseEventKind::Down(MouseButton::Left),
1✔
257
            column: scrollbar_x,
1✔
258
            row: area.y + 1,
1✔
259
            modifiers: KeyModifiers::NONE,
1✔
260
        };
1✔
261
        let resp = drag.handle_mouse(&down, area, total, view);
1✔
262
        assert!(resp.handled);
1✔
263
        assert!(resp.offset.is_some());
1✔
264

265
        let drag_evt = MouseEvent {
1✔
266
            kind: MouseEventKind::Drag(MouseButton::Left),
1✔
267
            column: scrollbar_x,
1✔
268
            row: area.y + 2,
1✔
269
            modifiers: KeyModifiers::NONE,
1✔
270
        };
1✔
271
        let resp2 = drag.handle_mouse(&drag_evt, area, total, view);
1✔
272
        assert!(resp2.handled);
1✔
273
        assert!(resp2.offset.is_some());
1✔
274

275
        let up = MouseEvent {
1✔
276
            kind: MouseEventKind::Up(MouseButton::Left),
1✔
277
            column: scrollbar_x,
1✔
278
            row: area.y + 2,
1✔
279
            modifiers: KeyModifiers::NONE,
1✔
280
        };
1✔
281
        let resp3 = drag.handle_mouse(&up, area, total, view);
1✔
282
        assert!(resp3.handled);
1✔
283
        assert!(resp3.offset.is_none());
1✔
284
    }
1✔
285

286
    #[test]
287
    fn scroll_view_set_offset_and_max() {
1✔
288
        let mut sv = ScrollView::new();
1✔
289
        let area = Rect {
1✔
290
            x: 0,
1✔
291
            y: 0,
1✔
292
            width: 3,
1✔
293
            height: 4,
1✔
294
        };
1✔
295
        sv.update(area, 50, 3);
1✔
296
        sv.set_offset(1000);
1✔
297
        assert!(sv.offset() <= sv.total.saturating_sub(sv.view));
1✔
298
        sv.set_offset(0);
1✔
299
        assert_eq!(sv.offset(), 0);
1✔
300
    }
1✔
301

302
    #[test]
303
    fn rect_contains_edge_cases() {
1✔
304
        let r = Rect {
1✔
305
            x: 0,
1✔
306
            y: 0,
1✔
307
            width: 0,
1✔
308
            height: 3,
1✔
309
        };
1✔
310
        assert!(!rect_contains(r, 0, 0));
1✔
311
        let r2 = Rect {
1✔
312
            x: 1,
1✔
313
            y: 1,
1✔
314
            width: 2,
1✔
315
            height: 2,
1✔
316
        };
1✔
317
        assert!(rect_contains(r2, 1, 1));
1✔
318
        assert!(!rect_contains(r2, 3, 1));
1✔
319
    }
1✔
320
}
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