Skip to content

Commit 9da6485

Browse files
committed
refactor(bar): app widget and icon caching
PR #1439 authored and submitted by @JustForFun88 I understand this PR combines two areas of work — refactoring the Applications widget and introducing a new icon caching system — which would ideally be submitted separately. Originally, I only intended to reduce allocations and simplify icon loading in `applications.rs`, but as I worked through it, it became clear that a more general-purpose caching system was needed. One improvement led to another ... 😄 Apologies for bundling these changes together. If needed, I’m happy to split this PR into smaller, focused ones. Key Changes - Introduced `IconsCache` with unified in-memory image & texture management. - Added `ImageIcon` and `ImageIconId` (based on path or HWND) for caching and reuse. - `Icon::Image` now wraps `ImageIcon`, decoupled from direct `RgbaImage` usage. - Extracted app launch logic into `UserCommand` with built-in cooldown. - Simplified config parsing and UI hover rendering in `App`. - Replaced legacy `ICON_CACHE` in `KomorebiNotificationStateContainerInformation` → Now uses the shared `ImageIcon::try_load(hwnd, ..)` with caching and fallback. Motivation - Reduce redundant image copies and avoid repeated pixel-to-texture conversions. - Cleanly separate concerns for launching and icon handling. - Reuse icons across `Applications`, Komorebi windows, and potentially more in the future. Tested - Works on Windows 11. - Verified path/exe/HWND icon loading and fallback.
2 parents ce59bd9 + 258b51e commit 9da6485

File tree

4 files changed

+316
-164
lines changed

4 files changed

+316
-164
lines changed

komorebi-bar/src/main.rs

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,17 @@ use eframe::egui::ViewportBuilder;
1515
use font_loader::system_fonts;
1616
use hotwatch::EventKind;
1717
use hotwatch::Hotwatch;
18-
use image::RgbaImage;
1918
use komorebi_client::replace_env_in_path;
2019
use komorebi_client::PathExt;
2120
use komorebi_client::SocketMessage;
2221
use komorebi_client::SubscribeOptions;
23-
use std::collections::HashMap;
2422
use std::io::BufReader;
2523
use std::io::Read;
2624
use std::path::PathBuf;
2725
use std::sync::atomic::AtomicI32;
2826
use std::sync::atomic::AtomicU32;
2927
use std::sync::atomic::AtomicUsize;
3028
use std::sync::atomic::Ordering;
31-
use std::sync::LazyLock;
32-
use std::sync::Mutex;
3329
use std::time::Duration;
3430
use tracing_subscriber::EnvFilter;
3531
use windows::Win32::Foundation::HWND;
@@ -53,9 +49,6 @@ pub static DEFAULT_PADDING: f32 = 10.0;
5349
pub static AUTO_SELECT_FILL_COLOUR: AtomicU32 = AtomicU32::new(0);
5450
pub static AUTO_SELECT_TEXT_COLOUR: AtomicU32 = AtomicU32::new(0);
5551

56-
pub static ICON_CACHE: LazyLock<Mutex<HashMap<isize, RgbaImage>>> =
57-
LazyLock::new(|| Mutex::new(HashMap::new()));
58-
5952
#[derive(Parser)]
6053
#[clap(author, about, version)]
6154
struct Opts {

komorebi-bar/src/widgets/applications.rs

Lines changed: 138 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use super::komorebi::img_to_texture;
1+
use super::ImageIcon;
22
use crate::render::RenderConfig;
33
use crate::selected_frame::SelectableFrame;
44
use crate::widgets::widget::BarWidget;
@@ -17,14 +17,13 @@ use eframe::egui::Stroke;
1717
use eframe::egui::StrokeKind;
1818
use eframe::egui::Ui;
1919
use eframe::egui::Vec2;
20-
use image::DynamicImage;
21-
use image::RgbaImage;
2220
use komorebi_client::PathExt;
2321
use serde::Deserialize;
2422
use serde::Serialize;
23+
use std::borrow::Cow;
2524
use std::path::Path;
26-
use std::path::PathBuf;
2725
use std::process::Command;
26+
use std::sync::Arc;
2827
use std::time::Duration;
2928
use std::time::Instant;
3029
use tracing;
@@ -119,43 +118,32 @@ impl BarWidget for Applications {
119118

120119
impl 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

190176
impl 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)]
263228
pub 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

270235
impl 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

Comments
 (0)