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 Self::StatusMessage(msg.into())
77 }
78
79 #[must_use]
81 pub const fn navigate(target: TabTarget) -> Self {
82 Self::NavigateTo(target)
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum TabTarget {
89 Summary,
90 Components,
91 Dependencies,
92 Licenses,
93 Vulnerabilities,
94 Quality,
95 Compliance,
96 SideBySide,
97 GraphChanges,
98 Source,
99 ComponentByName(String),
101 VulnerabilityById(String),
103}
104
105impl TabTarget {
106 #[must_use]
108 pub const fn to_tab_kind(&self) -> Option<super::app::TabKind> {
109 match self {
110 Self::Summary => Some(super::app::TabKind::Summary),
111 Self::Components | Self::ComponentByName(_) => Some(super::app::TabKind::Components),
112 Self::Dependencies => Some(super::app::TabKind::Dependencies),
113 Self::Licenses => Some(super::app::TabKind::Licenses),
114 Self::Vulnerabilities | Self::VulnerabilityById(_) => {
115 Some(super::app::TabKind::Vulnerabilities)
116 }
117 Self::Quality => Some(super::app::TabKind::Quality),
118 Self::Compliance => Some(super::app::TabKind::Compliance),
119 Self::SideBySide => Some(super::app::TabKind::SideBySide),
120 Self::GraphChanges => Some(super::app::TabKind::GraphChanges),
121 Self::Source => Some(super::app::TabKind::Source),
122 }
123 }
124
125 #[must_use]
127 pub const fn from_tab_kind(kind: super::app::TabKind) -> Self {
128 match kind {
129 super::app::TabKind::Summary => Self::Summary,
130 super::app::TabKind::Components => Self::Components,
131 super::app::TabKind::Dependencies => Self::Dependencies,
132 super::app::TabKind::Licenses => Self::Licenses,
133 super::app::TabKind::Vulnerabilities => Self::Vulnerabilities,
134 super::app::TabKind::Quality => Self::Quality,
135 super::app::TabKind::Compliance => Self::Compliance,
136 super::app::TabKind::SideBySide => Self::SideBySide,
137 super::app::TabKind::GraphChanges => Self::GraphChanges,
138 super::app::TabKind::Source => Self::Source,
139 }
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum OverlayKind {
146 Help,
147 Export,
148 Legend,
149 Search,
150 Shortcuts,
151}
152
153#[derive(Debug, Clone)]
155pub struct Shortcut {
156 pub key: String,
158 pub description: String,
160 pub primary: bool,
162}
163
164impl Shortcut {
165 pub fn new(key: impl Into<String>, description: impl Into<String>) -> Self {
167 Self {
168 key: key.into(),
169 description: description.into(),
170 primary: false,
171 }
172 }
173
174 pub fn primary(key: impl Into<String>, description: impl Into<String>) -> Self {
176 Self {
177 key: key.into(),
178 description: description.into(),
179 primary: true,
180 }
181 }
182}
183
184pub struct ViewContext<'a> {
186 pub mode: ViewMode,
188 pub focused: bool,
190 pub width: u16,
192 pub height: u16,
194 pub tick: u64,
196 pub status_message: &'a mut Option<String>,
198}
199
200impl ViewContext<'_> {
201 pub fn set_status(&mut self, msg: impl Into<String>) {
203 *self.status_message = Some(msg.into());
204 }
205
206 pub fn clear_status(&mut self) {
208 *self.status_message = None;
209 }
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum ViewMode {
215 Diff,
217 View,
219 MultiDiff,
221 Timeline,
223 Matrix,
225}
226
227impl ViewMode {
228 #[must_use]
230 pub const fn from_app_mode(mode: super::app::AppMode) -> Self {
231 match mode {
232 super::app::AppMode::Diff => Self::Diff,
233 super::app::AppMode::View => Self::View,
234 super::app::AppMode::MultiDiff => Self::MultiDiff,
235 super::app::AppMode::Timeline => Self::Timeline,
236 super::app::AppMode::Matrix => Self::Matrix,
237 }
238 }
239}
240
241pub trait ViewState: Send {
267 fn handle_key(&mut self, key: KeyEvent, ctx: &mut ViewContext) -> EventResult;
273
274 fn handle_mouse(&mut self, _mouse: MouseEvent, _ctx: &mut ViewContext) -> EventResult {
278 EventResult::Ignored
279 }
280
281 fn title(&self) -> &str;
283
284 fn shortcuts(&self) -> Vec<Shortcut>;
288
289 fn on_enter(&mut self, _ctx: &mut ViewContext) {}
293
294 fn on_leave(&mut self, _ctx: &mut ViewContext) {}
298
299 fn on_tick(&mut self, _ctx: &mut ViewContext) {}
303
304 fn has_modal(&self) -> bool {
308 false
309 }
310}
311
312pub trait ListViewState: ViewState {
317 fn selected(&self) -> usize;
319
320 fn set_selected(&mut self, idx: usize);
322
323 fn total(&self) -> usize;
325
326 fn select_next(&mut self) {
328 let total = self.total();
329 let selected = self.selected();
330 if total > 0 && selected < total.saturating_sub(1) {
331 self.set_selected(selected + 1);
332 }
333 }
334
335 fn select_prev(&mut self) {
337 let selected = self.selected();
338 if selected > 0 {
339 self.set_selected(selected - 1);
340 }
341 }
342
343 fn page_down(&mut self) {
345 use super::constants::PAGE_SIZE;
346 let total = self.total();
347 let selected = self.selected();
348 if total > 0 {
349 self.set_selected((selected + PAGE_SIZE).min(total.saturating_sub(1)));
350 }
351 }
352
353 fn page_up(&mut self) {
355 use super::constants::PAGE_SIZE;
356 let selected = self.selected();
357 self.set_selected(selected.saturating_sub(PAGE_SIZE));
358 }
359
360 fn go_first(&mut self) {
362 self.set_selected(0);
363 }
364
365 fn go_last(&mut self) {
367 let total = self.total();
368 if total > 0 {
369 self.set_selected(total.saturating_sub(1));
370 }
371 }
372
373 fn handle_list_nav_key(&mut self, key: KeyEvent) -> EventResult {
382 use crossterm::event::KeyCode;
383
384 match key.code {
385 KeyCode::Down | KeyCode::Char('j') => {
386 self.select_next();
387 EventResult::Consumed
388 }
389 KeyCode::Up | KeyCode::Char('k') => {
390 self.select_prev();
391 EventResult::Consumed
392 }
393 KeyCode::Home | KeyCode::Char('g') => {
394 self.go_first();
395 EventResult::Consumed
396 }
397 KeyCode::End | KeyCode::Char('G') => {
398 self.go_last();
399 EventResult::Consumed
400 }
401 KeyCode::PageDown => {
402 self.page_down();
403 EventResult::Consumed
404 }
405 KeyCode::PageUp => {
406 self.page_up();
407 EventResult::Consumed
408 }
409 _ => EventResult::Ignored,
410 }
411 }
412}
413
414impl fmt::Display for EventResult {
416 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
417 match self {
418 Self::Consumed => write!(f, "Consumed"),
419 Self::Ignored => write!(f, "Ignored"),
420 Self::NavigateTo(target) => write!(f, "NavigateTo({target:?})"),
421 Self::Exit => write!(f, "Exit"),
422 Self::ShowOverlay(kind) => write!(f, "ShowOverlay({kind:?})"),
423 Self::StatusMessage(msg) => write!(f, "StatusMessage({msg})"),
424 }
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use crossterm::event::{KeyCode, KeyModifiers};
432
433 struct TestListView {
435 selected: usize,
436 total: usize,
437 }
438
439 impl TestListView {
440 fn new(total: usize) -> Self {
441 Self { selected: 0, total }
442 }
443 }
444
445 impl ViewState for TestListView {
446 fn handle_key(&mut self, key: KeyEvent, _ctx: &mut ViewContext) -> EventResult {
447 self.handle_list_nav_key(key)
448 }
449
450 fn title(&self) -> &str {
451 "Test View"
452 }
453
454 fn shortcuts(&self) -> Vec<Shortcut> {
455 vec![
456 Shortcut::primary("j/k", "Navigate"),
457 Shortcut::new("g/G", "First/Last"),
458 ]
459 }
460 }
461
462 impl ListViewState for TestListView {
463 fn selected(&self) -> usize {
464 self.selected
465 }
466
467 fn set_selected(&mut self, idx: usize) {
468 self.selected = idx;
469 }
470
471 fn total(&self) -> usize {
472 self.total
473 }
474 }
475
476 fn make_key_event(code: KeyCode) -> KeyEvent {
477 KeyEvent::new(code, KeyModifiers::empty())
478 }
479
480 fn make_context() -> ViewContext<'static> {
481 let status: &'static mut Option<String> = Box::leak(Box::new(None));
482 ViewContext {
483 mode: ViewMode::Diff,
484 focused: true,
485 width: 80,
486 height: 24,
487 tick: 0,
488 status_message: status,
489 }
490 }
491
492 #[test]
493 fn test_list_view_navigation() {
494 let mut view = TestListView::new(10);
495 let mut ctx = make_context();
496
497 assert_eq!(view.selected(), 0);
499
500 let result = view.handle_key(make_key_event(KeyCode::Down), &mut ctx);
502 assert_eq!(result, EventResult::Consumed);
503 assert_eq!(view.selected(), 1);
504
505 let result = view.handle_key(make_key_event(KeyCode::Up), &mut ctx);
507 assert_eq!(result, EventResult::Consumed);
508 assert_eq!(view.selected(), 0);
509
510 let result = view.handle_key(make_key_event(KeyCode::Up), &mut ctx);
512 assert_eq!(result, EventResult::Consumed);
513 assert_eq!(view.selected(), 0);
514 }
515
516 #[test]
517 fn test_list_view_go_to_end() {
518 let mut view = TestListView::new(10);
519 let mut ctx = make_context();
520
521 let result = view.handle_key(make_key_event(KeyCode::Char('G')), &mut ctx);
523 assert_eq!(result, EventResult::Consumed);
524 assert_eq!(view.selected(), 9);
525
526 let result = view.handle_key(make_key_event(KeyCode::Down), &mut ctx);
528 assert_eq!(result, EventResult::Consumed);
529 assert_eq!(view.selected(), 9);
530 }
531
532 #[test]
533 fn test_event_result_display() {
534 assert_eq!(format!("{}", EventResult::Consumed), "Consumed");
535 assert_eq!(format!("{}", EventResult::Ignored), "Ignored");
536 assert_eq!(format!("{}", EventResult::Exit), "Exit");
537 }
538
539 #[test]
540 fn test_shortcut_creation() {
541 let shortcut = Shortcut::new("Enter", "Select item");
542 assert_eq!(shortcut.key, "Enter");
543 assert_eq!(shortcut.description, "Select item");
544 assert!(!shortcut.primary);
545
546 let primary = Shortcut::primary("q", "Quit");
547 assert!(primary.primary);
548 }
549
550 #[test]
551 fn test_event_result_helpers() {
552 let result = EventResult::status("Test message");
553 assert_eq!(
554 result,
555 EventResult::StatusMessage("Test message".to_string())
556 );
557
558 let nav = EventResult::navigate(TabTarget::Components);
559 assert_eq!(nav, EventResult::NavigateTo(TabTarget::Components));
560 }
561}