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

jzombie / term-wm / 20828291393

08 Jan 2026 06:58PM UTC coverage: 39.989% (+0.9%) from 39.086%
20828291393

Pull #9

github

web-flow
Merge bd526f376 into dccaa1888
Pull Request #9: Centralize key bindings

209 of 364 new or added lines in 10 files covered. (57.42%)

13 existing lines in 6 files now uncovered.

3601 of 9005 relevant lines covered (39.99%)

4.45 hits per line

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

48.97
/src/components/toggle_list.rs
1
use crossterm::event::Event;
2
use ratatui::layout::Rect;
3
use ratatui::style::{Modifier, Style};
4
use ratatui::widgets::{Block, Borders, List, ListItem, ListState};
5

6
use crate::components::{Component, scroll_view::ScrollViewComponent};
7
use crate::ui::UiFrame;
8

9
#[derive(Clone)]
10
pub struct ToggleItem {
11
    pub id: String,
12
    pub label: String,
13
    pub checked: bool,
14
}
15

16
pub struct ToggleListComponent {
17
    items: Vec<ToggleItem>,
18
    selected: usize,
19
    title: String,
20
    scroll_view: ScrollViewComponent,
21
}
22

23
impl Component for ToggleListComponent {
24
    fn render(&mut self, frame: &mut UiFrame<'_>, area: Rect, focused: bool) {
×
25
        let block = if focused {
×
26
            Block::default()
×
27
                .borders(Borders::ALL)
×
28
                .title(format!("{} (focus)", self.title))
×
29
                .border_style(Style::default().fg(crate::theme::success_fg()))
×
30
        } else {
31
            Block::default()
×
32
                .borders(Borders::ALL)
×
33
                .title(self.title.as_str())
×
34
        };
35
        let inner = block.inner(area);
×
36
        frame.render_widget(block, area);
×
37
        if inner.height == 0 || inner.width == 0 {
×
38
            return;
×
39
        }
×
40

41
        let total = self.items.len();
×
42
        let view = inner.height as usize;
×
43
        self.scroll_view.update(inner, total, view);
×
44
        self.keep_selected_in_view(view);
×
45

46
        let offset = self.scroll_view.offset();
×
47
        let items = self
×
48
            .items
×
49
            .iter()
×
50
            .skip(offset)
×
51
            .take(view)
×
52
            .map(|item| {
×
53
                let marker = if item.checked { "[x]" } else { "[ ]" };
×
54
                ListItem::new(format!("{marker} {}", item.label))
×
55
            })
×
56
            .collect::<Vec<_>>();
×
57

58
        let mut state = ListState::default();
×
59
        if total > 0 && self.selected >= offset {
×
60
            state.select(Some(self.selected - offset));
×
61
        }
×
62

63
        let list =
×
64
            List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED));
×
65
        frame.render_stateful_widget(list, inner, &mut state);
×
66
        self.scroll_view.render(frame);
×
67
    }
×
68

69
    fn handle_event(&mut self, event: &Event) -> bool {
3✔
70
        match event {
3✔
71
            Event::Key(key) => {
3✔
72
                let kb = crate::keybindings::KeyBindings::default();
3✔
73
                if kb.matches(crate::keybindings::Action::MenuUp, key)
3✔
74
                    || kb.matches(crate::keybindings::Action::MenuPrev, key)
3✔
75
                {
76
                    self.bump_selection(-1);
×
77
                    true
×
78
                } else if kb.matches(crate::keybindings::Action::MenuDown, key)
3✔
79
                    || kb.matches(crate::keybindings::Action::MenuNext, key)
2✔
80
                {
81
                    self.bump_selection(1);
1✔
82
                    true
1✔
83
                } else if kb.matches(crate::keybindings::Action::ScrollPageUp, key) {
2✔
UNCOV
84
                    self.bump_selection(-5);
×
85
                    true
×
86
                } else if kb.matches(crate::keybindings::Action::ScrollPageDown, key) {
2✔
UNCOV
87
                    self.bump_selection(5);
×
88
                    true
×
89
                } else if kb.matches(crate::keybindings::Action::ScrollHome, key) {
2✔
90
                    self.selected = 0;
1✔
91
                    true
1✔
92
                } else if kb.matches(crate::keybindings::Action::ScrollEnd, key) {
1✔
93
                    if !self.items.is_empty() {
1✔
94
                        self.selected = self.items.len() - 1;
1✔
95
                    }
1✔
96
                    true
1✔
NEW
97
                } else if kb.matches(crate::keybindings::Action::ToggleSelection, key) {
×
NEW
98
                    self.toggle_selected()
×
99
                } else {
NEW
100
                    false
×
101
                }
102
            }
103
            Event::Mouse(_) => self.handle_scrollbar_event(event),
×
104
            _ => false,
×
105
        }
106
    }
3✔
107
}
108

