From 07b332b8455cecc961596ea3144c5de5fc7a128c Mon Sep 17 00:00:00 2001 From: ByteAtATime Date: Fri, 28 Nov 2025 21:16:52 -0800 Subject: [PATCH] refactor: extract ActionPanel state into separate struct --- src/components/action_panel.rs | 196 ++++++++++++++++++++++++++------- src/components/footer.rs | 4 +- src/main.rs | 2 +- src/message.rs | 10 +- src/state.rs | 22 +--- src/update.rs | 135 ++++++----------------- src/view.rs | 16 +-- 7 files changed, 211 insertions(+), 174 deletions(-) diff --git a/src/components/action_panel.rs b/src/components/action_panel.rs index 585fdae..3ae66b0 100644 --- a/src/components/action_panel.rs +++ b/src/components/action_panel.rs @@ -1,15 +1,16 @@ use iced::widget::text; use iced::{ Alignment::Center, - Border, Color, Element, Length, Padding, Theme, color, + Border, Color, Element, Length, Padding, Task, Theme, color, widget::{ Button, button, column, container, mouse_area, opaque, row, rule, space, text::LineHeight, text_input, }, }; +use std::time::Instant; use crate::{ - components::actions::{Action, ActionPanelItem, ActionPanelSection}, + components::actions::{Action, ActionHandler, ActionPanelItem, ActionPanelSection}, icons, }; @@ -27,45 +28,171 @@ const SECTION_TITLE_COLOR: Color = Color::from_rgba(1.0, 1.0, 1.0, 0.5); const TEXT_COLOR: Color = Color::WHITE; #[derive(Debug, Clone)] -pub enum ActionPanelMessage { +pub enum Message { + Open, Close, - InvokeAction(crate::components::actions::ActionHandler), + InvokeAction(ActionHandler), SearchChanged(String), Select(usize), + MoveUp, + MoveDown, + InvokeSelected, + Tick(Instant), } -pub fn render_action_panel( +#[derive(Debug, Clone, Default)] +pub struct AnimationState { + pub start_time: Option, + pub opacity: f32, + pub scale: f32, +} + +#[derive(Debug, Clone)] +pub struct State { + pub visible: bool, + pub search_text: String, + pub selected_index: usize, + pub input_id: iced::widget::Id, + pub animation: AnimationState, +} + +impl State { + pub fn new() -> Self { + Self { + visible: false, + search_text: String::new(), + selected_index: 0, + input_id: iced::widget::Id::unique(), + animation: AnimationState::default(), + } + } + + pub fn update( + &mut self, + message: Message, + actions: &[ActionPanelItem], + ) -> (Task, Option) { + match message { + Message::Open => { + self.visible = true; + self.selected_index = 0; + self.animation.start_time = Some(Instant::now()); + self.animation.opacity = animation::OPACITY_START; + self.animation.scale = animation::SCALE_START; + (iced::widget::operation::focus(self.input_id.clone()), None) + } + Message::Close => { + self.visible = false; + self.reset(); + (Task::none(), None) + } + Message::InvokeAction(handler) => { + self.visible = false; + self.reset(); + (Task::none(), Some(handler)) + } + Message::SearchChanged(text) => { + self.search_text = text; + self.selected_index = 0; + (Task::none(), None) + } + Message::Select(index) => { + self.selected_index = index; + (Task::none(), None) + } + Message::MoveUp => { + if self.selected_index > 0 { + self.selected_index -= 1; + } + (Task::none(), None) + } + Message::MoveDown => { + let count = count_actions(actions, &self.search_text); + if self.selected_index < count.saturating_sub(1) { + self.selected_index += 1; + } + (Task::none(), None) + } + Message::InvokeSelected => { + let filtered = filter_actions(actions, &self.search_text); + let action = filtered + .iter() + .flat_map(|item| match item { + ActionPanelItem::Action(a) => std::slice::from_ref(a).iter(), + ActionPanelItem::Section(s) => s.children.iter(), + }) + .nth(self.selected_index); + + if let Some(action) = action { + if let Some(handler) = &action.handler { + self.visible = false; + self.reset(); + return (Task::none(), Some(handler.clone())); + } + } + (Task::none(), None) + } + Message::Tick(now) => { + if let Some(start) = self.animation.start_time { + let elapsed = now.duration_since(start).as_millis() as f32; + let duration = animation::DURATION_MS as f32; + let t = (elapsed / duration).clamp(0.0, 1.0); + + let ease = 1.0 - (1.0 - t).powi(2); + + self.animation.opacity = + animation::OPACITY_START + (1.0 - animation::OPACITY_START) * ease; + self.animation.scale = + animation::SCALE_START + (1.0 - animation::SCALE_START) * ease; + + if t >= 1.0 { + self.animation.start_time = None; + self.animation.opacity = 1.0; + self.animation.scale = 1.0; + } + } + (Task::none(), None) + } + } + } + + fn reset(&mut self) { + self.search_text.clear(); + self.selected_index = 0; + self.animation.start_time = None; + self.animation.opacity = 1.0; + self.animation.scale = 1.0; + } +} + +pub fn render_action_panel<'a>( + state: &'a State, actions: &[ActionPanelItem], - search_text: &str, - selected_index: usize, - opacity: f32, - input_id: iced::widget::Id, -) -> Element<'static, ActionPanelMessage> { - let filtered_actions = filter_actions(actions, search_text); - let search_text_owned = search_text.to_string(); +) -> Element<'a, Message> { + let filtered_actions = filter_actions(actions, &state.search_text); + let search_text_owned = state.search_text.to_string(); let filtered_count = filtered_actions.len(); let text_color = Color { - a: opacity, + a: state.animation.opacity, ..TEXT_COLOR }; let section_title_color = Color { - a: 0.5 * opacity, + a: 0.5 * state.animation.opacity, ..SECTION_TITLE_COLOR }; let bg_color = Color { - a: opacity, + a: state.animation.opacity, ..color!(0x2c2c2c) }; let mut current_index = 0usize; - let _total_actions = count_actions(&filtered_actions, &search_text_owned); let actions_col = filtered_actions.into_iter().enumerate().fold( column![].width(Length::Fill).padding([12, 0]), |col, (idx, action)| match action { ActionPanelItem::Action(action) => { - let is_selected = current_index == selected_index; + let is_selected = current_index == state.selected_index; current_index += 1; col.push( @@ -73,7 +200,7 @@ pub fn render_action_panel( action, is_selected, current_index - 1, - opacity, + state.animation.opacity, )) .width(Length::Fill) .padding([0, 8]), @@ -84,11 +211,11 @@ pub fn render_action_panel( let mut col = col.push(render_section_owned( section, - selected_index, + state.selected_index, &mut current_index, is_first, section_title_color, - opacity, + state.animation.opacity, )); if idx < filtered_count - 1 { @@ -104,17 +231,17 @@ pub fn render_action_panel( ); let search_bar = text_input("Search for actions...", &search_text_owned) - .id(input_id) - .on_input(ActionPanelMessage::SearchChanged) + .id(state.input_id.clone()) + .on_input(Message::SearchChanged) .size(13) .padding([0, 16]) .style(move |_theme: &Theme, _status| text_input::Style { background: iced::Background::Color(Color::TRANSPARENT), border: iced::Border::default(), icon: text_color, - placeholder: Color::from_rgba(1.0, 1.0, 1.0, 0.4 * opacity), + placeholder: Color::from_rgba(1.0, 1.0, 1.0, 0.4 * state.animation.opacity), value: text_color, - selection: Color::from_rgba(1.0, 1.0, 1.0, 0.2 * opacity), + selection: Color::from_rgba(1.0, 1.0, 1.0, 0.2 * state.animation.opacity), }); let search_container = column![rule::horizontal(1), container(search_bar).center_y(44)]; @@ -138,7 +265,7 @@ pub fn render_action_panel( .align_right(Length::Fill) .padding([0, 12]), ) - .on_press(ActionPanelMessage::Close), + .on_press(Message::Close), ) .into() } @@ -197,7 +324,7 @@ fn render_action_owned( is_selected: bool, index: usize, opacity: f32, -) -> Element<'static, ActionPanelMessage> { +) -> Element<'static, Message> { let title = action.title.clone(); let icon_char = action .icon @@ -210,7 +337,7 @@ fn render_action_owned( ..TEXT_COLOR }; - let content: Element<'static, ActionPanelMessage> = if let Some(icon) = icon_char { + let content: Element<'static, Message> = if let Some(icon) = icon_char { row![ text(icon).font(ICON_FONT).size(18).color(text_color), text(title).font(INTER_FONT).color(text_color) @@ -253,9 +380,9 @@ fn render_action_owned( }); if let Some(handler) = action.handler { - btn = btn.on_press(ActionPanelMessage::InvokeAction(handler)); + btn = btn.on_press(Message::InvokeAction(handler)); } else { - btn = btn.on_press(ActionPanelMessage::Select(index)); + btn = btn.on_press(Message::Select(index)); } btn.into() @@ -268,7 +395,7 @@ fn render_section_owned( is_first: bool, section_title_color: Color, opacity: f32, -) -> Element<'static, ActionPanelMessage> { +) -> Element<'static, Message> { let section_title = text(section.props.title.clone()) .size(13) .line_height(LineHeight::Absolute(iced::Pixels(14.0))) @@ -295,12 +422,3 @@ fn render_section_owned( .padding(Padding::from(8).top(top_pad)) .into() } - -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), - ActionPanelMessage::Select(index) => crate::Message::ActionPanelSelect(index), - } -} diff --git a/src/components/footer.rs b/src/components/footer.rs index eda160a..651e7d4 100644 --- a/src/components/footer.rs +++ b/src/components/footer.rs @@ -121,7 +121,9 @@ pub fn render_footer<'a>( pub fn map_footer_message(msg: FooterMessage) -> crate::Message { match msg { - FooterMessage::OpenActionPanel => crate::Message::ToggleActionPanel(true), + FooterMessage::OpenActionPanel => { + crate::Message::ActionPanel(crate::components::action_panel::Message::Open) + } FooterMessage::InvokeAction(handler) => crate::Message::InvokeAction(handler), } } diff --git a/src/main.rs b/src/main.rs index 903fe73..ecbc36c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -98,7 +98,7 @@ fn subscription(state: &State) -> Subscription { let window_close_sub = window::close_events().map(Message::WindowClosed); - let animation_sub = if state.action_panel_start_time.is_some() { + let animation_sub = if state.action_panel.animation.start_time.is_some() { window::frames().map(Message::Tick) } else { Subscription::none() diff --git a/src/message.rs b/src/message.rs index d70ea14..5edef67 100644 --- a/src/message.rs +++ b/src/message.rs @@ -6,6 +6,8 @@ use iced::widget::image::Handle; use iced::window; use std::time::Instant; +use crate::components::action_panel; + #[derive(Clone, Debug)] pub enum Message { UpdateTree(Tree), @@ -14,11 +16,9 @@ pub enum Message { EscapePressed, ImageLoaded(String, Handle), InvokeAction(ActionHandler), - ToggleActionPanel(bool), - ActionPanelSearchChanged(String), - ActionPanelSelect(usize), - ActionPanelMoveUp, - ActionPanelMoveDown, + + ActionPanel(action_panel::Message), + ShowToast(String), DropdownChanged(String), LaunchCommand(ExtensionCommand), diff --git a/src/state.rs b/src/state.rs index 0f14345..5f7114e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,9 +1,9 @@ use iced::widget::Id as WidgetId; use iced::window; use std::path::PathBuf; -use std::time::Instant; use crate::apps; +use crate::components::action_panel; use crate::components::actions::ActionPanelItem; use crate::extensions; use crate::frecency::FrecencyStore; @@ -20,19 +20,13 @@ pub struct State { pub theme: Theme, pub search_text: String, pub search_input_id: WidgetId, - pub action_panel_input_id: WidgetId, - pub action_panel_visible: bool, - pub action_panel_search: String, - pub action_panel_selected: usize, + + pub action_panel: action_panel::State, pub selected_actions: Vec, + pub toast_message: String, pub window_id: Option, pub settings_window_id: Option, - - // Animation state - pub action_panel_start_time: Option, - pub action_panel_opacity: f32, - pub action_panel_scale: f32, } fn get_frecency_path() -> PathBuf { @@ -71,17 +65,11 @@ impl State { theme, search_text, search_input_id: WidgetId::unique(), - action_panel_input_id: WidgetId::unique(), - action_panel_visible: false, - action_panel_search: String::new(), - action_panel_selected: 0, + action_panel: action_panel::State::new(), selected_actions: Vec::new(), toast_message: String::new(), window_id: None, settings_window_id: None, - action_panel_start_time: None, - action_panel_opacity: 1.0, - action_panel_scale: 1.0, }; state.update_selected_actions(); diff --git a/src/update.rs b/src/update.rs index ef93235..00e5d78 100644 --- a/src/update.rs +++ b/src/update.rs @@ -5,7 +5,6 @@ use iced::{ window, }; use serde_json::Value; -use std::time::Instant; use crate::apps; use crate::components::action_panel; @@ -107,62 +106,31 @@ pub fn update(state: &mut State, message: Message) -> Task { Message::InvokeAction(handler) => { return handler.call(); } - Message::ToggleActionPanel(visibility) => { - state.action_panel_visible = visibility; - if visibility { - state.action_panel_selected = 0; - state.action_panel_start_time = Some(Instant::now()); - state.action_panel_opacity = action_panel::animation::OPACITY_START; - state.action_panel_scale = action_panel::animation::SCALE_START; - return operation::focus(state.action_panel_input_id.clone()); - } else { - state.action_panel_start_time = None; - state.action_panel_search.clear(); - state.action_panel_selected = 0; - if state.screen.can_search() { - return operation::focus(state.search_input_id.clone()); + Message::ActionPanel(msg) => { + let actions = state.selected_actions.clone(); + let (task, command) = state.action_panel.update(msg, &actions); + + if !state.action_panel.visible && state.screen.can_search() { + if command.is_none() { + return Task::batch(vec![ + task, + operation::focus(state.search_input_id.clone()), + ]); } } + + if let Some(handler) = command { + return Task::batch(vec![task, handler.call()]); + } + + return task; } Message::Tick(now) => { - if let Some(start) = state.action_panel_start_time { - let elapsed = now.duration_since(start).as_millis() as f32; - let duration = action_panel::animation::DURATION_MS as f32; - let t = (elapsed / duration).clamp(0.0, 1.0); - - let ease = 1.0 - (1.0 - t).powi(2); - - state.action_panel_opacity = action_panel::animation::OPACITY_START - + (1.0 - action_panel::animation::OPACITY_START) * ease; - state.action_panel_scale = action_panel::animation::SCALE_START - + (1.0 - action_panel::animation::SCALE_START) * ease; - - if t >= 1.0 { - state.action_panel_start_time = None; - state.action_panel_opacity = 1.0; - state.action_panel_scale = 1.0; - } - } - } - Message::ActionPanelSearchChanged(text) => { - state.action_panel_search = text; - state.action_panel_selected = 0; - } - Message::ActionPanelSelect(index) => { - state.action_panel_selected = index; - } - Message::ActionPanelMoveUp => { - if state.action_panel_selected > 0 { - state.action_panel_selected -= 1; - } - } - Message::ActionPanelMoveDown => { - let count = crate::components::action_panel::count_actions( - &state.selected_actions, - &state.action_panel_search, - ); - if state.action_panel_selected < count.saturating_sub(1) { - state.action_panel_selected += 1; + if state.action_panel.animation.start_time.is_some() { + return update( + state, + Message::ActionPanel(action_panel::Message::Tick(now)), + ); } } Message::ShowToast(message) => { @@ -255,15 +223,8 @@ fn dispatch_screen_message(state: &mut State, message: Message) -> Task } fn handle_escape(state: &mut State) -> Task { - if state.action_panel_visible { - state.action_panel_visible = false; - state.action_panel_search.clear(); - state.action_panel_selected = 0; - state.action_panel_start_time = None; - if state.screen.can_search() { - return operation::focus(state.search_input_id.clone()); - } - return Task::none(); + if state.action_panel.visible { + return update(state, Message::ActionPanel(action_panel::Message::Close)); } if !state.search_text.is_empty() { @@ -291,38 +252,23 @@ fn handle_key_press(state: &mut State, key: Key, modifiers: Modifiers) -> Task { - return Task::done(Message::ActionPanelMoveUp); + return update(state, Message::ActionPanel(action_panel::Message::MoveUp)); } Named::ArrowDown => { - return Task::done(Message::ActionPanelMoveDown); + return update( + state, + Message::ActionPanel(action_panel::Message::MoveDown), + ); } Named::Enter => { - let filtered = crate::components::action_panel::filter_actions( - &state.selected_actions, - &state.action_panel_search, + return update( + state, + Message::ActionPanel(action_panel::Message::InvokeSelected), ); - let action = filtered - .iter() - .flat_map(|item| match item { - ActionPanelItem::Action(a) => std::slice::from_ref(a).iter(), - ActionPanelItem::Section(s) => s.children.iter(), - }) - .nth(state.action_panel_selected); - - if let Some(action) = action { - if let Some(handler) = &action.handler { - state.action_panel_visible = false; - state.action_panel_search.clear(); - state.action_panel_selected = 0; - state.action_panel_start_time = None; - return handler.call(); - } - } - return Task::none(); } _ => {} } @@ -367,21 +313,10 @@ fn handle_key_press(state: &mut State, key: Key, modifiers: Modifiers) -> Task Element<'_, Message> { .push(container(content).width(Length::Fill).height(Length::Fill)) .push(footer); - let action_panel = if state.action_panel_visible { - let panel_content = render_action_panel( - &state.selected_actions, - &state.action_panel_search, - state.action_panel_selected, - state.action_panel_opacity, - state.action_panel_input_id.clone(), - ) - .map(map_action_panel_message); + let action_panel = if state.action_panel.visible { + let panel_content = render_action_panel(&state.action_panel, &state.selected_actions) + .map(Message::ActionPanel); - let animated = Scaler::new(state.action_panel_scale, panel_content); + let animated = Scaler::new(state.action_panel.animation.scale, panel_content); Some(Element::from(animated)) } else {