use std::{
borrow::Cow,
time::{Duration, Instant},
};
use nucleo_matcher::{
Config, Matcher,
pattern::{CaseMatching, Normalization, Pattern},
};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Rect},
style::Style,
widgets::{Block, List, ListItem, Paragraph},
};
use tui_textarea::{Input, Key, TextArea};
use crate::{
app_state::{App, AppEvent, AppFocus, IconifyCollectionListItem, IconifySearchPayload},
iconify::IconifyClient,
scroll,
utils::popup_area,
};
const SEARCH_DEBOUNCE_MS: u64 = 280;
const SEARCH_LIMIT: u32 = 80;
#[derive(Debug, Clone)]
struct FuzzyCandidate<'a> {
index: usize,
haystack: Cow<'a, str>,
}
impl AsRef<str> for FuzzyCandidate<'_> {
fn as_ref(&self) -> &str {
&self.haystack
}
}
fn fuzzy_rank_indices(query: &str, candidates: Vec<FuzzyCandidate<'_>>) -> Vec<usize> {
if query.trim().is_empty() {
return candidates
.into_iter()
.map(|candidate| candidate.index)
.collect();
}
let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
let mut matcher = Matcher::new(Config::DEFAULT);
let mut matched = pattern.match_list(candidates, &mut matcher);
matched.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.index.cmp(&b.0.index)));
matched
.into_iter()
.map(|(candidate, _)| candidate.index)
.collect()
}
fn fuzzy_filter_collections(
collections: &[IconifyCollectionListItem],
query: &str,
) -> Vec<IconifyCollectionListItem> {
let candidates = collections
.iter()
.enumerate()
.map(|(index, item)| FuzzyCandidate {
index,
haystack: Cow::Owned(format!("{} {}", item.prefix, item.name)),
})
.collect::<Vec<_>>();
fuzzy_rank_indices(query, candidates)
.into_iter()
.map(|index| collections[index].clone())
.collect()
}
fn fuzzy_filter_icons(icons: &[String], query: &str) -> Vec<String> {
let candidates = icons
.iter()
.enumerate()
.map(|(index, icon)| FuzzyCandidate {
index,
haystack: Cow::Borrowed(icon.as_str()),
})
.collect::<Vec<_>>();
fuzzy_rank_indices(query, candidates)
.into_iter()
.map(|index| icons[index].clone())
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IconifySearchTab {
Collections,
Icons,
}
#[derive(Debug)]
pub struct IconifySearchPopupState {
pub search_textarea: TextArea<'static>,
pub search_value: String,
pub active_tab: IconifySearchTab,
pub selected_collection_index: usize,
pub selected_icon_index: usize,
pub collections_scroll_offset: usize,
pub icons_scroll_offset: usize,
pub collections_list_area: Option<Rect>,
pub icons_list_area: Option<Rect>,
pub all_collections: Vec<IconifyCollectionListItem>,
pub filtered_collections: Vec<IconifyCollectionListItem>,
pub search_icons: Vec<String>,
pub collection_icons: Vec<String>,
pub collection_icons_prefix: Option<String>,
pub visible_icons: Vec<String>,
pub selected_collection_filter: Option<String>,
pub pending_search_query: Option<String>,
pub debounce_deadline: Option<Instant>,
pub latest_collections_request_id: u64,
pub latest_search_request_id: u64,
pub latest_collection_icons_request_id: u64,
pub is_loading_collections: bool,
pub is_loading_search: bool,
pub is_loading_collection_icons: bool,
pub status_message: Option<String>,
pub status_is_error: bool,
}
impl IconifySearchPopupState {
pub fn new() -> Self {
Self {
search_textarea: TextArea::default(),
search_value: String::new(),
active_tab: IconifySearchTab::Collections,
selected_collection_index: 0,
selected_icon_index: 0,
collections_scroll_offset: 0,
icons_scroll_offset: 0,
collections_list_area: None,
icons_list_area: None,
all_collections: Vec::new(),
filtered_collections: Vec::new(),
search_icons: Vec::new(),
collection_icons: Vec::new(),
collection_icons_prefix: None,
visible_icons: Vec::new(),
selected_collection_filter: None,
pending_search_query: None,
debounce_deadline: None,
latest_collections_request_id: 0,
latest_search_request_id: 0,
latest_collection_icons_request_id: 0,
is_loading_collections: false,
is_loading_search: false,
is_loading_collection_icons: false,
status_message: None,
status_is_error: false,
}
}
fn active_collections(&self) -> &[IconifyCollectionListItem] {
if self.search_value.trim().is_empty() {
&self.all_collections
} else {
&self.filtered_collections
}
}
fn refresh_filtered_collections(&mut self) {
let query = self.search_value.trim();
if query.is_empty() {
self.filtered_collections.clear();
return;
}
self.filtered_collections = fuzzy_filter_collections(&self.all_collections, query);
}
fn sync_search_dispatch_state(&mut self) {
self.pending_search_query = None;
self.debounce_deadline = None;
let query = self.search_value.trim().to_string();
if query.is_empty()
|| self.active_tab != IconifySearchTab::Icons
|| self.selected_collection_filter.is_some()
{
self.is_loading_search = false;
return;
}
self.pending_search_query = Some(query);
self.debounce_deadline = Some(Instant::now() + Duration::from_millis(SEARCH_DEBOUNCE_MS));
self.is_loading_search = true;
}
fn clear_search_input(&mut self) {
self.search_textarea = TextArea::default();
self.search_value.clear();
self.filtered_collections.clear();
self.search_icons.clear();
self.selected_icon_index = 0;
self.sync_search_dispatch_state();
self.clear_status();
}
fn selected_collection_prefix(&self) -> Option<String> {
self.active_collections()
.get(self.selected_collection_index)
.map(|item| item.prefix.clone())
}
fn selected_icon_name(&self) -> Option<String> {
self.visible_icons.get(self.selected_icon_index).cloned()
}
fn set_status(&mut self, message: String, is_error: bool) {
self.status_message = Some(message);
self.status_is_error = is_error;
}
fn clear_status(&mut self) {
self.status_message = None;
self.status_is_error = false;
}
fn clamp_collection_selection(&mut self) {
let len = self.active_collections().len();
if len == 0 {
self.selected_collection_index = 0;
} else if self.selected_collection_index >= len {
self.selected_collection_index = len.saturating_sub(1);
}
}
fn clamp_icon_selection(&mut self) {
let len = self.visible_icons.len();
if len == 0 {
self.selected_icon_index = 0;
} else if self.selected_icon_index >= len {
self.selected_icon_index = len.saturating_sub(1);
}
}
fn update_search_value(&mut self) {
self.search_value = self.search_textarea.lines().join("");
self.clear_status();
self.selected_collection_index = 0;
self.selected_icon_index = 0;
self.refresh_filtered_collections();
let query = self.search_value.trim().to_string();
if query.is_empty() {
self.filtered_collections.clear();
self.search_icons.clear();
self.refresh_visible_icons();
self.sync_search_dispatch_state();
return;
}
self.search_icons.clear();
self.refresh_visible_icons();
self.sync_search_dispatch_state();
}
fn refresh_visible_icons(&mut self) {
if let Some(prefix) = &self.selected_collection_filter {
if self.collection_icons_prefix.as_deref() != Some(prefix.as_str()) {
self.visible_icons.clear();
self.clamp_icon_selection();
return;
}
let query = self.search_value.trim();
if query.is_empty() {
self.visible_icons = self.collection_icons.clone();
} else {
self.visible_icons = fuzzy_filter_icons(&self.collection_icons, query);
}
} else {
self.visible_icons = self.search_icons.clone();
}
self.clamp_icon_selection();
}
fn move_collection_selection(&mut self, delta: i32) {
let len = self.active_collections().len();
if len == 0 {
self.selected_collection_index = 0;
self.collections_scroll_offset = 0;
return;
}
let current = self.selected_collection_index as i32;
let next = (current + delta).rem_euclid(len as i32) as usize;
self.selected_collection_index = next;
let height = self
.collections_list_area
.map(|area| area.height as usize)
.unwrap_or(0);
scroll::ensure_visible(
self.selected_collection_index,
&mut self.collections_scroll_offset,
height,
);
}
fn move_icon_selection(&mut self, delta: i32) {
let len = self.visible_icons.len();
if len == 0 {
self.selected_icon_index = 0;
self.icons_scroll_offset = 0;
return;
}
let current = self.selected_icon_index as i32;
let next = (current + delta).rem_euclid(len as i32) as usize;
self.selected_icon_index = next;
let height = self
.icons_list_area
.map(|area| area.height as usize)
.unwrap_or(0);
scroll::ensure_visible(
self.selected_icon_index,
&mut self.icons_scroll_offset,
height,
);
}
}
enum PopupAction {
None,
Close,
OpenCollection(String),
FillAddPopup(String),
OpenIconInBrowser(String),
}
impl App {
fn next_request_id(&mut self) -> u64 {
self.next_async_request_id = self.next_async_request_id.saturating_add(1);
self.next_async_request_id
}
pub fn init_iconify_search_popup(&mut self) {
self.app_focus = AppFocus::IconifySearchPopup;
self.iconify_search_popup_state = Some(IconifySearchPopupState::new());
self.request_iconify_collections();
}
fn close_iconify_search_popup(&mut self) {
self.app_focus = AppFocus::Main;
self.iconify_search_popup_state = None;
}
pub fn handlekeys_iconify_search_popup(&mut self, input: Input) {
let mut action = PopupAction::None;
if let Some(state) = self.iconify_search_popup_state.as_mut() {
match input.key {
Key::Esc => action = PopupAction::Close,
Key::Tab => {
match state.active_tab {
IconifySearchTab::Collections => {
state.active_tab = IconifySearchTab::Icons;
}
IconifySearchTab::Icons => {
state.selected_collection_filter = None;
state.clear_search_input();
state.active_tab = IconifySearchTab::Collections;
state.is_loading_collection_icons = false;
state.refresh_visible_icons();
}
}
state.sync_search_dispatch_state();
}
Key::Up => match state.active_tab {
IconifySearchTab::Collections => state.move_collection_selection(-1),
IconifySearchTab::Icons => state.move_icon_selection(-1),
},
Key::Down => match state.active_tab {
IconifySearchTab::Collections => state.move_collection_selection(1),
IconifySearchTab::Icons => state.move_icon_selection(1),
},
Key::Enter => match state.active_tab {
IconifySearchTab::Collections => {
if let Some(prefix) = state.selected_collection_prefix() {
state.clear_search_input();
action = PopupAction::OpenCollection(prefix);
}
}
IconifySearchTab::Icons => {
if let Some(icon_name) = state.selected_icon_name() {
action = PopupAction::FillAddPopup(icon_name);
} else {
state.set_status("No icon selected.".to_string(), true);
}
}
},
Key::Char('o') if input.ctrl => {
if state.active_tab == IconifySearchTab::Icons {
if let Some(icon_name) = state.selected_icon_name() {
action = PopupAction::OpenIconInBrowser(icon_name);
} else {
state.set_status("No icon selected.".to_string(), true);
}
}
}
_ => {
state.search_textarea.input(input);
state.update_search_value();
}
}
}
match action {
PopupAction::None => {}
PopupAction::Close => self.close_iconify_search_popup(),
PopupAction::OpenCollection(prefix) => self.open_collection_icons(prefix),
PopupAction::FillAddPopup(icon_name) => {
self.close_iconify_search_popup();
self.init_add_popup_with_icon_source(&icon_name);
}
PopupAction::OpenIconInBrowser(icon_name) => {
self.open_icon_browser_preview(icon_name);
}
}
}
pub fn handle_mouse_iconify_search_popup(
&mut self,
mouse: ratatui::crossterm::event::MouseEvent,
) {
use ratatui::crossterm::event::{MouseButton, MouseEventKind};
let Some(state) = self.iconify_search_popup_state.as_mut() else {
return;
};
match state.active_tab {
IconifySearchTab::Collections => {
let Some(area) = state.collections_list_area else {
return;
};
let len = state.active_collections().len();
let height = area.height as usize;
match mouse.kind {
MouseEventKind::ScrollUp => scroll::scroll_viewport(
&mut state.collections_scroll_offset,
-3,
len,
height,
),
MouseEventKind::ScrollDown => scroll::scroll_viewport(
&mut state.collections_scroll_offset,
3,
len,
height,
),
MouseEventKind::Down(MouseButton::Left) => {
if mouse.row >= area.y
&& mouse.row < area.y + area.height
&& mouse.column >= area.x
&& mouse.column < area.x + area.width
{
let row = (mouse.row - area.y) as usize;
let clicked = state.collections_scroll_offset + row;
if clicked < len {
state.selected_collection_index = clicked;
}
}
}
_ => {}
}
}
IconifySearchTab::Icons => {
let Some(area) = state.icons_list_area else {
return;
};
let len = state.visible_icons.len();
let height = area.height as usize;
match mouse.kind {
MouseEventKind::ScrollUp => {
scroll::scroll_viewport(&mut state.icons_scroll_offset, -3, len, height)
}
MouseEventKind::ScrollDown => {
scroll::scroll_viewport(&mut state.icons_scroll_offset, 3, len, height)
}
MouseEventKind::Down(MouseButton::Left) => {
if mouse.row >= area.y
&& mouse.row < area.y + area.height
&& mouse.column >= area.x
&& mouse.column < area.x + area.width
{
let row = (mouse.row - area.y) as usize;
let clicked = state.icons_scroll_offset + row;
if clicked < len {
state.selected_icon_index = clicked;
}
}
}
_ => {}
}
}
}
}
pub fn tick_iconify_search_popup(&mut self) {
let query_to_dispatch = self.iconify_search_popup_state.as_ref().and_then(|state| {
if state.active_tab != IconifySearchTab::Icons
|| state.selected_collection_filter.is_some()
{
return None;
}
let deadline = state.debounce_deadline?;
if Instant::now() >= deadline {
state.pending_search_query.clone()
} else {
None
}
});
if let Some(query) = query_to_dispatch {
self.dispatch_iconify_search(query);
}
}
pub fn handle_app_event(&mut self, event: AppEvent) {
match event {
AppEvent::IconifyCollectionsLoaded { request_id, result } => {
if let Some(state) = self.iconify_search_popup_state.as_mut() {
if request_id != state.latest_collections_request_id {
return;
}
state.is_loading_collections = false;
match result {
Ok(items) => {
state.all_collections = items;
state.refresh_filtered_collections();
state.clamp_collection_selection();
if state.all_collections.is_empty() {
state.set_status("No Iconify collections found.".to_string(), true);
} else if !state.is_loading_search {
state.clear_status();
}
}
Err(error) => state.set_status(error, true),
}
}
}
AppEvent::IconifySearchLoaded {
request_id,
query,
result,
} => {
if let Some(state) = self.iconify_search_popup_state.as_mut() {
if request_id != state.latest_search_request_id {
return;
}
if query != state.search_value.trim() {
return;
}
if state.active_tab != IconifySearchTab::Icons
|| state.selected_collection_filter.is_some()
{
state.is_loading_search = false;
return;
}
state.is_loading_search = false;
match result {
Ok(payload) => {
state.search_icons = payload.icons;
state.clamp_collection_selection();
state.refresh_visible_icons();
if state.search_icons.is_empty()
&& state.active_collections().is_empty()
{
state.set_status(
"No matching icons or collections.".to_string(),
false,
);
} else {
state.clear_status();
}
}
Err(error) => {
state.search_icons.clear();
state.refresh_visible_icons();
state.set_status(error, true);
}
}
}
}
AppEvent::IconifyCollectionIconsLoaded {
request_id,
prefix,
result,
} => {
if let Some(state) = self.iconify_search_popup_state.as_mut() {
if request_id != state.latest_collection_icons_request_id {
return;
}
state.is_loading_collection_icons = false;
match result {
Ok(icons) => {
state.collection_icons_prefix = Some(prefix);
state.collection_icons = icons;
state.refresh_visible_icons();
if state.collection_icons.is_empty() {
state.set_status("No icons in this collection.".to_string(), false);
} else if !state.search_value.trim().is_empty()
&& state.visible_icons.is_empty()
{
state.set_status(
"No matching icons in this collection.".to_string(),
false,
);
} else {
state.clear_status();
}
}
Err(error) => {
state.collection_icons.clear();
state.collection_icons_prefix = None;
state.refresh_visible_icons();
state.set_status(error, true);
}
}
}
}
}
}
fn request_iconify_collections(&mut self) {
let request_id = self.next_request_id();
let Some(state) = self.iconify_search_popup_state.as_mut() else {
return;
};
state.latest_collections_request_id = request_id;
state.is_loading_collections = true;
state.set_status("Loading collections...".to_string(), false);
let tx = self.tx.clone();
tokio::spawn(async move {
let result = async {
let client = IconifyClient::from_env().map_err(|error| error.to_string())?;
let response = client
.collections()
.await
.map_err(|error| error.to_string())?;
let mut collections: Vec<IconifyCollectionListItem> = response
.collections
.into_iter()
.map(|(prefix, meta)| IconifyCollectionListItem {
name: meta.display_name(&prefix),
total: meta.total,
prefix,
})
.collect();
collections.sort_by(|a, b| a.prefix.cmp(&b.prefix));
Ok::<Vec<IconifyCollectionListItem>, String>(collections)
}
.await;
let _ = tx.send(AppEvent::IconifyCollectionsLoaded { request_id, result });
});
}
fn dispatch_iconify_search(&mut self, query: String) {
let request_id = self.next_request_id();
let Some(state) = self.iconify_search_popup_state.as_mut() else {
return;
};
if state.active_tab != IconifySearchTab::Icons || state.selected_collection_filter.is_some()
{
state.pending_search_query = None;
state.debounce_deadline = None;
state.is_loading_search = false;
return;
}
state.pending_search_query = None;
state.debounce_deadline = None;
state.latest_search_request_id = request_id;
state.is_loading_search = true;
let tx = self.tx.clone();
tokio::spawn(async move {
let result = async {
let client = IconifyClient::from_env().map_err(|error| error.to_string())?;
let response = client
.search(&query, Some(SEARCH_LIMIT), None, false)
.await
.map_err(|error| error.to_string())?;
Ok::<IconifySearchPayload, String>(IconifySearchPayload {
icons: response.icons,
})
}
.await;
let _ = tx.send(AppEvent::IconifySearchLoaded {
request_id,
query,
result,
});
});
}
fn open_collection_icons(&mut self, prefix: String) {
let mut should_fetch = false;
if let Some(state) = self.iconify_search_popup_state.as_mut() {
state.active_tab = IconifySearchTab::Icons;
state.selected_collection_filter = Some(prefix.clone());
state.selected_icon_index = 0;
state.refresh_visible_icons();
state.sync_search_dispatch_state();
should_fetch = state.collection_icons_prefix.as_deref() != Some(prefix.as_str());
if should_fetch {
state.collection_icons.clear();
state.refresh_visible_icons();
}
}
if !should_fetch {
return;
}
let request_id = self.next_request_id();
let Some(state) = self.iconify_search_popup_state.as_mut() else {
return;
};
state.latest_collection_icons_request_id = request_id;
state.is_loading_collection_icons = true;
state.set_status(format!("Loading icons for collection '{prefix}'..."), false);
let tx = self.tx.clone();
tokio::spawn(async move {
let result = async {
let client = IconifyClient::from_env().map_err(|error| error.to_string())?;
let response = client
.collection(&prefix)
.await
.map_err(|error| error.to_string())?;
let icons = response
.icons
.into_iter()
.map(|icon| format!("{}:{icon}", response.prefix))
.collect::<Vec<_>>();
Ok::<Vec<String>, String>(icons)
}
.await;
let _ = tx.send(AppEvent::IconifyCollectionIconsLoaded {
request_id,
prefix,
result,
});
});
}
fn open_icon_browser_preview(&mut self, icon_name: String) {
let Some(url) = icones_collection_url(&icon_name) else {
if let Some(state) = self.iconify_search_popup_state.as_mut() {
state.set_status(
format!("Cannot open Icones page for invalid icon name '{icon_name}'."),
true,
);
}
return;
};
if let Some(state) = self.iconify_search_popup_state.as_mut() {
state.set_status("Opening icon in browser...".to_string(), false);
}
match crate::viewer::open_url_in_browser(&url) {
Ok(()) => {
if let Some(state) = self.iconify_search_popup_state.as_mut() {
state.set_status(format!("Opened Icones page: {url}"), false);
}
}
Err(error) => {
if let Some(state) = self.iconify_search_popup_state.as_mut() {
state.set_status(format!("Failed to open browser: {error}"), true);
}
}
}
}
}
fn icones_collection_url(icon_name: &str) -> Option<String> {
let (prefix, _) = icon_name.split_once(':')?;
Some(format!(
"https://icones.js.org/collection/{prefix}?icon={icon_name}"
))
}
pub fn render_iconify_search_popup(f: &mut Frame, app: &mut App) {
use ratatui::{
style::Modifier,
text::{Line, Span},
};
let area = popup_area(f.area(), 92, 23);
let body_area = crate::views::theme::render_popup_shell(f, area, "Iconify Search");
let inner = ratatui::layout::Layout::default()
.direction(ratatui::layout::Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
Constraint::Length(1),
])
.split(body_area);
let Some(state) = app.iconify_search_popup_state.as_mut() else {
return;
};
let search_block = Block::default()
.title("Search")
.title_style(
Style::default()
.fg(crate::views::theme::MUTED_TEXT)
.add_modifier(Modifier::BOLD),
)
.style(Style::default().fg(crate::views::theme::TEXT));
state.search_textarea.set_block(search_block);
state.search_textarea.set_cursor_style(
Style::default()
.bg(crate::views::theme::ACCENT)
.fg(crate::views::theme::BASE_BG),
);
state
.search_textarea
.set_cursor_line_style(Style::default());
f.render_widget(&state.search_textarea, inner[0]);
let collections_style = if state.active_tab == IconifySearchTab::Collections {
Style::default()
.bg(crate::views::theme::TAB_BG_ACTIVE)
.fg(crate::views::theme::BASE_BG)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.bg(crate::views::theme::TAB_BG)
.fg(crate::views::theme::MUTED_TEXT)
};
let icons_style = if state.active_tab == IconifySearchTab::Icons {
Style::default()
.bg(crate::views::theme::TAB_BG_ACTIVE)
.fg(crate::views::theme::BASE_BG)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.bg(crate::views::theme::TAB_BG)
.fg(crate::views::theme::MUTED_TEXT)
};
let mut tabs_spans = vec![
Span::styled(" Collections ", collections_style),
Span::styled(" | ", Style::default().fg(crate::views::theme::SUBTLE_TEXT)),
Span::styled(" Icons ", icons_style),
];
if let Some(prefix) = &state.selected_collection_filter {
tabs_spans.push(Span::raw(" "));
tabs_spans.push(Span::styled(
format!("collection: {prefix}"),
Style::default().fg(crate::views::theme::SUBTLE_TEXT),
));
}
let tabs = Paragraph::new(Line::from(tabs_spans)).alignment(Alignment::Left);
f.render_widget(tabs, inner[1]);
let list_area = inner[3];
let list_rows_area = Rect {
x: list_area.x,
y: list_area.y.saturating_add(1),
width: list_area.width,
height: list_area.height.saturating_sub(1),
};
let list_visible_height = list_rows_area.height as usize;
match state.active_tab {
IconifySearchTab::Collections => {
let (items, col_len, col_is_empty): (Vec<ListItem>, usize, bool) = {
let collection_items = state.active_collections();
let col_len = collection_items.len();
let col_is_empty = collection_items.is_empty();
let items = if col_is_empty {
vec![ListItem::new(Line::from(Span::styled(
"No collections",
Style::default().fg(crate::views::theme::SUBTLE_TEXT),
)))]
} else {
collection_items
.iter()
.map(|item| {
let total_label = match item.total {
Some(total) => format!("{total}"),
None => "-".to_string(),
};
let line = Line::from(vec![
Span::styled(
format!("{: <10}", item.prefix),
Style::default()
.fg(crate::views::theme::ACCENT_SOFT)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {total_label: <6}"),
Style::default().fg(crate::views::theme::MUTED_TEXT),
),
Span::styled(
item.name.clone(),
Style::default().fg(crate::views::theme::TEXT),
),
]);
ListItem::new(line)
})
.collect()
};
(items, col_len, col_is_empty)
};
crate::scroll::clamp_offset(
&mut state.collections_scroll_offset,
col_len,
list_visible_height,
);
crate::scroll::ensure_visible(
state.selected_collection_index,
&mut state.collections_scroll_offset,
list_visible_height,
);
state.collections_list_area = Some(list_rows_area);
let mut list_state = ratatui::widgets::ListState::default();
if !col_is_empty {
*list_state.offset_mut() = state.collections_scroll_offset;
let in_view = list_visible_height > 0
&& state.selected_collection_index >= state.collections_scroll_offset
&& state.selected_collection_index
< state.collections_scroll_offset + list_visible_height;
if in_view {
list_state.select(Some(state.selected_collection_index));
}
}
let list = List::new(items)
.block(
Block::default()
.title("Collections")
.title_style(
Style::default()
.fg(crate::views::theme::MUTED_TEXT)
.add_modifier(Modifier::BOLD),
)
.style(
Style::default()
.bg(crate::views::theme::PANEL_BG)
.fg(crate::views::theme::TEXT),
),
)
.highlight_symbol(" ")
.highlight_style(
Style::default()
.bg(crate::views::theme::ROW_HIGHLIGHT_BG)
.fg(crate::views::theme::BASE_BG)
.add_modifier(Modifier::BOLD),
);
f.render_stateful_widget(list, inner[3], &mut list_state);
}
IconifySearchTab::Icons => {
let items: Vec<ListItem> = if state.visible_icons.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
"No icons",
Style::default().fg(crate::views::theme::SUBTLE_TEXT),
)))]
} else {
state
.visible_icons
.iter()
.map(|icon| {
ListItem::new(Line::from(Span::styled(
icon.clone(),
Style::default().fg(crate::views::theme::TEXT),
)))
})
.collect()
};
let icons_len = state.visible_icons.len();
let icons_is_empty = state.visible_icons.is_empty();
crate::scroll::clamp_offset(
&mut state.icons_scroll_offset,
icons_len,
list_visible_height,
);
crate::scroll::ensure_visible(
state.selected_icon_index,
&mut state.icons_scroll_offset,
list_visible_height,
);
state.icons_list_area = Some(list_rows_area);
let mut list_state = ratatui::widgets::ListState::default();
if !icons_is_empty {
*list_state.offset_mut() = state.icons_scroll_offset;
let in_view = list_visible_height > 0
&& state.selected_icon_index >= state.icons_scroll_offset
&& state.selected_icon_index < state.icons_scroll_offset + list_visible_height;
if in_view {
list_state.select(Some(state.selected_icon_index));
}
}
let title = if let Some(filter_prefix) = &state.selected_collection_filter {
format!("Icons (collection: {filter_prefix})")
} else {
"Icons".to_string()
};
let list = List::new(items)
.block(
Block::default()
.title(title)
.title_style(
Style::default()
.fg(crate::views::theme::MUTED_TEXT)
.add_modifier(Modifier::BOLD),
)
.style(
Style::default()
.bg(crate::views::theme::PANEL_BG)
.fg(crate::views::theme::TEXT),
),
)
.highlight_symbol(" ")
.highlight_style(
Style::default()
.bg(crate::views::theme::ROW_HIGHLIGHT_BG)
.fg(crate::views::theme::BASE_BG)
.add_modifier(Modifier::BOLD),
);
f.render_stateful_widget(list, inner[3], &mut list_state);
}
}
let loading_message = if state.is_loading_collection_icons {
Some("Loading collection icons...")
} else if state.is_loading_search {
Some("Searching Iconify...")
} else if state.is_loading_collections {
Some("Loading collections...")
} else {
None
};
let status_message = loading_message
.map(std::string::ToString::to_string)
.or_else(|| state.status_message.clone())
.unwrap_or_default();
let status_color = if state.status_is_error {
crate::views::theme::ERROR
} else {
crate::views::theme::MUTED_TEXT
};
let status = Paragraph::new(status_message)
.alignment(Alignment::Left)
.style(Style::default().fg(status_color));
f.render_widget(status, inner[4]);
let help_line = if state.active_tab == IconifySearchTab::Collections {
crate::views::theme::shortcut_line(&[
("Open", "enter"),
("Switch", "tab"),
("Move", "up/down"),
("Close", "esc"),
])
} else {
crate::views::theme::shortcut_line(&[
("Use icon", "enter"),
("Preview", "ctrl+o"),
("Move", "up/down"),
("Switch", "tab"),
("Close", "esc"),
])
};
let help = Paragraph::new(help_line).alignment(Alignment::Left);
f.render_widget(help, inner[5]);
}
#[cfg(any())]
mod tests {
use super::{
IconifySearchPopupState, IconifySearchTab, fuzzy_filter_collections, fuzzy_filter_icons,
icones_collection_url,
};
use crate::app_state::{App, AppConfig, AppFocus, IconifyCollectionListItem};
use tempfile::TempDir;
use tui_textarea::{Input, Key};
fn test_app() -> App {
let temp_dir = TempDir::new().expect("temp dir should be created");
let folder = temp_dir.path().join("icons");
let config = AppConfig {
folder: folder.to_string_lossy().into_owned(),
preset: "normal".to_string(),
svg_viewer_cmd: None,
svg_viewer_cmd_source: "test".to_string(),
global_config_loaded: false,
project_config_loaded: false,
flutter_barrel_file: None,
flutter_barrel_class: None,
};
App::new(config)
}
#[test]
fn builds_icones_collection_url_for_iconify_name() {
assert_eq!(
icones_collection_url("mdi:home"),
Some("https://icones.js.org/collection/mdi".to_string())
);
}
#[test]
fn returns_none_for_invalid_icon_name() {
assert_eq!(icones_collection_url("mdi"), None);
assert_eq!(icones_collection_url(""), None);
}
#[test]
fn fuzzy_collections_support_non_substring_queries() {
let items = vec![
IconifyCollectionListItem {
prefix: "lucide".to_string(),
name: "Lucide".to_string(),
total: Some(100),
},
IconifyCollectionListItem {
prefix: "mdi".to_string(),
name: "Material Design Icons".to_string(),
total: Some(200),
},
];
let filtered = fuzzy_filter_collections(&items, "lcd");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].prefix, "lucide");
}
#[test]
fn fuzzy_icon_filter_supports_non_substring_queries() {
let icons = vec![
"arrow-left".to_string(),
"arrow-right".to_string(),
"alert-circle".to_string(),
];
let filtered = fuzzy_filter_icons(&icons, "arwlf");
assert_eq!(filtered, vec!["arrow-left".to_string()]);
}
#[test]
fn j_and_k_type_into_search_input() {
let mut app = test_app();
app.init_iconify_search_popup();
let state = app
.iconify_search_popup_state
.as_mut()
.expect("iconify popup should initialize");
state.active_tab = IconifySearchTab::Icons;
state.visible_icons = vec!["arrow-left".to_string(), "arrow-right".to_string()];
state.selected_icon_index = 1;
app.handlekeys_iconify_search_popup(Input {
key: Key::Char('j'),
..Default::default()
});
let state = app
.iconify_search_popup_state
.as_ref()
.expect("iconify popup should remain open");
assert_eq!(state.search_textarea.lines().join(""), "j");
assert_eq!(state.selected_icon_index, 1);
app.handlekeys_iconify_search_popup(Input {
key: Key::Char('k'),
..Default::default()
});
let state = app
.iconify_search_popup_state
.as_ref()
.expect("iconify popup should remain open");
assert_eq!(state.search_textarea.lines().join(""), "jk");
assert_eq!(state.selected_icon_index, 1);
}
#[test]
fn collection_icon_search_is_local_and_does_not_queue_remote_search() {
let mut app = test_app();
app.init_iconify_search_popup();
let state = app
.iconify_search_popup_state
.as_mut()
.expect("iconify popup should initialize");
state.active_tab = IconifySearchTab::Icons;
state.selected_collection_filter = Some("mdi".to_string());
state.collection_icons = vec![
"home-outline".to_string(),
"account-box".to_string(),
"heart".to_string(),
];
state.visible_icons = state.collection_icons.clone();
app.handlekeys_iconify_search_popup(Input {
key: Key::Char('h'),
..Default::default()
});
let state = app
.iconify_search_popup_state
.as_ref()
.expect("iconify popup should remain open");
assert_eq!(state.search_query, "h");
assert_eq!(state.search_textarea.lines().join(""), "h");
assert!(!state.is_loading_search);
assert_eq!(state.last_search_query, "");
assert!(state.visible_icons.contains(&"home-outline".to_string()));
}
#[test]
fn global_icon_search_keeps_remote_query_flow() {
let mut app = test_app();
app.init_iconify_search_popup();
let state = app
.iconify_search_popup_state
.as_mut()
.expect("iconify popup should initialize");
state.active_tab = IconifySearchTab::Icons;
state.selected_collection_filter = None;
state.visible_icons.clear();
app.handlekeys_iconify_search_popup(Input {
key: Key::Char('m'),
..Default::default()
});
let state = app
.iconify_search_popup_state
.as_ref()
.expect("iconify popup should remain open");
assert_eq!(state.search_query, "m");
assert!(state.is_loading_search);
assert_eq!(state.last_search_query, "m");
}
#[test]
fn iconify_search_escape_closes_popup() {
let mut app = test_app();
app.init_iconify_search_popup();
app.handlekeys_iconify_search_popup(Input {
key: Key::Esc,
..Default::default()
});
assert!(app.iconify_search_popup_state.is_none());
assert!(matches!(app.app_focus, AppFocus::Main));
}
}
#[cfg(test)]
mod tests {
use super::{
IconifySearchPopupState, IconifySearchTab, fuzzy_filter_collections, fuzzy_filter_icons,
icones_collection_url,
};
use crate::app_state::{App, AppConfig, AppFocus, IconifyCollectionListItem};
use tempfile::TempDir;
use tui_textarea::{Input, Key};
fn test_app() -> App {
let temp_dir = TempDir::new().expect("temp dir should be created");
let folder = temp_dir.path().join("icons");
let config = AppConfig {
folder: folder.to_string_lossy().into_owned(),
preset: "normal".to_string(),
svg_viewer_cmd: None,
svg_viewer_cmd_source: "test".to_string(),
global_config_loaded: false,
project_config_loaded: false,
flutter_barrel_file: None,
flutter_barrel_class: None,
};
App::new(config)
}
#[test]
fn builds_icones_collection_url_for_iconify_name() {
assert_eq!(
icones_collection_url("lucide:bean"),
Some("https://icones.js.org/collection/lucide?icon=lucide:bean".to_string())
);
}
#[test]
fn returns_none_for_invalid_icon_name() {
assert_eq!(icones_collection_url("bean"), None);
}
#[test]
fn fuzzy_collections_support_non_substring_queries() {
let collections = vec![
IconifyCollectionListItem {
prefix: "lucide".to_string(),
name: "Lucide Icons".to_string(),
total: Some(100),
},
IconifyCollectionListItem {
prefix: "mdi".to_string(),
name: "Material Design Icons".to_string(),
total: Some(100),
},
];
let filtered = fuzzy_filter_collections(&collections, "lcd");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].prefix, "lucide");
}
#[test]
fn fuzzy_icon_filter_supports_non_substring_queries() {
let icons = vec![
"lucide:bean".to_string(),
"lucide:beaker".to_string(),
"lucide:home".to_string(),
];
let filtered = fuzzy_filter_icons(&icons, "bn");
assert_eq!(filtered, vec!["lucide:bean".to_string()]);
}
#[test]
fn collection_icon_search_is_local_and_does_not_queue_remote_search() {
let mut app = test_app();
app.app_focus = AppFocus::IconifySearchPopup;
let mut state = IconifySearchPopupState::new();
state.active_tab = IconifySearchTab::Icons;
state.selected_collection_filter = Some("lucide".to_string());
state.collection_icons_prefix = Some("lucide".to_string());
state.collection_icons = vec![
"lucide:bean".to_string(),
"lucide:beaker".to_string(),
"lucide:home".to_string(),
];
state.refresh_visible_icons();
app.iconify_search_popup_state = Some(state);
app.handlekeys_iconify_search_popup(Input {
key: Key::Char('b'),
..Default::default()
});
app.handlekeys_iconify_search_popup(Input {
key: Key::Char('n'),
..Default::default()
});
let state = app
.iconify_search_popup_state
.as_ref()
.expect("iconify popup state should exist");
assert_eq!(state.search_value, "bn");
assert!(state.pending_search_query.is_none());
assert!(!state.is_loading_search);
assert_eq!(state.visible_icons, vec!["lucide:bean".to_string()]);
}
#[test]
fn global_icon_search_keeps_remote_query_flow() {
let mut app = test_app();
app.app_focus = AppFocus::IconifySearchPopup;
let mut state = IconifySearchPopupState::new();
state.active_tab = IconifySearchTab::Icons;
app.iconify_search_popup_state = Some(state);
app.handlekeys_iconify_search_popup(Input {
key: Key::Char('h'),
..Default::default()
});
let state = app
.iconify_search_popup_state
.as_ref()
.expect("iconify popup state should exist");
assert_eq!(state.pending_search_query.as_deref(), Some("h"));
assert!(state.is_loading_search);
}
#[test]
fn j_and_k_type_into_search_input() {
let mut app = test_app();
app.app_focus = AppFocus::IconifySearchPopup;
app.iconify_search_popup_state = Some(IconifySearchPopupState::new());
app.handlekeys_iconify_search_popup(Input {
key: Key::Char('j'),
..Default::default()
});
app.handlekeys_iconify_search_popup(Input {
key: Key::Char('k'),
..Default::default()
});
let state = app
.iconify_search_popup_state
.as_ref()
.expect("iconify popup state should exist");
assert_eq!(state.search_value, "jk");
}
}