Coverage Report

Created: 2026-04-05 07:19

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
crates/flotilla-tui/src/widgets/tabs.rs
Line
Count
Source
1
use std::collections::BTreeMap;
2
3
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
4
use flotilla_protocol::RepoIdentity;
5
use ratatui::{layout::Rect, style::Style, Frame};
6
7
use crate::{
8
    app::{ui_state::DragState, TabId, TuiModel, UiState},
9
    segment_bar::{self, BarStyle, ThemedRibbonStyle, ThemedTabBarStyle},
10
    theme::{BarKind, Theme},
11
    widgets::AppAction,
12
};
13
14
/// Action returned from a tab bar click. The caller interprets the action
15
/// and mutates `App` state accordingly.
16
#[derive(Debug, Clone, PartialEq, Eq)]
17
pub enum TabBarAction {
18
    /// Switch to the flotilla config screen.
19
    SwitchToConfig,
20
    /// Switch to a repo tab and start a potential drag.
21
    SwitchToRepo(usize),
22
    /// Open the file picker to add a new repo.
23
    OpenFilePicker,
24
    /// No recognized tab was hit. The caller should continue with
25
    /// normal mouse handling.
26
    None,
27
}
28
29
/// Tab bar strip component. Handles rendering, hit-testing, drag-reorder,
30
/// and tab navigation. Owned by `Screen`.
31
#[derive(Default)]
32
pub struct Tabs {
33
    /// Click target areas populated during render.
34
    tab_areas: BTreeMap<TabId, Rect>,
35
    /// Tab drag-reorder state.
36
    pub drag: DragState,
37
    /// Whether a drag is visually active.
38
    drag_active: bool,
39
}
40
41
impl Tabs {
42
655
    pub fn new() -> Self {
43
655
        Self::default()
44
655
    }
45
46
    // ── Rendering ──
47
48
    /// Render the tab bar into `area`, populating click targets for later
49
    /// hit-testing.
50
56
    pub fn render(&mut self, model: &TuiModel, ui: &mut UiState, theme: &Theme, frame: &mut Frame, area: Rect) {
51
56
        self.drag_active = self.drag.active;
52
53
56
        let mut items = Vec::new();
54
56
        let mut tab_ids = Vec::new();
55
56
        // Flotilla logo tab
57
56
        let flotilla_style = theme.logo_style(ui.is_config);
58
56
        items.push(segment_bar::SegmentItem {
59
56
            label: TabId::FLOTILLA_LABEL.to_string(),
60
56
            key_hint: None,
61
56
            active: ui.is_config,
62
56
            dragging: false,
63
56
            style_override: Some(flotilla_style),
64
56
        });
65
56
        tab_ids.push(TabId::Flotilla);
66
67
        // Repo tabs
68
59
        for (i, repo_identity) in 
model.repo_order.iter()56
.
enumerate56
() {
69
59
            let rm = &model.repos[repo_identity];
70
59
            let name = TuiModel::repo_name(&rm.path);
71
59
            let is_active = !ui.is_config && 
i == model.active_repo56
;
72
59
            let loading = if rm.loading { 
" ⟳"0
} else { "" };
73
59
            let changed = if rm.has_unseen_changes { 
"*"0
} else { "" };
74
59
            let label = format!("{name}{changed}{loading}");
75
76
59
            items.push(segment_bar::SegmentItem {
77
59
                label,
78
59
                key_hint: None,
79
59
                active: is_active,
80
59
                dragging: is_active && 
self.drag_active54
,
81
59
                style_override: None,
82
            });
83
59
            tab_ids.push(TabId::Repo(i));
84
        }
85
86
        // [+] button
87
56
        items.push(segment_bar::SegmentItem {
88
56
            label: "[+]".to_string(),
89
56
            key_hint: None,
90
56
            active: false,
91
56
            dragging: false,
92
56
            style_override: Some(Style::default().fg(theme.status_ok)),
93
56
        });
94
56
        tab_ids.push(TabId::Add);
95
96
        // Render
97
56
        let tab_style: Box<dyn BarStyle> = match theme.tab_bar.kind {
98
55
            BarKind::Pipe => Box::new(ThemedTabBarStyle { theme, site: &theme.tab_bar }),
99
1
            BarKind::Chevron => Box::new(ThemedRibbonStyle { theme, site: &theme.tab_bar }),
100
        };
101
56
        let hits = segment_bar::render(&items, tab_style.as_ref(), area, frame.buffer_mut());
102
103
        // Map hit regions to tab areas
104
56
        self.tab_areas.clear();
105
171
        for hit in 
hits56
{
106
171
            if let Some(tab_id) = tab_ids.get(hit.index) {
107
171
                self.tab_areas.insert(tab_id.clone(), hit.area);
108
171
            
}0
109
        }
110
111
        // Write back to shared layout so other components can read tab_areas
112
56
        ui.layout.tab_areas = self.tab_areas.clone();
113
56
    }
114
115
    // ── Click hit-testing ──
116
117
    /// Hit-test a left mouse click against the rendered tab areas.
118
    ///
119
    /// Returns a `TabBarAction` describing what was clicked. The caller
120
    /// is responsible for actually performing the action on `App`.
121
11
    pub fn handle_click(&self, x: u16, y: u16) -> TabBarAction {
122
11
        let hit =
123
11
            self.tab_areas.iter().find(|(_, r)| 
x >= r.x3
&&
x3
< r.x + r.width &&
y >= r.y3
&&
y3
< r.y + r.height).map(|(id, _)|
id3
.
clone3
());
124
125
3
        match hit {
126
1
            Some(TabId::Flotilla) => TabBarAction::SwitchToConfig,
127
1
            Some(TabId::Repo(i)) => TabBarAction::SwitchToRepo(i),
128
1
            Some(TabId::Add) => TabBarAction::OpenFilePicker,
129
8
            _ => TabBarAction::None,
130
        }
131
11
    }
132
133
    // ── Drag handling ──
134
135
    /// Handle a drag event during tab reordering. Returns `true` if a swap
136
    /// occurred and the caller should update model state.
137
0
    pub fn handle_drag(&self, column: u16, row: u16, repo_order: &mut [RepoIdentity], active_repo: &mut usize) -> bool {
138
0
        let Some(dragging_idx) = self.drag.dragging_tab else {
139
0
            return false;
140
        };
141
142
0
        if !self.drag.active {
143
0
            return false;
144
0
        }
145
146
0
        for (id, r) in &self.tab_areas {
147
0
            if let TabId::Repo(i) = *id {
148
0
                if column >= r.x && column < r.x + r.width && row >= r.y && row < r.y + r.height && i != dragging_idx {
149
0
                    repo_order.swap(dragging_idx, i);
150
0
                    *active_repo = i;
151
                    // Note: we can't update drag.dragging_tab here because we take &self.
152
                    // The caller must update drag.dragging_tab = Some(i) after this returns true.
153
0
                    return true;
154
0
                }
155
0
            }
156
        }
157
158
0
        false
159
0
    }
160
161
    /// Update the drag state after a successful swap.
162
0
    pub fn update_drag_index(&mut self, new_idx: usize) {
163
0
        self.drag.dragging_tab = Some(new_idx);
164
0
    }
165
166
    // ── Mouse event handling ──
167
168
    /// Handle a mouse event on the tab bar. Returns app actions to process.
169
7
    pub fn handle_mouse(&mut self, mouse: MouseEvent) -> Vec<AppAction> {
170
7
        let mut actions = Vec::new();
171
7
        let x = mouse.column;
172
7
        let y = mouse.row;
173
174
7
        match mouse.kind {
175
            MouseEventKind::Down(MouseButton::Left) => {
176
7
                let tab_action = self.handle_click(x, y);
177
7
                match tab_action {
178
0
                    TabBarAction::SwitchToConfig => {
179
0
                        self.drag.dragging_tab = None;
180
0
                        actions.push(AppAction::SwitchToConfig);
181
0
                    }
182
0
                    TabBarAction::SwitchToRepo(i) => {
183
0
                        actions.push(AppAction::SwitchToRepo(i));
184
0
                        // Start potential drag
185
0
                        self.drag.dragging_tab = Some(i);
186
0
                        self.drag.start_x = x;
187
0
                        self.drag.active = false;
188
0
                    }
189
0
                    TabBarAction::OpenFilePicker => {
190
0
                        actions.push(AppAction::OpenFilePicker);
191
0
                    }
192
7
                    TabBarAction::None => {}
193
                }
194
            }
195
            MouseEventKind::Drag(MouseButton::Left) => {
196
0
                if self.drag.dragging_tab.is_some() && !self.drag.active {
197
0
                    let dx = (x as i16 - self.drag.start_x as i16).unsigned_abs();
198
0
                    if dx >= 2 {
199
0
                        self.drag.active = true;
200
0
                    }
201
0
                }
202
            }
203
            MouseEventKind::Up(MouseButton::Left) => {
204
0
                if self.drag.dragging_tab.take().is_some() {
205
0
                    if self.drag.active {
206
0
                        actions.push(AppAction::SaveTabOrder);
207
0
                    }
208
0
                    self.drag.active = false;
209
0
                }
210
            }
211
0
            _ => {}
212
        }
213
214
7
        actions
215
7
    }
216
217
    // ── Tab navigation ──
218
219
    /// Switch to the next tab (forward). Wraps from the last repo to config,
220
    /// and from config to the first repo.
221
9
    pub fn next_tab(&mut self, model: &mut TuiModel, ui: &mut UiState) {
222
9
        self.step_tab(model, ui, TabDirection::Forward);
223
9
    }
224
225
    /// Switch to the previous tab (backward). Wraps from the first repo to config,
226
    /// and from config to the last repo.
227
9
    pub fn prev_tab(&mut self, model: &mut TuiModel, ui: &mut UiState) {
228
9
        self.step_tab(model, ui, TabDirection::Backward);
229
9
    }
230
231
    /// Switch directly to a specific repo tab by index.
232
19
    pub fn switch_to(&self, idx: usize, model: &mut TuiModel, ui: &mut UiState) {
233
19
        if idx < model.repo_order.len() {
234
17
            ui.is_config = false;
235
17
            model.active_repo = idx;
236
17
            let key = &model.repo_order[idx];
237
17
            model.repos.get_mut(key).expect("active repo must have model entry").has_unseen_changes = false;
238
17
        
}2
239
19
    }
240
241
    /// Move the current tab left (delta = -1) or right (delta = 1).
242
    /// Returns true if a swap occurred.
243
12
    pub fn move_tab(&self, delta: isize, model: &mut TuiModel) -> bool {
244
12
        let len = model.repo_order.len();
245
12
        if len < 2 {
246
4
            return false;
247
8
        }
248
8
        let cur = model.active_repo;
249
8
        let new_idx = cur as isize + delta;
250
8
        if new_idx < 0 || 
new_idx >= len as isize6
{
251
4
            return false;
252
4
        }
253
4
        let new_idx = new_idx as usize;
254
4
        model.repo_order.swap(cur, new_idx);
255
4
        model.active_repo = new_idx;
256
4
        true
257
12
    }
258
259
    /// Read-only access to the tab areas for external code that still
260
    /// references them (e.g. gear icon placement in the table area).
261
0
    pub fn tab_areas(&self) -> &BTreeMap<TabId, Rect> {
262
0
        &self.tab_areas
263
0
    }
264
265
    // ── Private helpers ──
266
267
18
    fn step_tab(&mut self, model: &mut TuiModel, ui: &mut UiState, direction: TabDirection) {
268
18
        if model.repo_order.is_empty() {
269
4
            return;
270
14
        }
271
14
        if ui.is_config {
272
5
            ui.is_config = false;
273
5
            model.active_repo = match direction {
274
3
                TabDirection::Forward => 0,
275
2
                TabDirection::Backward => model.repo_order.len() - 1,
276
            };
277
5
            return;
278
9
        }
279
280
9
        match direction {
281
            TabDirection::Forward => {
282
4
                if model.active_repo + 1 < model.repo_order.len() {
283
2
                    self.switch_to(model.active_repo + 1, model, ui);
284
2
                } else {
285
2
                    ui.is_config = true;
286
2
                }
287
            }
288
            TabDirection::Backward => {
289
5
                if model.active_repo > 0 {
290
2
                    self.switch_to(model.active_repo - 1, model, ui);
291
3
                } else {
292
3
                    ui.is_config = true;
293
3
                }
294
            }
295
        }
296
18
    }
297
}
298
299
enum TabDirection {
300
    Forward,
301
    Backward,
302
}
303
304
#[cfg(test)]
305
mod tests {
306
    use ratatui::layout::Rect;
307
308
    use super::*;
309
    use crate::app::test_support::stub_app_with_repos;
310
311
    // ── Click hit-testing ──
312
313
    #[test]
314
1
    fn handle_click_returns_none_for_miss() {
315
1
        let tabs = Tabs::new();
316
1
        assert_eq!(tabs.handle_click(100, 100), TabBarAction::None);
317
1
    }
318
319
    #[test]
320
1
    fn handle_click_detects_flotilla_tab() {
321
1
        let mut tabs = Tabs::new();
322
1
        tabs.tab_areas.insert(TabId::Flotilla, Rect::new(0, 0, 10, 1));
323
1
        assert_eq!(tabs.handle_click(5, 0), TabBarAction::SwitchToConfig);
324
1
    }
325
326
    #[test]
327
1
    fn handle_click_detects_repo_tab() {
328
1
        let mut tabs = Tabs::new();
329
1
        tabs.tab_areas.insert(TabId::Repo(0), Rect::new(10, 0, 10, 1));
330
1
        assert_eq!(tabs.handle_click(15, 0), TabBarAction::SwitchToRepo(0));
331
1
    }
332
333
    #[test]
334
1
    fn handle_click_detects_add_button() {
335
1
        let mut tabs = Tabs::new();
336
1
        tabs.tab_areas.insert(TabId::Add, Rect::new(30, 0, 5, 1));
337
1
        assert_eq!(tabs.handle_click(32, 0), TabBarAction::OpenFilePicker);
338
1
    }
339
340
    // ── Tab navigation ──
341
342
    #[test]
343
1
    fn next_tab_advances_active_repo() {
344
1
        let mut app = stub_app_with_repos(3);
345
1
        let mut tabs = Tabs::new();
346
1
        assert_eq!(app.model.active_repo, 0);
347
1
        tabs.next_tab(&mut app.model, &mut app.ui);
348
1
        assert_eq!(app.model.active_repo, 1);
349
1
    }
350
351
    #[test]
352
1
    fn next_tab_wraps_to_config() {
353
1
        let mut app = stub_app_with_repos(2);
354
1
        let mut tabs = Tabs::new();
355
1
        tabs.switch_to(1, &mut app.model, &mut app.ui);
356
1
        tabs.next_tab(&mut app.model, &mut app.ui);
357
1
        assert!(app.ui.is_config);
358
1
    }
359
360
    #[test]
361
1
    fn next_tab_from_config_goes_to_first() {
362
1
        let mut app = stub_app_with_repos(3);
363
1
        let mut tabs = Tabs::new();
364
1
        app.ui.is_config = true;
365
1
        tabs.next_tab(&mut app.model, &mut app.ui);
366
1
        assert_eq!(app.model.active_repo, 0);
367
1
        assert!(!app.ui.is_config);
368
1
    }
369
370
    #[test]
371
1
    fn next_tab_noop_with_no_repos() {
372
1
        let mut app = stub_app_with_repos(0);
373
1
        let mut tabs = Tabs::new();
374
1
        tabs.next_tab(&mut app.model, &mut app.ui);
375
1
    }
376
377
    #[test]
378
1
    fn prev_tab_decrements_active_repo() {
379
1
        let mut app = stub_app_with_repos(3);
380
1
        let mut tabs = Tabs::new();
381
1
        tabs.switch_to(2, &mut app.model, &mut app.ui);
382
1
        tabs.prev_tab(&mut app.model, &mut app.ui);
383
1
        assert_eq!(app.model.active_repo, 1);
384
1
    }
385
386
    #[test]
387
1
    fn prev_tab_wraps_to_config() {
388
1
        let mut app = stub_app_with_repos(2);
389
1
        let mut tabs = Tabs::new();
390
        // active_repo is 0
391
1
        tabs.prev_tab(&mut app.model, &mut app.ui);
392
1
        assert!(app.ui.is_config);
393
1
    }
394
395
    #[test]
396
1
    fn prev_tab_from_config_goes_to_last() {
397
1
        let mut app = stub_app_with_repos(3);
398
1
        let mut tabs = Tabs::new();
399
1
        app.ui.is_config = true;
400
1
        tabs.prev_tab(&mut app.model, &mut app.ui);
401
1
        assert_eq!(app.model.active_repo, 2);
402
1
        assert!(!app.ui.is_config);
403
1
    }
404
405
    #[test]
406
1
    fn prev_tab_noop_with_no_repos() {
407
1
        let mut app = stub_app_with_repos(0);
408
1
        let mut tabs = Tabs::new();
409
1
        tabs.prev_tab(&mut app.model, &mut app.ui);
410
1
    }
411
412
    // ── switch_to ──
413
414
    #[test]
415
1
    fn switch_to_clears_unseen_changes() {
416
1
        let mut app = stub_app_with_repos(2);
417
1
        let tabs = Tabs::new();
418
1
        let key = app.model.repo_order[1].clone();
419
1
        app.model.repos.get_mut(&key).expect("repo model").has_unseen_changes = true;
420
1
        tabs.switch_to(1, &mut app.model, &mut app.ui);
421
1
        assert!(!app.model.repos[&key].has_unseen_changes);
422
1
    }
423
424
    #[test]
425
1
    fn switch_to_sets_active_repo_and_mode() {
426
1
        let mut app = stub_app_with_repos(3);
427
1
        let tabs = Tabs::new();
428
1
        app.ui.is_config = true;
429
1
        tabs.switch_to(2, &mut app.model, &mut app.ui);
430
1
        assert_eq!(app.model.active_repo, 2);
431
1
        assert!(!app.ui.is_config);
432
1
    }
433
434
    #[test]
435
1
    fn switch_to_noop_for_out_of_range() {
436
1
        let mut app = stub_app_with_repos(2);
437
1
        let tabs = Tabs::new();
438
1
        tabs.switch_to(5, &mut app.model, &mut app.ui);
439
1
        assert_eq!(app.model.active_repo, 0);
440
1
    }
441
442
    // ── move_tab ──
443
444
    #[test]
445
1
    fn move_tab_swaps_repos_forward() {
446
1
        let mut app = stub_app_with_repos(3);
447
1
        let tabs = Tabs::new();
448
1
        assert_eq!(app.model.active_repo, 0);
449
1
        let path0 = app.model.repo_order[0].clone();
450
1
        let path1 = app.model.repo_order[1].clone();
451
1
        let result = tabs.move_tab(1, &mut app.model);
452
1
        assert!(result);
453
1
        assert_eq!(app.model.active_repo, 1);
454
1
        assert_eq!(app.model.repo_order[0], path1);
455
1
        assert_eq!(app.model.repo_order[1], path0);
456
1
    }
457
458
    #[test]
459
1
    fn move_tab_swaps_repos_backward() {
460
1
        let mut app = stub_app_with_repos(3);
461
1
        let tabs = Tabs::new();
462
1
        tabs.switch_to(2, &mut app.model, &mut app.ui);
463
1
        let path1 = app.model.repo_order[1].clone();
464
1
        let path2 = app.model.repo_order[2].clone();
465
1
        let result = tabs.move_tab(-1, &mut app.model);
466
1
        assert!(result);
467
1
        assert_eq!(app.model.active_repo, 1);
468
1
        assert_eq!(app.model.repo_order[1], path2);
469
1
        assert_eq!(app.model.repo_order[2], path1);
470
1
    }
471
472
    #[test]
473
1
    fn move_tab_returns_false_at_boundary() {
474
1
        let mut app = stub_app_with_repos(3);
475
1
        let tabs = Tabs::new();
476
1
        assert!(!tabs.move_tab(-1, &mut app.model));
477
1
        tabs.switch_to(2, &mut app.model, &mut app.ui);
478
1
        assert!(!tabs.move_tab(1, &mut app.model));
479
1
    }
480
481
    #[test]
482
1
    fn move_tab_returns_false_with_single_repo() {
483
1
        let mut app = stub_app_with_repos(1);
484
1
        let tabs = Tabs::new();
485
1
        assert!(!tabs.move_tab(1, &mut app.model));
486
1
        assert!(!tabs.move_tab(-1, &mut app.model));
487
1
    }
488
}