//! Who (people intelligence) screen state. //! //! Manages 5 query modes (Expert, Workload, Reviews, Active, Overlap), //! input fields (path or username depending on mode), and result display. use lore::core::who_types::WhoResult; use crate::text_width::{next_char_boundary, prev_char_boundary}; // --------------------------------------------------------------------------- // WhoMode // --------------------------------------------------------------------------- /// The 5 query modes for the who screen. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum WhoMode { /// File-path expertise scores. #[default] Expert, /// Issue/MR assignment workload for a username. Workload, /// Review activity breakdown for a username. Reviews, /// Recent unresolved discussions (no input needed). Active, /// Shared file knowledge between contributors. Overlap, } impl WhoMode { /// Short label for mode tab rendering. #[must_use] pub fn label(self) -> &'static str { match self { Self::Expert => "Expert", Self::Workload => "Workload", Self::Reviews => "Reviews", Self::Active => "Active", Self::Overlap => "Overlap", } } /// Abbreviated 3-char label for narrow terminals. #[must_use] pub fn short_label(self) -> &'static str { match self { Self::Expert => "Exp", Self::Workload => "Wkl", Self::Reviews => "Rev", Self::Active => "Act", Self::Overlap => "Ovl", } } /// Whether this mode requires a path input. #[must_use] pub fn needs_path(self) -> bool { matches!(self, Self::Expert | Self::Overlap) } /// Whether this mode requires a username input. #[must_use] pub fn needs_username(self) -> bool { matches!(self, Self::Workload | Self::Reviews) } /// Whether include_closed affects this mode's query. #[must_use] pub fn affected_by_include_closed(self) -> bool { matches!(self, Self::Workload | Self::Active) } /// Cycle to the next mode (wraps around). #[must_use] pub fn next(self) -> Self { match self { Self::Expert => Self::Workload, Self::Workload => Self::Reviews, Self::Reviews => Self::Active, Self::Active => Self::Overlap, Self::Overlap => Self::Expert, } } /// All modes in order. pub const ALL: [Self; 5] = [ Self::Expert, Self::Workload, Self::Reviews, Self::Active, Self::Overlap, ]; /// Mode from 1-based number key (1=Expert, 2=Workload, ..., 5=Overlap). #[must_use] pub fn from_number(n: u8) -> Option { match n { 1 => Some(Self::Expert), 2 => Some(Self::Workload), 3 => Some(Self::Reviews), 4 => Some(Self::Active), 5 => Some(Self::Overlap), _ => None, } } } // --------------------------------------------------------------------------- // WhoState // --------------------------------------------------------------------------- /// State for the who/people screen. #[derive(Debug, Default)] pub struct WhoState { /// Active query mode. pub mode: WhoMode, /// Current result (if any). pub result: Option, // Input fields. /// Path input text (used by Expert and Overlap modes). pub path: String, /// Cursor position within path string (byte offset). pub path_cursor: usize, /// Whether the path input has focus. pub path_focused: bool, /// Username input text (used by Workload and Reviews modes). pub username: String, /// Cursor position within username string (byte offset). pub username_cursor: usize, /// Whether the username input has focus. pub username_focused: bool, /// Toggle: include closed entities in Workload/Active queries. pub include_closed: bool, // Result navigation. /// Index of the selected row in the result list. pub selected_index: usize, /// Vertical scroll offset for the result area. pub scroll_offset: usize, // Async coordination. /// Monotonic generation counter for stale-response detection. pub generation: u64, /// Whether a query is in-flight. pub loading: bool, } impl WhoState { /// Enter the who screen: focus the appropriate input. pub fn enter(&mut self) { self.focus_input_for_mode(); } /// Leave the who screen: blur all inputs. pub fn leave(&mut self) { self.path_focused = false; self.username_focused = false; } /// Switch to a different mode. Clears result and resets selection. /// Returns the new generation for stale detection. pub fn set_mode(&mut self, mode: WhoMode) -> u64 { if self.mode == mode { return self.generation; } self.mode = mode; self.result = None; self.selected_index = 0; self.scroll_offset = 0; self.focus_input_for_mode(); self.bump_generation() } /// Toggle include_closed. Returns new generation if the mode is affected. pub fn toggle_include_closed(&mut self) -> Option { self.include_closed = !self.include_closed; if self.mode.affected_by_include_closed() { Some(self.bump_generation()) } else { None } } /// Apply query results if generation matches. pub fn apply_results(&mut self, generation: u64, result: WhoResult) { if generation != self.generation { return; // Stale response — discard. } self.result = Some(result); self.loading = false; self.selected_index = 0; self.scroll_offset = 0; } /// Submit the current input (trigger a query). /// Returns the generation for the new query. pub fn submit(&mut self) -> u64 { self.loading = true; self.bump_generation() } // --- Input field operations --- /// Insert a char at cursor in the active input field. pub fn insert_char(&mut self, c: char) { if self.path_focused { self.path.insert(self.path_cursor, c); self.path_cursor += c.len_utf8(); } else if self.username_focused { self.username.insert(self.username_cursor, c); self.username_cursor += c.len_utf8(); } } /// Delete the char before cursor in the active input field. pub fn delete_char_before_cursor(&mut self) { if self.path_focused && self.path_cursor > 0 { let prev = prev_char_boundary(&self.path, self.path_cursor); self.path.drain(prev..self.path_cursor); self.path_cursor = prev; } else if self.username_focused && self.username_cursor > 0 { let prev = prev_char_boundary(&self.username, self.username_cursor); self.username.drain(prev..self.username_cursor); self.username_cursor = prev; } } /// Move cursor left in the active input. pub fn cursor_left(&mut self) { if self.path_focused && self.path_cursor > 0 { self.path_cursor = prev_char_boundary(&self.path, self.path_cursor); } else if self.username_focused && self.username_cursor > 0 { self.username_cursor = prev_char_boundary(&self.username, self.username_cursor); } } /// Move cursor right in the active input. pub fn cursor_right(&mut self) { if self.path_focused && self.path_cursor < self.path.len() { self.path_cursor = next_char_boundary(&self.path, self.path_cursor); } else if self.username_focused && self.username_cursor < self.username.len() { self.username_cursor = next_char_boundary(&self.username, self.username_cursor); } } /// Whether any input field has focus. #[must_use] pub fn has_text_focus(&self) -> bool { self.path_focused || self.username_focused } /// Blur all inputs. pub fn blur(&mut self) { self.path_focused = false; self.username_focused = false; } /// Focus the appropriate input for the current mode. pub fn focus_input_for_mode(&mut self) { self.path_focused = self.mode.needs_path(); self.username_focused = self.mode.needs_username(); // Place cursor at end of text. if self.path_focused { self.path_cursor = self.path.len(); } if self.username_focused { self.username_cursor = self.username.len(); } } // --- Selection navigation --- /// Move selection up. pub fn select_prev(&mut self) { self.selected_index = self.selected_index.saturating_sub(1); } /// Move selection down (bounded by result count). pub fn select_next(&mut self, result_count: usize) { if result_count > 0 { self.selected_index = (self.selected_index + 1).min(result_count - 1); } } /// Ensure the selected row is visible within the viewport. pub fn ensure_visible(&mut self, viewport_height: usize) { if viewport_height == 0 { return; } if self.selected_index < self.scroll_offset { self.scroll_offset = self.selected_index; } else if self.selected_index >= self.scroll_offset + viewport_height { self.scroll_offset = self.selected_index - viewport_height + 1; } } // --- Internal --- fn bump_generation(&mut self) -> u64 { self.generation += 1; self.generation } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn test_who_mode_defaults_to_expert() { assert_eq!(WhoMode::default(), WhoMode::Expert); } #[test] fn test_who_mode_labels() { assert_eq!(WhoMode::Expert.label(), "Expert"); assert_eq!(WhoMode::Active.label(), "Active"); assert_eq!(WhoMode::Overlap.label(), "Overlap"); } #[test] fn test_who_mode_needs_path() { assert!(WhoMode::Expert.needs_path()); assert!(WhoMode::Overlap.needs_path()); assert!(!WhoMode::Workload.needs_path()); assert!(!WhoMode::Reviews.needs_path()); assert!(!WhoMode::Active.needs_path()); } #[test] fn test_who_mode_needs_username() { assert!(WhoMode::Workload.needs_username()); assert!(WhoMode::Reviews.needs_username()); assert!(!WhoMode::Expert.needs_username()); assert!(!WhoMode::Active.needs_username()); } #[test] fn test_who_mode_next_cycles() { let start = WhoMode::Expert; let m = start.next().next().next().next().next(); assert_eq!(m, start); } #[test] fn test_who_mode_from_number() { assert_eq!(WhoMode::from_number(1), Some(WhoMode::Expert)); assert_eq!(WhoMode::from_number(5), Some(WhoMode::Overlap)); assert_eq!(WhoMode::from_number(0), None); assert_eq!(WhoMode::from_number(6), None); } #[test] fn test_who_state_default() { let state = WhoState::default(); assert_eq!(state.mode, WhoMode::Expert); assert!(state.result.is_none()); assert!(!state.include_closed); assert_eq!(state.generation, 0); } #[test] fn test_set_mode_bumps_generation() { let mut state = WhoState::default(); let generation = state.set_mode(WhoMode::Workload); assert_eq!(generation, 1); assert_eq!(state.mode, WhoMode::Workload); assert!(state.result.is_none()); assert!(state.username_focused); assert!(!state.path_focused); } #[test] fn test_set_mode_same_does_not_bump() { let mut state = WhoState::default(); let generation = state.set_mode(WhoMode::Expert); assert_eq!(generation, 0); // No bump for same mode. } #[test] fn test_toggle_include_closed_returns_gen_for_affected_modes() { let state = &mut WhoState { mode: WhoMode::Workload, ..WhoState::default() }; let generation = state.toggle_include_closed(); assert!(generation.is_some()); assert!(state.include_closed); } #[test] fn test_toggle_include_closed_returns_none_for_unaffected_modes() { let state = &mut WhoState { mode: WhoMode::Expert, ..WhoState::default() }; let generation = state.toggle_include_closed(); assert!(generation.is_none()); assert!(state.include_closed); } #[test] fn test_stale_response_guard() { let mut state = WhoState::default(); let stale_gen = state.submit(); // Bump generation again (simulating user changed mode). let _new_gen = state.set_mode(WhoMode::Active); // Old response arrives — should be discarded. state.apply_results( stale_gen, WhoResult::Active(lore::core::who_types::ActiveResult { discussions: vec![], total_unresolved_in_window: 0, truncated: false, }), ); assert!(state.result.is_none()); // Stale, discarded. } #[test] fn test_insert_and_delete_char() { let mut state = WhoState { path_focused: true, ..WhoState::default() }; state.insert_char('s'); state.insert_char('r'); state.insert_char('c'); assert_eq!(state.path, "src"); assert_eq!(state.path_cursor, 3); state.delete_char_before_cursor(); assert_eq!(state.path, "sr"); assert_eq!(state.path_cursor, 2); } #[test] fn test_cursor_movement() { let mut state = WhoState { username_focused: true, username: "alice".into(), username_cursor: 5, ..WhoState::default() }; state.cursor_left(); assert_eq!(state.username_cursor, 4); state.cursor_right(); assert_eq!(state.username_cursor, 5); // Right at end is clamped. state.cursor_right(); assert_eq!(state.username_cursor, 5); } #[test] fn test_select_prev_next() { let mut state = WhoState::default(); state.select_next(5); assert_eq!(state.selected_index, 1); state.select_next(5); assert_eq!(state.selected_index, 2); state.select_prev(); assert_eq!(state.selected_index, 1); state.select_prev(); assert_eq!(state.selected_index, 0); state.select_prev(); // Should not underflow. assert_eq!(state.selected_index, 0); } #[test] fn test_ensure_visible() { let mut state = WhoState { selected_index: 15, ..WhoState::default() }; state.ensure_visible(5); assert_eq!(state.scroll_offset, 11); // 15 - 5 + 1 } #[test] fn test_enter_focuses_correct_input() { let mut state = WhoState { mode: WhoMode::Expert, ..WhoState::default() }; state.enter(); assert!(state.path_focused); assert!(!state.username_focused); state.mode = WhoMode::Reviews; state.enter(); assert!(!state.path_focused); // Reviews needs username. // focus_input_for_mode is called in enter(). } #[test] fn test_affected_by_include_closed() { assert!(WhoMode::Workload.affected_by_include_closed()); assert!(WhoMode::Active.affected_by_include_closed()); assert!(!WhoMode::Expert.affected_by_include_closed()); assert!(!WhoMode::Reviews.affected_by_include_closed()); assert!(!WhoMode::Overlap.affected_by_include_closed()); } }