1- use super :: komorebi :: img_to_texture ;
1+ use super :: ImageIcon ;
22use crate :: render:: RenderConfig ;
33use crate :: selected_frame:: SelectableFrame ;
44use crate :: widgets:: widget:: BarWidget ;
@@ -17,14 +17,13 @@ use eframe::egui::Stroke;
1717use eframe:: egui:: StrokeKind ;
1818use eframe:: egui:: Ui ;
1919use eframe:: egui:: Vec2 ;
20- use image:: DynamicImage ;
21- use image:: RgbaImage ;
2220use komorebi_client:: PathExt ;
2321use serde:: Deserialize ;
2422use serde:: Serialize ;
23+ use std:: borrow:: Cow ;
2524use std:: path:: Path ;
26- use std:: path:: PathBuf ;
2725use std:: process:: Command ;
26+ use std:: sync:: Arc ;
2827use std:: time:: Duration ;
2928use std:: time:: Instant ;
3029use tracing;
@@ -119,43 +118,32 @@ impl BarWidget for Applications {
119118
120119impl From < & ApplicationsConfig > for Applications {
121120 fn from ( applications_config : & ApplicationsConfig ) -> Self {
122- // Allow immediate launch by initializing last_launch in the past.
123- let last_launch = Instant :: now ( ) - 2 * MIN_LAUNCH_INTERVAL ;
124- let mut applications_config = applications_config. clone ( ) ;
125121 let items = applications_config
126122 . items
127- . iter_mut ( )
123+ . iter ( )
128124 . enumerate ( )
129- . map ( |( index, app_config) | {
130- app_config. command = app_config
131- . command
132- . replace_env ( )
133- . to_string_lossy ( )
134- . to_string ( ) ;
135-
136- if let Some ( icon) = & mut app_config. icon {
137- * icon = icon. replace_env ( ) . to_string_lossy ( ) . to_string ( ) ;
138- }
125+ . map ( |( index, config) | {
126+ let command = UserCommand :: new ( & config. command ) ;
139127
140128 App {
141- enable : app_config . enable . unwrap_or ( applications_config. enable ) ,
129+ enable : config . enable . unwrap_or ( applications_config. enable ) ,
142130 #[ allow( clippy:: obfuscated_if_else) ]
143- name : app_config
131+ name : config
144132 . name
145133 . is_empty ( )
146134 . then ( || format ! ( "App {}" , index + 1 ) )
147- . unwrap_or_else ( || app_config. name . clone ( ) ) ,
148- icon : Icon :: try_from ( app_config) ,
149- command : app_config. command . clone ( ) ,
150- display : app_config
135+ . unwrap_or_else ( || config. name . clone ( ) ) ,
136+ icon : Icon :: try_from_path ( config. icon . as_deref ( ) )
137+ . or_else ( || Icon :: try_from_command ( & command) ) ,
138+ command,
139+ display : config
151140 . display
152141 . or ( applications_config. display )
153142 . unwrap_or_default ( ) ,
154- show_command_on_hover : app_config
143+ show_command_on_hover : config
155144 . show_command_on_hover
156145 . or ( applications_config. show_command_on_hover )
157146 . unwrap_or ( false ) ,
158- last_launch,
159147 }
160148 } )
161149 . collect ( ) ;
@@ -178,13 +166,11 @@ pub struct App {
178166 /// Icon to display for this application, if available.
179167 pub icon : Option < Icon > ,
180168 /// Command to execute when the application is launched.
181- pub command : String ,
169+ pub command : UserCommand ,
182170 /// Display format (icon, text, or both).
183171 pub display : DisplayFormat ,
184172 /// Whether to show the launch command on hover.
185173 pub show_command_on_hover : bool ,
186- /// Last time this application was launched (used for cooldown control).
187- pub last_launch : Instant ,
188174}
189175
190176impl App {
@@ -206,17 +192,15 @@ impl App {
206192 }
207193
208194 // Add hover text with command information
195+ let response = ui. response ( ) ;
209196 if self . show_command_on_hover {
210- ui. response ( )
211- . on_hover_text ( format ! ( "Launch: {}" , self . command) ) ;
212- } else {
213- ui. response ( ) ;
197+ response. on_hover_text ( format ! ( "Launch: {}" , self . command. as_ref( ) ) ) ;
214198 }
215199 } )
216200 . clicked ( )
217201 {
218202 // Launch the application when clicked
219- self . launch_if_ready ( ) ;
203+ self . command . launch_if_ready ( ) ;
220204 }
221205 }
222206
@@ -236,84 +220,75 @@ impl App {
236220 fn draw_name ( & self , ui : & mut Ui ) {
237221 ui. add ( Label :: new ( & self . name ) . selectable ( false ) ) ;
238222 }
239-
240- /// Attempts to launch the specified command in a separate thread if enough time has passed
241- /// since the last launch. This prevents repeated launches from rapid consecutive clicks.
242- ///
243- /// Errors during launch are logged using the `tracing` crate.
244- pub fn launch_if_ready ( & mut self ) {
245- let now = Instant :: now ( ) ;
246- if now. duration_since ( self . last_launch ) < MIN_LAUNCH_INTERVAL {
247- return ;
248- }
249-
250- self . last_launch = now;
251- let command_string = self . command . clone ( ) ;
252- // Launch the application in a separate thread to avoid blocking the UI
253- std:: thread:: spawn ( move || {
254- if let Err ( e) = Command :: new ( "cmd" ) . args ( [ "/C" , & command_string] ) . spawn ( ) {
255- tracing:: error!( "Failed to launch command '{}': {}" , command_string, e) ;
256- }
257- } ) ;
258- }
259223}
260224
261- /// Holds decoded image data to be used as an icon in the UI.
225+ /// Holds image/text data to be used as an icon in the UI.
226+ /// This represents source icon data before rendering.
262227#[ derive( Clone , Debug ) ]
263228pub enum Icon {
264229 /// RGBA image used for rendering the icon.
265- Image ( RgbaImage ) ,
230+ Image ( ImageIcon ) ,
266231 /// Text-based icon, e.g. from a font like Nerd Fonts.
267232 Text ( String ) ,
268233}
269234
270235impl Icon {
271- /// Attempts to create an `Icon` from the given `AppConfig`.
272- /// Loads the image from a specified icon path or extracts it from the application's
273- /// executable if the command points to a valid executable file.
236+ /// Attempts to create an [`Icon`] from a string path or text glyph/glyphs.
237+ ///
238+ /// - Environment variables in the path are resolved using [`PathExt::replace_env`].
239+ /// - Uses [`ImageIcon::try_load`] to load and cache the icon image based on the resolved path.
240+ /// - If the path is invalid but the string is non-empty, it is interpreted as a text-based icon and
241+ /// returned as [`Icon::Text`].
242+ /// - Returns `None` if the input is empty, `None`, or image loading fails.
274243 #[ inline]
275- pub fn try_from ( config : & AppConfig ) -> Option < Self > {
276- if let Some ( icon) = config. icon . as_deref ( ) . map ( str:: trim) {
277- if !icon. is_empty ( ) {
278- let path = Path :: new ( & icon) ;
279- if path. is_file ( ) {
280- match image:: open ( path) . as_ref ( ) . map ( DynamicImage :: to_rgba8) {
281- Ok ( image) => return Some ( Icon :: Image ( image) ) ,
282- Err ( err) => {
283- tracing:: error!( "Failed to load icon from {}, error: {}" , icon, err)
284- }
285- }
286- } else {
287- return Some ( Icon :: Text ( icon. to_owned ( ) ) ) ;
288- }
289- }
244+ pub fn try_from_path ( icon : Option < & str > ) -> Option < Self > {
245+ let icon = icon. map ( str:: trim) ?;
246+ if icon. is_empty ( ) {
247+ return None ;
290248 }
291249
292- let binary = PathBuf :: from ( config. command . split ( ".exe" ) . next ( ) ?) ;
293- let path = if binary. is_file ( ) {
294- Some ( binary)
295- } else {
296- which ( binary) . ok ( )
297- } ;
298-
299- match path {
300- Some ( path) => windows_icons:: get_icon_by_path ( & path. to_string_lossy ( ) )
301- . or_else ( || windows_icons_fallback:: get_icon_by_path ( & path. to_string_lossy ( ) ) )
302- . map ( Icon :: Image ) ,
303- None => None ,
250+ let path = icon. replace_env ( ) ;
251+ if !path. is_file ( ) {
252+ return Some ( Icon :: Text ( icon. to_owned ( ) ) ) ;
304253 }
254+
255+ let image_icon = ImageIcon :: try_load ( path. as_ref ( ) , || match image:: open ( & path) {
256+ Ok ( img) => Some ( img) ,
257+ Err ( err) => {
258+ tracing:: error!( "Failed to load icon from {:?}, error: {}" , path, err) ;
259+ None
260+ }
261+ } ) ?;
262+
263+ Some ( Icon :: Image ( image_icon) )
264+ }
265+
266+ /// Attempts to create an [`Icon`] by extracting an image from the executable path of a [`UserCommand`].
267+ ///
268+ /// - Uses [`ImageIcon::try_load`] to load and cache the icon image based on the resolved executable path.
269+ /// - Returns [`Icon::Image`] if an icon is successfully extracted.
270+ /// - Returns `None` if the executable path is unavailable or icon extraction fails.
271+ #[ inline]
272+ pub fn try_from_command ( command : & UserCommand ) -> Option < Self > {
273+ let path = command. get_executable ( ) ?;
274+ let image_icon = ImageIcon :: try_load ( path. as_ref ( ) , || {
275+ let path_str = path. to_str ( ) ?;
276+ windows_icons:: get_icon_by_path ( path_str)
277+ . or_else ( || windows_icons_fallback:: get_icon_by_path ( path_str) )
278+ } ) ?;
279+ Some ( Icon :: Image ( image_icon) )
305280 }
306281
307- /// Renders the icon in the given `Ui` context with the specified size .
282+ /// Renders the icon in the given [ `Ui`] using the provided [`IconConfig`] .
308283 #[ inline]
309284 pub fn draw ( & self , ctx : & Context , ui : & mut Ui , icon_config : & IconConfig ) {
310285 match self {
311- Icon :: Image ( image ) => {
286+ Icon :: Image ( image_icon ) => {
312287 Frame :: NONE
313288 . inner_margin ( Margin :: same ( ui. style ( ) . spacing . button_padding . y as i8 ) )
314289 . show ( ui, |ui| {
315290 ui. add (
316- Image :: from ( & img_to_texture ( ctx, image ) )
291+ Image :: from_texture ( & image_icon . texture ( ctx) )
317292 . maintain_aspect_ratio ( true )
318293 . fit_to_exact_size ( Vec2 :: splat ( icon_config. size ) ) ,
319294 ) ;
@@ -355,3 +330,77 @@ pub struct IconConfig {
355330 /// Color of the icon used for text-based icons
356331 pub color : Color32 ,
357332}
333+
334+ /// A structure to manage command execution with cooldown prevention.
335+ #[ derive( Clone , Debug ) ]
336+ pub struct UserCommand {
337+ /// The command string to execute
338+ pub command : Arc < str > ,
339+ /// Last time this command was executed (used for cooldown control)
340+ pub last_launch : Instant ,
341+ }
342+
343+ impl AsRef < str > for UserCommand {
344+ #[ inline]
345+ fn as_ref ( & self ) -> & str {
346+ & self . command
347+ }
348+ }
349+
350+ impl UserCommand {
351+ /// Creates a new [`UserCommand`] with environment variables in the command path
352+ /// resolved using [`PathExt::replace_env`].
353+ #[ inline]
354+ pub fn new ( command : & str ) -> Self {
355+ // Allow immediate launch by initializing last_launch in the past
356+ let last_launch = Instant :: now ( ) - 2 * MIN_LAUNCH_INTERVAL ;
357+
358+ Self {
359+ command : Arc :: from ( command. replace_env ( ) . to_str ( ) . unwrap_or_default ( ) ) ,
360+ last_launch,
361+ }
362+ }
363+
364+ /// Attempts to resolve the executable path from the command string.
365+ ///
366+ /// Resolution logic:
367+ /// - Splits the command by ".exe" and checks if the first part is an existing file.
368+ /// - If not, attempts to locate the binary using [`which`] on this name.
369+ /// - If still unresolved, takes the first word (separated by whitespace) and attempts
370+ /// to find it in the system `PATH` using [`which`].
371+ ///
372+ /// Returns `None` if no executable path can be determined.
373+ #[ inline]
374+ pub fn get_executable ( & self ) -> Option < Cow < ' _ , Path > > {
375+ if let Some ( binary) = self . command . split ( ".exe" ) . next ( ) . map ( Path :: new) {
376+ if binary. is_file ( ) {
377+ return Some ( Cow :: Borrowed ( binary) ) ;
378+ } else if let Ok ( binary) = which ( binary) {
379+ return Some ( Cow :: Owned ( binary) ) ;
380+ }
381+ }
382+
383+ which ( self . command . split ( ' ' ) . next ( ) ?) . ok ( ) . map ( Cow :: Owned )
384+ }
385+
386+ /// Attempts to launch the specified command in a separate thread if enough time has passed
387+ /// since the last launch. This prevents repeated launches from rapid consecutive clicks.
388+ ///
389+ /// Errors during launch are logged using the `tracing` crate.
390+ pub fn launch_if_ready ( & mut self ) {
391+ let now = Instant :: now ( ) ;
392+ // Check if enough time has passed since the last launch
393+ if now. duration_since ( self . last_launch ) < MIN_LAUNCH_INTERVAL {
394+ return ;
395+ }
396+
397+ self . last_launch = now;
398+ let command_string = self . command . clone ( ) ;
399+ // Launch the application in a separate thread to avoid blocking the UI
400+ std:: thread:: spawn ( move || {
401+ if let Err ( e) = Command :: new ( "cmd" ) . args ( [ "/C" , & command_string] ) . spawn ( ) {
402+ tracing:: error!( "Failed to launch command '{}': {}" , command_string, e) ;
403+ }
404+ } ) ;
405+ }
406+ }
0 commit comments