109
impl ToggleListComponent {
110
    pub fn new<T: Into<String>>(title: T) -> Self {
2✔
111
        Self {
2✔
112
            items: Vec::new(),
2✔
113
            selected: 0,
2✔
114
            title: title.into(),
2✔
115
            scroll_view: ScrollViewComponent::new(),
2✔
116
        }
2✔
117
    }
2✔
118

119
    pub fn set_items(&mut self, items: Vec<ToggleItem>) {
2✔
120
        self.items = items;
2✔
121
        if self.selected >= self.items.len() {
2✔
122
            self.selected = self.items.len().saturating_sub(1);
×
123
        }
2✔
124
    }
2✔
125

126
    pub fn items(&self) -> &[ToggleItem] {
1✔
127
        &self.items
1✔
128
    }
1✔
129

130
    pub fn items_mut(&mut self) -> &mut [ToggleItem] {
×
131
        &mut self.items
×
132
    }
×
133

134
    pub fn selected(&self) -> usize {
7✔
135
        self.selected
7✔
136
    }
7✔
137

138
    pub fn set_selected(&mut self, selected: usize) {
×
139
        self.selected = selected.min(self.items.len().saturating_sub(1));
×
140
    }
×
141

142
    pub fn scroll_offset(&self) -> usize {
×
143
        self.scroll_view.offset()
×
144
    }
×
145

146
    pub fn move_selection(&mut self, delta: isize) {
3✔
147
        self.bump_selection(delta);
3✔
148
    }
3✔
149

150
    fn bump_selection(&mut self, delta: isize) {
4✔
151
        if self.items.is_empty() {
4✔
152
            self.selected = 0;
×
153
            return;
×
154
        }
4✔
155
        if delta.is_negative() {
4✔
156
            self.selected = self.selected.saturating_sub(delta.unsigned_abs());
1✔
157
        } else {
3✔
158
            self.selected = (self.selected + delta as usize).min(self.items.len() - 1);
3✔
159
        }
3✔
160
    }
4✔
161

162
    pub fn toggle_selected(&mut self) -> bool {
1✔
163
        if let Some(item) = self.items.get_mut(self.selected) {
1✔
164
            item.checked = !item.checked;
1✔
165
            return true;
1✔
166
        }
×
167
        false
×
168
    }
1✔
169

170
    fn keep_selected_in_view(&mut self, view: usize) {
×
171
        if view == 0 {
×
172
            self.scroll_view.set_offset(0);
×
173
            return;
×
174
        }
×
175
        if self.items.is_empty() {
×
176
            self.scroll_view.set_offset(0);
×
177
            return;
×
178
        }
×
179
        let mut offset = self.scroll_view.offset();
×
180
        if self.selected < offset {
×
181
            offset = self.selected;
×
182
        } else if self.selected >= offset + view {
×
183
            offset = self.selected + 1 - view;
×
184
        }
×
185
        self.scroll_view.set_offset(offset);
×
186
    }
×
187

188
    fn handle_scrollbar_event(&mut self, event: &Event) -> bool {
×
189
        let response = self.scroll_view.handle_event(event);
×
190
        if let Some(offset) = response.v_offset {
×
191
            self.scroll_view.set_offset(offset);
×
192
        }
×
193
        if response.handled {
×
194
            self.scroll_view
×
195
                .set_total_view(self.items.len(), self.scroll_view.view());
×
196
            let view = self.scroll_view.view();
×
197
            if view > 0 {
×
198
                if self.selected < self.scroll_view.offset() {
×
199
                    self.selected = self.scroll_view.offset();
×
200
                } else if self.selected >= self.scroll_view.offset() + view {
×
201
                    self.selected = self.scroll_view.offset() + view - 1;
×
202
                }
×
203
            }
×
204
        }
×
205
        response.handled
×
206
    }
×
207
}
208

209
#[cfg(test)]
210
mod tests {
211
    use super::*;
212
    use crate::components::Component;
213
    use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
214

215
    fn make_items(n: usize) -> Vec<ToggleItem> {
2✔
216
        (0..n)
2✔
217
            .map(|i| ToggleItem {
2✔
218
                id: format!("id{}", i),
8✔
219
                label: format!("label{}", i),
8✔
220
                checked: i % 2 == 0,
8✔
221
            })
8✔
222
            .collect()
2✔
223
    }
2✔
224

225
    #[test]
226
    fn bump_selection_bounds_and_toggle() {
1✔
227
        let mut t = ToggleListComponent::new("test");
1✔
228
        t.set_items(make_items(3));
1✔
229
        assert_eq!(t.selected(), 0);
1✔
230
        t.move_selection(1);
1✔
231
        assert_eq!(t.selected(), 1);
1✔
232
        t.move_selection(10);
1✔
233
        assert_eq!(t.selected(), 2);
1✔
234
        t.move_selection(-100);
1✔
235
        assert_eq!(t.selected(), 0);
1✔
236

237
        // toggle the first item
238
        assert!(t.toggle_selected());
1✔
239
        assert!(!t.items()[0].checked);
1✔
240
    }
1✔
241

242
    #[test]
243
    fn handle_event_navigation() {
1✔
244
        let mut t = ToggleListComponent::new("s");
1✔
245
        t.set_items(make_items(5));
1✔
246
        t.handle_event(&Event::Key(KeyEvent::new(
1✔
247
            KeyCode::Down,
1✔
248
            KeyModifiers::NONE,
1✔
249
        )));
1✔
250
        assert_eq!(t.selected(), 1);
1✔
251
        t.handle_event(&Event::Key(KeyEvent::new(
1✔
252
            KeyCode::Home,
1✔
253
            KeyModifiers::NONE,
1✔
254
        )));
1✔
255
        assert_eq!(t.selected(), 0);
1✔
256
        t.handle_event(&Event::Key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)));
1✔
257
        assert_eq!(t.selected(), 4);
1✔
258
    }
1✔
259
}
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