use ratatui::layout::Rect;
use super::traits::{HotkeyCategory, HotkeyEntryData, HotkeyProvider};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HotkeyFocus {
SearchInput,
#[default]
CategoryList,
HotkeyList,
}
impl HotkeyFocus {
pub fn next(&self) -> Self {
match self {
HotkeyFocus::SearchInput => HotkeyFocus::CategoryList,
HotkeyFocus::CategoryList => HotkeyFocus::HotkeyList,
HotkeyFocus::HotkeyList => HotkeyFocus::SearchInput,
}
}
pub fn prev(&self) -> Self {
match self {
HotkeyFocus::SearchInput => HotkeyFocus::HotkeyList,
HotkeyFocus::CategoryList => HotkeyFocus::SearchInput,
HotkeyFocus::HotkeyList => HotkeyFocus::CategoryList,
}
}
}
#[derive(Debug, Clone)]
pub struct CategoryClickRegion<C> {
pub area: Rect,
pub category: C,
}
#[derive(Debug, Clone)]
pub struct HotkeyClickRegion {
pub area: Rect,
pub index: usize,
}
#[derive(Debug, Clone)]
pub struct HotkeyDialogState<C: HotkeyCategory> {
pub search_query: String,
pub search_cursor_pos: usize,
pub selected_category: C,
pub category_scroll: usize,
pub hotkey_scroll: usize,
pub selected_hotkey_idx: usize,
pub focus: HotkeyFocus,
pub category_click_regions: Vec<CategoryClickRegion<C>>,
pub hotkey_click_regions: Vec<HotkeyClickRegion>,
cached_entry_count: usize,
}
impl<C: HotkeyCategory> Default for HotkeyDialogState<C> {
fn default() -> Self {
Self::new()
}
}
impl<C: HotkeyCategory> HotkeyDialogState<C> {
pub fn new() -> Self {
Self {
search_query: String::new(),
search_cursor_pos: 0,
selected_category: C::default(),
category_scroll: 0,
hotkey_scroll: 0,
selected_hotkey_idx: 0,
focus: HotkeyFocus::CategoryList,
category_click_regions: Vec::new(),
hotkey_click_regions: Vec::new(),
cached_entry_count: 0,
}
}
pub fn next_category(&mut self) {
self.selected_category = self.selected_category.next();
self.hotkey_scroll = 0;
self.selected_hotkey_idx = 0;
}
pub fn prev_category(&mut self) {
self.selected_category = self.selected_category.prev();
self.hotkey_scroll = 0;
self.selected_hotkey_idx = 0;
}
pub fn next_hotkey(&mut self) {
if self.cached_entry_count > 0 {
self.selected_hotkey_idx =
(self.selected_hotkey_idx + 1).min(self.cached_entry_count - 1);
}
}
pub fn prev_hotkey(&mut self) {
if self.selected_hotkey_idx > 0 {
self.selected_hotkey_idx -= 1;
}
}
pub fn page_down(&mut self) {
for _ in 0..10 {
self.next_hotkey();
}
}
pub fn page_up(&mut self) {
for _ in 0..10 {
self.prev_hotkey();
}
}
pub fn scroll_hotkeys_down(&mut self, amount: usize) {
let max_scroll = self.cached_entry_count.saturating_sub(1);
self.hotkey_scroll = (self.hotkey_scroll + amount).min(max_scroll);
}
pub fn scroll_hotkeys_up(&mut self, amount: usize) {
self.hotkey_scroll = self.hotkey_scroll.saturating_sub(amount);
}
pub fn focus_next(&mut self) {
self.focus = self.focus.next();
}
pub fn focus_prev(&mut self) {
self.focus = self.focus.prev();
}
pub fn is_searching(&self) -> bool {
!self.search_query.is_empty()
}
pub fn get_current_entries<P: HotkeyProvider<Category = C>>(
&self,
provider: &P,
) -> Vec<HotkeyEntryData> {
if self.is_searching() {
provider
.search(&self.search_query)
.into_iter()
.map(|(_, entry)| entry)
.collect()
} else {
provider.entries_for_category(self.selected_category)
}
}
pub fn get_search_results<P: HotkeyProvider<Category = C>>(
&self,
provider: &P,
) -> Vec<(C, HotkeyEntryData)> {
if self.search_query.is_empty() {
return vec![];
}
provider.search(&self.search_query)
}
pub fn get_selected_entry<P: HotkeyProvider<Category = C>>(
&self,
provider: &P,
) -> Option<HotkeyEntryData> {
let entries = self.get_current_entries(provider);
entries.get(self.selected_hotkey_idx).cloned()
}
pub fn update_entry_count(&mut self, count: usize) {
self.cached_entry_count = count;
}
pub fn insert_char(&mut self, c: char) {
let byte_pos = self.char_to_byte_index(self.search_cursor_pos);
self.search_query.insert(byte_pos, c);
self.search_cursor_pos += 1;
self.hotkey_scroll = 0;
self.selected_hotkey_idx = 0;
}
pub fn delete_char_backward(&mut self) -> bool {
if self.search_cursor_pos == 0 {
return false;
}
self.search_cursor_pos -= 1;
let byte_pos = self.char_to_byte_index(self.search_cursor_pos);
if let Some(c) = self.search_query[byte_pos..].chars().next() {
self.search_query
.replace_range(byte_pos..byte_pos + c.len_utf8(), "");
self.selected_hotkey_idx = 0;
return true;
}
false
}
pub fn delete_char_forward(&mut self) -> bool {
let byte_pos = self.char_to_byte_index(self.search_cursor_pos);
if byte_pos < self.search_query.len() {
if let Some(c) = self.search_query[byte_pos..].chars().next() {
self.search_query
.replace_range(byte_pos..byte_pos + c.len_utf8(), "");
self.selected_hotkey_idx = 0;
return true;
}
}
false
}
pub fn move_cursor_left(&mut self) {
if self.search_cursor_pos > 0 {
self.search_cursor_pos -= 1;
}
}
pub fn move_cursor_right(&mut self) {
let max = self.search_query.chars().count();
if self.search_cursor_pos < max {
self.search_cursor_pos += 1;
}
}
pub fn move_cursor_home(&mut self) {
self.search_cursor_pos = 0;
}
pub fn move_cursor_end(&mut self) {
self.search_cursor_pos = self.search_query.chars().count();
}
pub fn clear_search(&mut self) {
self.search_query.clear();
self.search_cursor_pos = 0;
self.hotkey_scroll = 0;
self.selected_hotkey_idx = 0;
}
fn char_to_byte_index(&self, char_idx: usize) -> usize {
self.search_query
.char_indices()
.nth(char_idx)
.map(|(i, _)| i)
.unwrap_or(self.search_query.len())
}
pub fn text_before_cursor(&self) -> &str {
let byte_pos = self.char_to_byte_index(self.search_cursor_pos);
&self.search_query[..byte_pos]
}
pub fn text_after_cursor(&self) -> &str {
let byte_pos = self.char_to_byte_index(self.search_cursor_pos);
&self.search_query[byte_pos..]
}
pub fn clear_click_regions(&mut self) {
self.category_click_regions.clear();
self.hotkey_click_regions.clear();
}
pub fn add_category_click_region(&mut self, area: Rect, category: C) {
self.category_click_regions
.push(CategoryClickRegion { area, category });
}
pub fn add_hotkey_click_region(&mut self, area: Rect, index: usize) {
self.hotkey_click_regions
.push(HotkeyClickRegion { area, index });
}
pub fn handle_click(&mut self, col: u16, row: u16) -> bool {
for region in &self.category_click_regions {
if col >= region.area.x
&& col < region.area.x + region.area.width
&& row >= region.area.y
&& row < region.area.y + region.area.height
{
self.selected_category = region.category;
self.hotkey_scroll = 0;
self.selected_hotkey_idx = 0;
self.focus = HotkeyFocus::CategoryList;
return true;
}
}
for region in &self.hotkey_click_regions {
if col >= region.area.x
&& col < region.area.x + region.area.width
&& row >= region.area.y
&& row < region.area.y + region.area.height
{
self.selected_hotkey_idx = region.index;
self.focus = HotkeyFocus::HotkeyList;
return true;
}
}
false
}
pub fn ensure_hotkey_visible(&mut self, visible_height: usize) {
if self.selected_hotkey_idx < self.hotkey_scroll {
self.hotkey_scroll = self.selected_hotkey_idx;
} else if self.selected_hotkey_idx >= self.hotkey_scroll + visible_height {
self.hotkey_scroll = self.selected_hotkey_idx - visible_height + 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
enum TestCategory {
#[default]
First,
Second,
}
impl HotkeyCategory for TestCategory {
fn all() -> &'static [Self] {
&[Self::First, Self::Second]
}
fn display_name(&self) -> &str {
match self {
Self::First => "First",
Self::Second => "Second",
}
}
fn next(&self) -> Self {
match self {
Self::First => Self::Second,
Self::Second => Self::First,
}
}
fn prev(&self) -> Self {
match self {
Self::First => Self::Second,
Self::Second => Self::First,
}
}
}
#[test]
fn test_new_state() {
let state: HotkeyDialogState<TestCategory> = HotkeyDialogState::new();
assert!(state.search_query.is_empty());
assert_eq!(state.focus, HotkeyFocus::CategoryList);
assert_eq!(state.selected_category, TestCategory::First);
}
#[test]
fn test_category_navigation() {
let mut state: HotkeyDialogState<TestCategory> = HotkeyDialogState::new();
assert_eq!(state.selected_category, TestCategory::First);
state.next_category();
assert_eq!(state.selected_category, TestCategory::Second);
state.prev_category();
assert_eq!(state.selected_category, TestCategory::First);
}
#[test]
fn test_focus_cycling() {
let mut state: HotkeyDialogState<TestCategory> = HotkeyDialogState::new();
state.focus = HotkeyFocus::CategoryList;
state.focus_next();
assert_eq!(state.focus, HotkeyFocus::HotkeyList);
state.focus_next();
assert_eq!(state.focus, HotkeyFocus::SearchInput);
state.focus_next();
assert_eq!(state.focus, HotkeyFocus::CategoryList);
}
#[test]
fn test_search_input() {
let mut state: HotkeyDialogState<TestCategory> = HotkeyDialogState::new();
state.insert_char('c');
state.insert_char('t');
state.insert_char('r');
state.insert_char('l');
assert_eq!(state.search_query, "ctrl");
assert_eq!(state.search_cursor_pos, 4);
state.delete_char_backward();
assert_eq!(state.search_query, "ctr");
assert_eq!(state.search_cursor_pos, 3);
}
#[test]
fn test_cursor_movement() {
let mut state: HotkeyDialogState<TestCategory> = HotkeyDialogState::new();
state.search_query = "test".to_string();
state.search_cursor_pos = 4;
state.move_cursor_left();
assert_eq!(state.search_cursor_pos, 3);
state.move_cursor_home();
assert_eq!(state.search_cursor_pos, 0);
state.move_cursor_end();
assert_eq!(state.search_cursor_pos, 4);
}
#[test]
fn test_is_searching() {
let mut state: HotkeyDialogState<TestCategory> = HotkeyDialogState::new();
assert!(!state.is_searching());
state.insert_char('a');
assert!(state.is_searching());
state.clear_search();
assert!(!state.is_searching());
}
}