1use crossterm::event::{KeyEvent, MouseEvent};
51use std::fmt;
52
53#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum EventResult {
59 Consumed,
61 Ignored,
63 NavigateTo(TabTarget),
65 Exit,
67 ShowOverlay(OverlayKind),
69 StatusMessage(String),
71}
72
73impl EventResult {
74 pub fn status(msg: impl Into<String>) -> Self {
76 EventResult::StatusMessage(msg.into())
77 }
78
79 pub fn navigate(target: TabTarget) -> Self {
81 EventResult::NavigateTo(target)
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum TabTarget {
88 Summary,
89 Components,
90 Dependencies,
91 Licenses,
92 Vulnerabilities,
93 Quality,
94 Compliance,
95 SideBySide,
96 GraphChanges,
97 Source,
98 ComponentByName(String),
100 VulnerabilityById(String),
102}
103
104impl TabTarget {
105 pub fn to_tab_kind(&self) -> Option<super::app::TabKind> {
107 match self {
108 TabTarget::Summary => Some(super::app::TabKind::Summary),
109 TabTarget::Components => Some(super::app::TabKind::Components),
110 TabTarget::Dependencies => Some(super::app::TabKind::Dependencies),
111 TabTarget::Licenses => Some(super::app::TabKind::Licenses),
112 TabTarget::Vulnerabilities => Some(super::app::TabKind::Vulnerabilities),
113 TabTarget::Quality => Some(super::app::TabKind::Quality),
114 TabTarget::Compliance => Some(super::app::TabKind::Compliance),
115 TabTarget::SideBySide => Some(super::app::TabKind::SideBySide),
116 TabTarget::GraphChanges => Some(super::app::TabKind::GraphChanges),
117 TabTarget::Source => Some(super::app::TabKind::Source),
118 TabTarget::ComponentByName(_) => Some(super::app::TabKind::Components),
119 TabTarget::VulnerabilityById(_) => Some(super::app::TabKind::Vulnerabilities),
120 }
121 }
122
123 pub fn from_tab_kind(kind: super::app::TabKind) -> Self {
125 match kind {
126 super::app::TabKind::Summary => TabTarget::Summary,
127 super::app::TabKind::Components => TabTarget::Components,
128 super::app::TabKind::Dependencies => TabTarget::Dependencies,
129 super::app::TabKind::Licenses => TabTarget::Licenses,
130 super::app::TabKind::Vulnerabilities => TabTarget::Vulnerabilities,
131 super::app::TabKind::Quality => TabTarget::Quality,
132 super::app::TabKind::Compliance => TabTarget::Compliance,
133 super::app::TabKind::SideBySide => TabTarget::SideBySide,
134 super::app::TabKind::GraphChanges => TabTarget::GraphChanges,
135 super::app::TabKind::Source => TabTarget::Source,
136 }
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum OverlayKind {
143 Help,
144 Export,
145 Legend,
146 Search,
147 Shortcuts,
148}
149
150#[derive(Debug, Clone)]
152pub struct Shortcut {
153 pub key: String,
155 pub description: String,
157 pub primary: bool,
159}
160
161impl Shortcut {
162 pub fn new(key: impl Into<String>, description: impl Into<String>) -> Self {
164 Self {
165 key: key.into(),
166 description: description.into(),
167 primary: false,
168 }
169 }
170
171 pub fn primary(key: impl Into<String>, description: impl Into<String>) -> Self {
173 Self {
174 key: key.into(),
175 description: description.into(),
176 primary: true,
177 }
178 }
179}
180
181pub struct ViewContext<'a> {
183 pub mode: ViewMode,
185 pub focused: bool,
187 pub width: u16,
189 pub height: u16,
191 pub tick: u64,
193 pub status_message: &'a mut Option<String>,
195}
196
197impl<'a> ViewContext<'a> {
198 pub fn set_status(&mut self, msg: impl Into<String>) {
200 *self.status_message = Some(msg.into());
201 }
202
203 pub fn clear_status(&mut self) {
205 *self.status_message = None;
206 }
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub enum ViewMode {
212 Diff,
214 View,
216 MultiDiff,
218 Timeline,
220 Matrix,
222}
223
224impl ViewMode {
225 pub fn from_app_mode(mode: super::app::AppMode) -> Self {
227 match mode {
228 super::app::AppMode::Diff => ViewMode::Diff,
229 super::app::AppMode::View => ViewMode::View,
230 super::app::AppMode::MultiDiff => ViewMode::MultiDiff,
231 super::app::AppMode::Timeline => ViewMode::Timeline,
232 super::app::AppMode::Matrix => ViewMode::Matrix,
233 }
234 }
235}
236
237pub trait ViewState: Send {
263 fn handle_key(&mut self, key: KeyEvent, ctx: &mut ViewContext) -> EventResult;
269
270 fn handle_mouse(&mut self, _mouse: MouseEvent, _ctx: &mut ViewContext) -> EventResult {
274 EventResult::Ignored
275 }
276
277 fn title(&self) -> &str;
279
280 fn shortcuts(&self) -> Vec<Shortcut>;
284
285 fn on_enter(&mut self, _ctx: &mut ViewContext) {}
289
290 fn on_leave(&mut self, _ctx: &mut ViewContext) {}
294
295 fn on_tick(&mut self, _ctx: &mut ViewContext) {}
299
300 fn has_modal(&self) -> bool {
304 false
305 }
306}
307
308pub trait ListViewState: ViewState {
313 fn selected(&self) -> usize;
315
316 fn set_selected(&mut self, idx: usize);
318
319 fn total(&self) -> usize;
321
322 fn select_next(&mut self) {
324 let total = self.total();
325 let selected = self.selected();
326 if total > 0 && selected < total.saturating_sub(1) {
327 self.set_selected(selected + 1);
328 }
329 }
330
331 fn select_prev(&mut self) {
333 let selected = self.selected();
334 if selected > 0 {
335 self.set_selected(selected - 1);
336 }
337 }
338
339 fn page_down(&mut self) {
341 let total = self.total();
342 let selected = self.selected();
343 if total > 0 {
344 self.set_selected((selected + 10).min(total.saturating_sub(1)));
345 }
346 }
347
348 fn page_up(&mut self) {
350 let selected = self.selected();
351 self.set_selected(selected.saturating_sub(10));
352 }
353
354 fn go_first(&mut self) {
356 self.set_selected(0);
357 }
358
359 fn go_last(&mut self) {
361 let total = self.total();
362 if total > 0 {
363 self.set_selected(total.saturating_sub(1));
364 }
365 }
366
367 fn handle_list_nav_key(&mut self, key: KeyEvent) -> EventResult {
376 use crossterm::event::KeyCode;
377
378 match key.code {
379 KeyCode::Down | KeyCode::Char('j') => {
380 self.select_next();
381 EventResult::Consumed
382 }
383 KeyCode::Up | KeyCode::Char('k') => {
384 self.select_prev();
385 EventResult::Consumed
386 }
387 KeyCode::Home | KeyCode::Char('g') => {
388 self.go_first();
389 EventResult::Consumed
390 }
391 KeyCode::End | KeyCode::Char('G') => {
392 self.go_last();
393 EventResult::Consumed
394 }
395 KeyCode::PageDown => {
396 self.page_down();
397 EventResult::Consumed
398 }
399 KeyCode::PageUp => {
400 self.page_up();
401 EventResult::Consumed
402 }
403 _ => EventResult::Ignored,
404 }
405 }
406}
407
408impl fmt::Display for EventResult {
410 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
411 match self {
412 EventResult::Consumed => write!(f, "Consumed"),
413 EventResult::Ignored => write!(f, "Ignored"),
414 EventResult::NavigateTo(target) => write!(f, "NavigateTo({:?})", target),
415 EventResult::Exit => write!(f, "Exit"),
416 EventResult::ShowOverlay(kind) => write!(f, "ShowOverlay({:?})", kind),
417 EventResult::StatusMessage(msg) => write!(f, "StatusMessage({})", msg),
418 }
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425 use crossterm::event::{KeyCode, KeyModifiers};
426
427 struct TestListView {
429 selected: usize,
430 total: usize,
431 }
432
433 impl TestListView {
434 fn new(total: usize) -> Self {
435 Self { selected: 0, total }
436 }
437 }
438
439 impl ViewState for TestListView {
440 fn handle_key(&mut self, key: KeyEvent, _ctx: &mut ViewContext) -> EventResult {
441 self.handle_list_nav_key(key)
442 }
443
444 fn title(&self) -> &str {
445 "Test View"
446 }
447
448 fn shortcuts(&self) -> Vec<Shortcut> {
449 vec![
450 Shortcut::primary("j/k", "Navigate"),
451 Shortcut::new("g/G", "First/Last"),
452 ]
453 }
454 }
455
456 impl ListViewState for TestListView {
457 fn selected(&self) -> usize {
458 self.selected
459 }
460
461 fn set_selected(&mut self, idx: usize) {
462 self.selected = idx;
463 }
464
465 fn total(&self) -> usize {
466 self.total
467 }
468 }
469
470 fn make_key_event(code: KeyCode) -> KeyEvent {
471 KeyEvent::new(code, KeyModifiers::empty())
472 }
473
474 fn make_context() -> ViewContext<'static> {
475 let status: &'static mut Option<String> = Box::leak(Box::new(None));
476 ViewContext {
477 mode: ViewMode::Diff,
478 focused: true,
479 width: 80,
480 height: 24,
481 tick: 0,
482 status_message: status,
483 }
484 }
485
486 #[test]
487 fn test_list_view_navigation() {
488 let mut view = TestListView::new(10);
489 let mut ctx = make_context();
490
491 assert_eq!(view.selected(), 0);
493
494 let result = view.handle_key(make_key_event(KeyCode::Down), &mut ctx);
496 assert_eq!(result, EventResult::Consumed);
497 assert_eq!(view.selected(), 1);
498
499 let result = view.handle_key(make_key_event(KeyCode::Up), &mut ctx);
501 assert_eq!(result, EventResult::Consumed);
502 assert_eq!(view.selected(), 0);
503
504 let result = view.handle_key(make_key_event(KeyCode::Up), &mut ctx);
506 assert_eq!(result, EventResult::Consumed);
507 assert_eq!(view.selected(), 0);
508 }
509
510 #[test]
511 fn test_list_view_go_to_end() {
512 let mut view = TestListView::new(10);
513 let mut ctx = make_context();
514
515 let result = view.handle_key(make_key_event(KeyCode::Char('G')), &mut ctx);
517 assert_eq!(result, EventResult::Consumed);
518 assert_eq!(view.selected(), 9);
519
520 let result = view.handle_key(make_key_event(KeyCode::Down), &mut ctx);
522 assert_eq!(result, EventResult::Consumed);
523 assert_eq!(view.selected(), 9);
524 }
525
526 #[test]
527 fn test_event_result_display() {
528 assert_eq!(format!("{}", EventResult::Consumed), "Consumed");
529 assert_eq!(format!("{}", EventResult::Ignored), "Ignored");
530 assert_eq!(format!("{}", EventResult::Exit), "Exit");
531 }
532
533 #[test]
534 fn test_shortcut_creation() {
535 let shortcut = Shortcut::new("Enter", "Select item");
536 assert_eq!(shortcut.key, "Enter");
537 assert_eq!(shortcut.description, "Select item");
538 assert!(!shortcut.primary);
539
540 let primary = Shortcut::primary("q", "Quit");
541 assert!(primary.primary);
542 }
543
544 #[test]
545 fn test_event_result_helpers() {
546 let result = EventResult::status("Test message");
547 assert_eq!(
548 result,
549 EventResult::StatusMessage("Test message".to_string())
550 );
551
552 let nav = EventResult::navigate(TabTarget::Components);
553 assert_eq!(nav, EventResult::NavigateTo(TabTarget::Components));
554 }
555}