use crossterm::event::{KeyCode, KeyEvent};
use crate::tui::action::{EditTarget, FetchReq};
use crate::tui::panes::PaneOutcome;
pub fn catalog() -> &'static [&'static str] {
seer_core::all_tlds()
}
pub struct TldState {
pub filter: String,
pub sel: usize,
}
impl Default for TldState {
fn default() -> Self {
let sel = catalog().iter().position(|&t| t == "com").unwrap_or(0);
Self {
filter: String::new(),
sel,
}
}
}
impl TldState {
pub fn filtered(&self) -> Vec<&'static str> {
filter_catalog(&self.filter)
}
pub fn current(&self) -> String {
let f = self.filtered();
if f.is_empty() {
return String::new();
}
let i = self.sel.min(f.len() - 1);
format!(".{}", f[i])
}
pub fn set_filter(&mut self, filter: String) {
self.filter = filter.trim().trim_start_matches('.').to_lowercase();
let len = self.filtered().len();
if len == 0 {
self.sel = 0;
} else if self.sel >= len {
self.sel = len - 1;
}
}
pub fn select(&mut self, tld: &str) -> bool {
let want = tld.trim().trim_start_matches('.').to_lowercase();
if let Some(i) = catalog().iter().position(|&t| t == want) {
self.filter.clear();
self.sel = i;
true
} else {
false
}
}
fn move_sel(&mut self, delta: isize) {
let len = self.filtered().len();
if len == 0 {
return;
}
let cur = self.sel.min(len - 1) as isize;
self.sel = (cur + delta).clamp(0, len as isize - 1) as usize;
}
pub fn handle_key(&mut self, key: KeyEvent) -> Option<PaneOutcome> {
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
self.move_sel(1);
Some(PaneOutcome::None)
}
KeyCode::Char('k') | KeyCode::Up => {
self.move_sel(-1);
Some(PaneOutcome::None)
}
KeyCode::Char('g') | KeyCode::Home => {
self.sel = 0;
Some(PaneOutcome::None)
}
KeyCode::Char('G') | KeyCode::End => {
let len = self.filtered().len();
self.sel = len.saturating_sub(1);
Some(PaneOutcome::None)
}
KeyCode::Char('/') | KeyCode::Char('f') => {
Some(PaneOutcome::EditField(EditTarget::TldFilter))
}
KeyCode::Char('l') | KeyCode::Enter => {
let cur = self.current();
if cur.is_empty() {
Some(PaneOutcome::None)
} else {
Some(PaneOutcome::Fetch(FetchReq::Tld(cur)))
}
}
_ => None,
}
}
}
pub fn filter_catalog(filter: &str) -> Vec<&'static str> {
let needle = filter.trim().trim_start_matches('.').to_lowercase();
if needle.is_empty() {
return catalog().to_vec();
}
catalog()
.iter()
.filter(|t| t.contains(&needle))
.copied()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyModifiers;
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn catalog_is_large() {
assert!(
catalog().len() > 1000,
"catalog should expose the full TLD list, got {}",
catalog().len()
);
}
#[test]
fn default_starts_on_com() {
let state = TldState::default();
assert_eq!(state.current(), ".com");
}
#[test]
fn j_and_k_move_selection() {
let mut state = TldState::default();
let start = state.sel;
let out = state.handle_key(press(KeyCode::Char('j')));
assert_eq!(state.sel, start + 1);
assert!(matches!(out, Some(PaneOutcome::None)));
state.handle_key(press(KeyCode::Char('k')));
assert_eq!(state.sel, start);
}
#[test]
fn arrows_also_move_selection() {
let mut state = TldState::default();
let start = state.sel;
state.handle_key(press(KeyCode::Down));
assert_eq!(state.sel, start + 1);
state.handle_key(press(KeyCode::Up));
assert_eq!(state.sel, start);
}
#[test]
fn g_jumps_to_top_and_bottom() {
let mut state = TldState::default();
state.handle_key(press(KeyCode::Char('G')));
assert_eq!(state.sel, state.filtered().len() - 1);
state.handle_key(press(KeyCode::Char('g')));
assert_eq!(state.sel, 0);
}
#[test]
fn selection_clamps_to_filtered_list() {
let mut state = TldState::default();
state.handle_key(press(KeyCode::Char('G'))); state.set_filter("com".into());
let len = state.filtered().len();
assert!(
state.sel < len,
"selection must stay in range after filtering"
);
}
#[test]
fn filter_narrows_the_list() {
let mut state = TldState::default();
let all = state.filtered().len();
state.set_filter("shop".into());
let narrowed = state.filtered();
assert!(narrowed.len() < all, "filter should reduce the list");
assert!(
narrowed.iter().all(|t| t.contains("shop")),
"all matches must contain the needle"
);
}
#[test]
fn slash_and_f_open_the_filter_field() {
let mut state = TldState::default();
assert!(matches!(
state.handle_key(press(KeyCode::Char('/'))),
Some(PaneOutcome::EditField(EditTarget::TldFilter))
));
assert!(matches!(
state.handle_key(press(KeyCode::Char('f'))),
Some(PaneOutcome::EditField(EditTarget::TldFilter))
));
}
#[test]
fn enter_fetches_selected_tld() {
let mut state = TldState::default();
let out = state.handle_key(press(KeyCode::Enter));
assert!(matches!(
out,
Some(PaneOutcome::Fetch(FetchReq::Tld(ref s))) if s == ".com"
));
}
#[test]
fn enter_with_no_matches_is_noop() {
let mut state = TldState::default();
state.set_filter("zzzznotatld".into());
assert_eq!(state.filtered().len(), 0);
let out = state.handle_key(press(KeyCode::Enter));
assert!(matches!(out, Some(PaneOutcome::None)));
}
#[test]
fn select_known_tld_clears_filter_and_points_at_it() {
let mut state = TldState::default();
state.set_filter("io".into());
assert!(state.select(".io"));
assert!(state.filter.is_empty(), "select should clear the filter");
assert_eq!(state.current(), ".io");
}
#[test]
fn select_unknown_tld_returns_false() {
let mut state = TldState::default();
assert!(!state.select(".zzzznotatld"));
}
#[test]
fn unowned_keys_return_none() {
let mut state = TldState::default();
assert!(state.handle_key(press(KeyCode::Esc)).is_none());
assert!(state.handle_key(press(KeyCode::Tab)).is_none());
assert!(state.handle_key(press(KeyCode::Char('['))).is_none());
assert!(state.handle_key(press(KeyCode::Char(']'))).is_none());
}
}