From a4e33b5a0fb0ec8526e24ff7ea146c6ee45a2b65 Mon Sep 17 00:00:00 2001 From: ByteAtATime Date: Fri, 28 Nov 2025 13:32:27 -0800 Subject: [PATCH] feat: add search bar to ActionPanel --- src/components/action_panel.rs | 142 ++++++++++++++++++++++++--------- src/message.rs | 1 + src/state.rs | 2 + src/update.rs | 10 ++- src/view.rs | 13 +-- 5 files changed, 122 insertions(+), 46 deletions(-) diff --git a/src/components/action_panel.rs b/src/components/action_panel.rs index 9c31e57..3137810 100644 --- a/src/components/action_panel.rs +++ b/src/components/action_panel.rs @@ -1,6 +1,6 @@ use iced::{ - Color, Element, Length, - widget::{Button, column, container, mouse_area, opaque, row, text}, + Border, Color, Element, Length, Theme, color, + widget::{Button, column, container, mouse_area, opaque, row, text, text_input}, }; use crate::{ @@ -14,70 +14,133 @@ const ICON_FONT: iced::Font = iced::Font::with_name("Raycast-Icons"); pub enum ActionPanelMessage { Close, InvokeAction(crate::components::actions::ActionHandler), + SearchChanged(String), } -pub fn render_action_panel(actions: &[ActionPanelItem]) -> Element<'_, ActionPanelMessage> { - let actions_col = actions - .iter() +pub fn render_action_panel( + actions: &[ActionPanelItem], + search_text: &str, +) -> Element<'static, ActionPanelMessage> { + let filtered_actions = filter_actions(actions, search_text); + let search_text_owned = search_text.to_string(); + + let actions_col = filtered_actions + .into_iter() .fold(column![].spacing(10), |col, action| { col.push(match action { - ActionPanelItem::Action(action) => render_action(action), - ActionPanelItem::Section(section) => render_section(section), + ActionPanelItem::Action(action) => render_action_owned(action), + ActionPanelItem::Section(section) => render_section_owned(section), }) }); + let search_bar = text_input("Search for actions...", &search_text_owned) + .on_input(ActionPanelMessage::SearchChanged) + .size(14) + .padding(8) + .style(|_theme: &Theme, _status| text_input::Style { + background: iced::Background::Color(Color::TRANSPARENT), + border: iced::Border::default(), + icon: Color::from_rgba(1.0, 1.0, 1.0, 0.6), + placeholder: Color::from_rgba(1.0, 1.0, 1.0, 0.4), + value: Color::WHITE, + selection: Color::from_rgba(1.0, 1.0, 1.0, 0.2), + }); + + let action_panel = column![ + container(opaque(column![actions_col, search_bar,])) + .padding(8) + .style(|_theme| { + container::Style { + background: Some(color!(0x2c2c2c).into()), // TODO: where does this color come from? + border: Border::default().rounded(8.0), + ..Default::default() + } + }), + container(column![]).height(40) + ] + .width(Length::Fixed(368.0)) + .spacing(10); + opaque( mouse_area( - container( - column![ - container(opaque(actions_col)) - .padding(8) - .style(|_theme| container::Style { - background: Some(Color::from_rgba(0.1, 0.1, 0.1, 0.95).into()), - ..Default::default() - }), - container(column![]).height(40) - ] - .spacing(10), - ) - .align_bottom(Length::Fill) - .align_right(Length::Fill), + container(action_panel) + .align_bottom(Length::Fill) + .align_right(Length::Fill), ) .on_press(ActionPanelMessage::Close), ) .into() } -fn render_action(action: &Action) -> Element<'_, ActionPanelMessage> { - let mut button = Button::new( - if let Some(icon) = action - .icon - .as_ref() - .and_then(|icon_name| icons::get_icon(icon_name)) - { - row![text(icon).font(ICON_FONT), text(action.title.clone())].into() - } else { - Element::from(text(action.title.clone())) - }, - ); +fn filter_actions(actions: &[ActionPanelItem], search_text: &str) -> Vec { + if search_text.is_empty() { + return actions.to_vec(); + } - if let Some(handler) = &action.handler { - button = button.on_press(ActionPanelMessage::InvokeAction(handler.clone())); + let search_lower = search_text.to_lowercase(); + + actions + .iter() + .filter_map(|item| match item { + ActionPanelItem::Action(action) => { + if action.title.to_lowercase().contains(&search_lower) { + Some(ActionPanelItem::Action(action.clone())) + } else { + None + } + } + ActionPanelItem::Section(section) => { + let filtered_children: Vec = section + .children + .iter() + .filter(|a| a.title.to_lowercase().contains(&search_lower)) + .cloned() + .collect(); + + if filtered_children.is_empty() { + None + } else { + Some(ActionPanelItem::Section(ActionPanelSection { + props: section.props.clone(), + children: filtered_children, + })) + } + } + }) + .collect() +} + +fn render_action_owned(action: Action) -> Element<'static, ActionPanelMessage> { + let title = action.title.clone(); + let icon_char = action + .icon + .as_ref() + .and_then(|icon_name| icons::get_icon(icon_name)) + .map(|s| s.to_string()); + + let mut button = Button::new(if let Some(icon) = icon_char { + row![text(icon).font(ICON_FONT), text(title)].into() + } else { + Element::from(text(title)) + }); + + if let Some(handler) = action.handler { + button = button.on_press(ActionPanelMessage::InvokeAction(handler)); } button.into() } -fn render_section(section: &ActionPanelSection) -> Element<'_, ActionPanelMessage> { - let section_title = text(§ion.props.title) +fn render_section_owned(section: ActionPanelSection) -> Element<'static, ActionPanelMessage> { + let section_title = text(section.props.title.clone()) .size(16) .color(Color::from_rgb8(0xFF, 0xFF, 0xFF)); let section_actions = section .children - .iter() + .into_iter() .fold(column![].spacing(5), |col, action| { - col.push(render_action(action)) + col.push(render_action_owned(action)) }); column![section_title, section_actions].spacing(5).into() @@ -87,5 +150,6 @@ pub fn map_action_panel_message(msg: ActionPanelMessage) -> crate::Message { match msg { ActionPanelMessage::Close => crate::Message::ToggleActionPanel(false), ActionPanelMessage::InvokeAction(handler) => crate::Message::InvokeAction(handler), + ActionPanelMessage::SearchChanged(text) => crate::Message::ActionPanelSearchChanged(text), } } diff --git a/src/message.rs b/src/message.rs index ea2591c..24c5604 100644 --- a/src/message.rs +++ b/src/message.rs @@ -13,6 +13,7 @@ pub enum Message { ImageLoaded(String, Handle), InvokeAction(ActionHandler), ToggleActionPanel(bool), + ActionPanelSearchChanged(String), ShowToast(String), DropdownChanged(String), LaunchCommand(ExtensionCommand), diff --git a/src/state.rs b/src/state.rs index ecabec0..302629e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -20,6 +20,7 @@ pub struct State { pub search_text: String, pub search_input_id: WidgetId, pub action_panel_visible: bool, + pub action_panel_search: String, pub selected_actions: Vec, pub toast_message: String, pub window_id: Option, @@ -63,6 +64,7 @@ impl State { search_text, search_input_id: WidgetId::unique(), action_panel_visible: false, + action_panel_search: String::new(), selected_actions: Vec::new(), toast_message: String::new(), window_id: None, diff --git a/src/update.rs b/src/update.rs index 751a62c..d85a567 100644 --- a/src/update.rs +++ b/src/update.rs @@ -106,10 +106,16 @@ pub fn update(state: &mut State, message: Message) -> Task { state.action_panel_visible = visibility; if visibility { return operation::focus_next(); - } else if state.screen.can_search() { - return operation::focus(state.search_input_id.clone()); + } else { + state.action_panel_search.clear(); + if state.screen.can_search() { + return operation::focus(state.search_input_id.clone()); + } } } + Message::ActionPanelSearchChanged(text) => { + state.action_panel_search = text; + } Message::ShowToast(message) => { state.toast_message = message; } diff --git a/src/view.rs b/src/view.rs index 4e538eb..f82f653 100644 --- a/src/view.rs +++ b/src/view.rs @@ -2,9 +2,9 @@ use iced::widget::{column, container, pick_list, row, rule, stack, text_input}; use iced::{Element, Length, Theme}; use crate::components::{ - action_panel::{render_action_panel, map_action_panel_message}, + action_panel::{map_action_panel_message, render_action_panel}, dropdown::{Dropdown, DropdownChild}, - footer::{render_footer, map_footer_message}, + footer::{map_footer_message, render_footer}, }; use crate::message::Message; use crate::screens::{Screen, Shell}; @@ -37,15 +37,18 @@ pub fn view(state: &State) -> Element<'_, Message> { Screen::List(s) => s.view().map(Message::List), }; - let footer = render_footer(&state.selected_actions, &state.toast_message, theme) - .map(map_footer_message); + let footer = + render_footer(&state.selected_actions, &state.toast_message, theme).map(map_footer_message); base_col = base_col .push(container(content).width(Length::Fill).height(Length::Fill)) .push(footer); let action_panel = if state.action_panel_visible { - Some(render_action_panel(&state.selected_actions).map(map_action_panel_message)) + Some( + render_action_panel(&state.selected_actions, &state.action_panel_search) + .map(map_action_panel_message), + ) } else { None };