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 | | } |