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.
This commit is contained in:
JustForFun88
2025-05-04 23:36:09 +05:00
committed by LGUG2Z
parent ce59bd9ae4
commit 3019eaf89c
4 changed files with 317 additions and 165 deletions

View File

@@ -15,12 +15,10 @@ use eframe::egui::ViewportBuilder;
use font_loader::system_fonts;
use hotwatch::EventKind;
use hotwatch::Hotwatch;
use image::RgbaImage;
use komorebi_client::replace_env_in_path;
use komorebi_client::PathExt;
use komorebi_client::SocketMessage;
use komorebi_client::SubscribeOptions;
use std::collections::HashMap;
use std::io::BufReader;
use std::io::Read;
use std::path::PathBuf;
@@ -28,8 +26,6 @@ use std::sync::atomic::AtomicI32;
use std::sync::atomic::AtomicU32;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::sync::LazyLock;
use std::sync::Mutex;
use std::time::Duration;
use tracing_subscriber::EnvFilter;
use windows::Win32::Foundation::HWND;
@@ -53,9 +49,6 @@ pub static DEFAULT_PADDING: f32 = 10.0;
pub static AUTO_SELECT_FILL_COLOUR: AtomicU32 = AtomicU32::new(0);
pub static AUTO_SELECT_TEXT_COLOUR: AtomicU32 = AtomicU32::new(0);
pub static ICON_CACHE: LazyLock<Mutex<HashMap<isize, RgbaImage>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Parser)]
#[clap(author, about, version)]
struct Opts {

View File

@@ -1,4 +1,4 @@
use super::komorebi::img_to_texture;
use super::ImageIcon;
use crate::render::RenderConfig;
use crate::selected_frame::SelectableFrame;
use crate::widgets::widget::BarWidget;
@@ -17,14 +17,13 @@ use eframe::egui::Stroke;
use eframe::egui::StrokeKind;
use eframe::egui::Ui;
use eframe::egui::Vec2;
use image::DynamicImage;
use image::RgbaImage;
use komorebi_client::PathExt;
use serde::Deserialize;
use serde::Serialize;
use std::borrow::Cow;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use tracing;
@@ -119,43 +118,32 @@ impl BarWidget for Applications {
impl From<&ApplicationsConfig> for Applications {
fn from(applications_config: &ApplicationsConfig) -> Self {
// Allow immediate launch by initializing last_launch in the past.
let last_launch = Instant::now() - 2 * MIN_LAUNCH_INTERVAL;
let mut applications_config = applications_config.clone();
let items = applications_config
.items
.iter_mut()
.iter()
.enumerate()
.map(|(index, app_config)| {
app_config.command = app_config
.command
.replace_env()
.to_string_lossy()
.to_string();
if let Some(icon) = &mut app_config.icon {
*icon = icon.replace_env().to_string_lossy().to_string();
}
.map(|(index, config)| {
let command = UserCommand::new(&config.command);
App {
enable: app_config.enable.unwrap_or(applications_config.enable),
enable: config.enable.unwrap_or(applications_config.enable),
#[allow(clippy::obfuscated_if_else)]
name: app_config
name: config
.name
.is_empty()
.then(|| format!("App {}", index + 1))
.unwrap_or_else(|| app_config.name.clone()),
icon: Icon::try_from(app_config),
command: app_config.command.clone(),
display: app_config
.unwrap_or_else(|| config.name.clone()),
icon: Icon::try_from_path(config.icon.as_deref())
.or_else(|| Icon::try_from_command(&command)),
command,
display: config
.display
.or(applications_config.display)
.unwrap_or_default(),
show_command_on_hover: app_config
show_command_on_hover: config
.show_command_on_hover
.or(applications_config.show_command_on_hover)
.unwrap_or(false),
last_launch,
}
})
.collect();
@@ -178,13 +166,11 @@ pub struct App {
/// Icon to display for this application, if available.
pub icon: Option<Icon>,
/// Command to execute when the application is launched.
pub command: String,
pub command: UserCommand,
/// Display format (icon, text, or both).
pub display: DisplayFormat,
/// Whether to show the launch command on hover.
pub show_command_on_hover: bool,
/// Last time this application was launched (used for cooldown control).
pub last_launch: Instant,
}
impl App {
@@ -206,17 +192,15 @@ impl App {
}
// Add hover text with command information
let response = ui.response();
if self.show_command_on_hover {
ui.response()
.on_hover_text(format!("Launch: {}", self.command));
} else {
ui.response();
response.on_hover_text(format!("Launch: {}", self.command.as_ref()));
}
})
.clicked()
{
// Launch the application when clicked
self.launch_if_ready();
self.command.launch_if_ready();
}
}
@@ -236,84 +220,75 @@ impl App {
fn draw_name(&self, ui: &mut Ui) {
ui.add(Label::new(&self.name).selectable(false));
}
/// Attempts to launch the specified command in a separate thread if enough time has passed
/// since the last launch. This prevents repeated launches from rapid consecutive clicks.
///
/// Errors during launch are logged using the `tracing` crate.
pub fn launch_if_ready(&mut self) {
let now = Instant::now();
if now.duration_since(self.last_launch) < MIN_LAUNCH_INTERVAL {
return;
}
self.last_launch = now;
let command_string = self.command.clone();
// Launch the application in a separate thread to avoid blocking the UI
std::thread::spawn(move || {
if let Err(e) = Command::new("cmd").args(["/C", &command_string]).spawn() {
tracing::error!("Failed to launch command '{}': {}", command_string, e);
}
});
}
}
/// Holds decoded image data to be used as an icon in the UI.
/// Holds image/text data to be used as an icon in the UI.
/// This represents source icon data before rendering.
#[derive(Clone, Debug)]
pub enum Icon {
/// RGBA image used for rendering the icon.
Image(RgbaImage),
Image(ImageIcon),
/// Text-based icon, e.g. from a font like Nerd Fonts.
Text(String),
}
impl Icon {
/// Attempts to create an `Icon` from the given `AppConfig`.
/// Loads the image from a specified icon path or extracts it from the application's
/// executable if the command points to a valid executable file.
/// Attempts to create an [`Icon`] from a string path or text glyph/glyphs.
///
/// - Environment variables in the path are resolved using [`PathExt::replace_env`].
/// - Uses [`ImageIcon::try_load`] to load and cache the icon image based on the resolved path.
/// - If the path is invalid but the string is non-empty, it is interpreted as a text-based icon and
/// returned as [`Icon::Text`].
/// - Returns `None` if the input is empty, `None`, or image loading fails.
#[inline]
pub fn try_from(config: &AppConfig) -> Option<Self> {
if let Some(icon) = config.icon.as_deref().map(str::trim) {
if !icon.is_empty() {
let path = Path::new(&icon);
if path.is_file() {
match image::open(path).as_ref().map(DynamicImage::to_rgba8) {
Ok(image) => return Some(Icon::Image(image)),
Err(err) => {
tracing::error!("Failed to load icon from {}, error: {}", icon, err)
}
}
} else {
return Some(Icon::Text(icon.to_owned()));
}
pub fn try_from_path(icon: Option<&str>) -> Option<Self> {
let icon = icon.map(str::trim)?;
if icon.is_empty() {
return None;
}
let path = icon.replace_env();
if !path.is_file() {
return Some(Icon::Text(icon.to_owned()));
}
let image_icon = ImageIcon::try_load(path.as_ref(), || match image::open(&path) {
Ok(img) => Some(img),
Err(err) => {
tracing::error!("Failed to load icon from {:?}, error: {}", path, err);
None
}
}
})?;
let binary = PathBuf::from(config.command.split(".exe").next()?);
let path = if binary.is_file() {
Some(binary)
} else {
which(binary).ok()
};
match path {
Some(path) => windows_icons::get_icon_by_path(&path.to_string_lossy())
.or_else(|| windows_icons_fallback::get_icon_by_path(&path.to_string_lossy()))
.map(Icon::Image),
None => None,
}
Some(Icon::Image(image_icon))
}
/// Renders the icon in the given `Ui` context with the specified size.
/// Attempts to create an [`Icon`] by extracting an image from the executable path of a [`UserCommand`].
///
/// - Uses [`ImageIcon::try_load`] to load and cache the icon image based on the resolved executable path.
/// - Returns [`Icon::Image`] if an icon is successfully extracted.
/// - Returns `None` if the executable path is unavailable or icon extraction fails.
#[inline]
pub fn try_from_command(command: &UserCommand) -> Option<Self> {
let path = command.get_executable()?;
let image_icon = ImageIcon::try_load(path.as_ref(), || {
let path_str = path.to_str()?;
windows_icons::get_icon_by_path(path_str)
.or_else(|| windows_icons_fallback::get_icon_by_path(path_str))
})?;
Some(Icon::Image(image_icon))
}
/// Renders the icon in the given [`Ui`] using the provided [`IconConfig`].
#[inline]
pub fn draw(&self, ctx: &Context, ui: &mut Ui, icon_config: &IconConfig) {
match self {
Icon::Image(image) => {
Icon::Image(image_icon) => {
Frame::NONE
.inner_margin(Margin::same(ui.style().spacing.button_padding.y as i8))
.show(ui, |ui| {
ui.add(
Image::from(&img_to_texture(ctx, image))
Image::from_texture(&image_icon.texture(ctx))
.maintain_aspect_ratio(true)
.fit_to_exact_size(Vec2::splat(icon_config.size)),
);
@@ -355,3 +330,77 @@ pub struct IconConfig {
/// Color of the icon used for text-based icons
pub color: Color32,
}
/// A structure to manage command execution with cooldown prevention.
#[derive(Clone, Debug)]
pub struct UserCommand {
/// The command string to execute
pub command: Arc<str>,
/// Last time this command was executed (used for cooldown control)
pub last_launch: Instant,
}
impl AsRef<str> for UserCommand {
#[inline]
fn as_ref(&self) -> &str {
&self.command
}
}
impl UserCommand {
/// Creates a new [`UserCommand`] with environment variables in the command path
/// resolved using [`PathExt::replace_env`].
#[inline]
pub fn new(command: &str) -> Self {
// Allow immediate launch by initializing last_launch in the past
let last_launch = Instant::now() - 2 * MIN_LAUNCH_INTERVAL;
Self {
command: Arc::from(command.replace_env().to_str().unwrap_or_default()),
last_launch,
}
}
/// Attempts to resolve the executable path from the command string.
///
/// Resolution logic:
/// - Splits the command by ".exe" and checks if the first part is an existing file.
/// - If not, attempts to locate the binary using [`which`] on this name.
/// - If still unresolved, takes the first word (separated by whitespace) and attempts
/// to find it in the system `PATH` using [`which`].
///
/// Returns `None` if no executable path can be determined.
#[inline]
pub fn get_executable(&self) -> Option<Cow<'_, Path>> {
if let Some(binary) = self.command.split(".exe").next().map(Path::new) {
if binary.is_file() {
return Some(Cow::Borrowed(binary));
} else if let Ok(binary) = which(binary) {
return Some(Cow::Owned(binary));
}
}
which(self.command.split(' ').next()?).ok().map(Cow::Owned)
}
/// Attempts to launch the specified command in a separate thread if enough time has passed
/// since the last launch. This prevents repeated launches from rapid consecutive clicks.
///
/// Errors during launch are logged using the `tracing` crate.
pub fn launch_if_ready(&mut self) {
let now = Instant::now();
// Check if enough time has passed since the last launch
if now.duration_since(self.last_launch) < MIN_LAUNCH_INTERVAL {
return;
}
self.last_launch = now;
let command_string = self.command.clone();
// Launch the application in a separate thread to avoid blocking the UI
std::thread::spawn(move || {
if let Err(e) = Command::new("cmd").args(["/C", &command_string]).spawn() {
tracing::error!("Failed to launch command '{}': {}", command_string, e);
}
});
}
}

View File

@@ -1,3 +1,4 @@
use super::ImageIcon;
use crate::bar::apply_theme;
use crate::config::DisplayFormat;
use crate::config::KomobarTheme;
@@ -8,14 +9,12 @@ use crate::selected_frame::SelectableFrame;
use crate::ui::CustomUi;
use crate::widgets::komorebi_layout::KomorebiLayout;
use crate::widgets::widget::BarWidget;
use crate::ICON_CACHE;
use crate::MAX_LABEL_WIDTH;
use crate::MONITOR_INDEX;
use eframe::egui::text::LayoutJob;
use eframe::egui::vec2;
use eframe::egui::Align;
use eframe::egui::Color32;
use eframe::egui::ColorImage;
use eframe::egui::Context;
use eframe::egui::CornerRadius;
use eframe::egui::Frame;
@@ -27,11 +26,8 @@ use eframe::egui::Sense;
use eframe::egui::Stroke;
use eframe::egui::StrokeKind;
use eframe::egui::TextFormat;
use eframe::egui::TextureHandle;
use eframe::egui::TextureOptions;
use eframe::egui::Ui;
use eframe::egui::Vec2;
use image::RgbaImage;
use komorebi_client::Container;
use komorebi_client::NotificationEvent;
use komorebi_client::PathExt;
@@ -233,7 +229,7 @@ impl BarWidget for Komorebi {
for (is_focused, container) in containers {
for icon in container.icons.iter().flatten().collect::<Vec<_>>() {
ui.add(
Image::from(&img_to_texture(ctx, icon))
Image::from(&icon.texture(ctx))
.maintain_aspect_ratio(true)
.fit_to_exact_size(if *is_focused { icon_size } else { text_size }),
);
@@ -604,7 +600,7 @@ impl BarWidget for Komorebi {
))
.show(ui, |ui| {
let response = ui.add(
Image::from(&img_to_texture(ctx, img))
Image::from(&img.texture(ctx) )
.maintain_aspect_ratio(true)
.fit_to_exact_size(icon_size),
);
@@ -670,13 +666,6 @@ impl BarWidget for Komorebi {
}
}
pub(super) fn img_to_texture(ctx: &Context, rgba_image: &RgbaImage) -> TextureHandle {
let size = [rgba_image.width() as usize, rgba_image.height() as usize];
let pixels = rgba_image.as_flat_samples();
let color_image = ColorImage::from_rgba_unmultiplied(size, pixels.as_slice());
ctx.load_texture("icon", color_image, TextureOptions::default())
}
#[allow(clippy::type_complexity)]
#[derive(Clone, Debug)]
pub struct KomorebiNotificationState {
@@ -868,7 +857,7 @@ impl KomorebiNotificationState {
#[derive(Clone, Debug)]
pub struct KomorebiNotificationStateContainerInformation {
pub titles: Vec<String>,
pub icons: Vec<Option<RgbaImage>>,
pub icons: Vec<Option<ImageIcon>>,
pub focused_window_idx: usize,
}
@@ -895,34 +884,17 @@ impl From<&Workspace> for KomorebiNotificationStateContainerInformation {
impl From<&Container> for KomorebiNotificationStateContainerInformation {
fn from(value: &Container) -> Self {
let windows = value.windows().iter().collect::<Vec<_>>();
let mut icons = vec![];
for window in windows {
let mut icon_cache = ICON_CACHE.lock().unwrap();
let mut update_cache = false;
let hwnd = window.hwnd;
match icon_cache.get(&hwnd) {
None => {
let icon = match windows_icons::get_icon_by_hwnd(window.hwnd) {
None => windows_icons_fallback::get_icon_by_process_id(window.process_id()),
Some(icon) => Some(icon),
};
icons.push(icon);
update_cache = true;
}
Some(icon) => {
icons.push(Some(icon.clone()));
}
}
if update_cache {
if let Some(Some(icon)) = icons.last() {
icon_cache.insert(hwnd, icon.clone());
}
}
}
let icons = windows
.iter()
.map(|window| {
ImageIcon::try_load(window.hwnd, || {
windows_icons::get_icon_by_hwnd(window.hwnd).or_else(|| {
windows_icons_fallback::get_icon_by_process_id(window.process_id())
})
})
})
.collect::<Vec<_>>();
Self {
titles: value
@@ -938,35 +910,14 @@ impl From<&Container> for KomorebiNotificationStateContainerInformation {
impl From<&Window> for KomorebiNotificationStateContainerInformation {
fn from(value: &Window) -> Self {
let mut icon_cache = ICON_CACHE.lock().unwrap();
let mut update_cache = false;
let mut icons = vec![];
let hwnd = value.hwnd;
match icon_cache.get(&hwnd) {
None => {
let icon = match windows_icons::get_icon_by_hwnd(hwnd) {
None => windows_icons_fallback::get_icon_by_process_id(value.process_id()),
Some(icon) => Some(icon),
};
icons.push(icon);
update_cache = true;
}
Some(icon) => {
icons.push(Some(icon.clone()));
}
}
if update_cache {
if let Some(Some(icon)) = icons.last() {
icon_cache.insert(hwnd, icon.clone());
}
}
let icons = ImageIcon::try_load(value.hwnd, || {
windows_icons::get_icon_by_hwnd(value.hwnd)
.or_else(|| windows_icons_fallback::get_icon_by_process_id(value.process_id()))
});
Self {
titles: vec![value.title().unwrap_or_default()],
icons,
icons: vec![icons],
focused_window_idx: 0,
}
}

View File

@@ -1,3 +1,14 @@
use eframe::egui::ColorImage;
use eframe::egui::Context;
use eframe::egui::TextureHandle;
use eframe::egui::TextureOptions;
use image::RgbaImage;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::sync::LazyLock;
use std::sync::RwLock;
pub mod applications;
pub mod battery;
pub mod cpu;
@@ -12,3 +23,151 @@ pub mod storage;
pub mod time;
pub mod update;
pub mod widget;
/// Global cache for icon images and their associated GPU textures.
pub static ICONS_CACHE: IconsCache = IconsCache::new();
/// In-memory cache for icon images and their associated GPU textures.
///
/// Stores raw [`ColorImage`]s and [`TextureHandle`]s keyed by [`ImageIconId`].
/// Texture entries are context-dependent and automatically invalidated when the [`Context`] changes.
#[allow(clippy::type_complexity)]
pub struct IconsCache {
textures: LazyLock<RwLock<(Option<Context>, HashMap<ImageIconId, TextureHandle>)>>,
images: LazyLock<RwLock<HashMap<ImageIconId, Arc<ColorImage>>>>,
}
impl IconsCache {
/// Creates a new empty IconsCache instance.
#[inline]
pub const fn new() -> Self {
Self {
textures: LazyLock::new(|| RwLock::new((None, HashMap::new()))),
images: LazyLock::new(|| RwLock::new(HashMap::new())),
}
}
/// Retrieves or creates a texture handle for the given icon ID and image.
///
/// If a texture for the given ID already exists for the current [`Context`], it is reused.
/// Otherwise, a new texture is created, inserted into the cache, and returned.
/// The cache is reset if the [`Context`] has changed.
#[inline]
pub fn texture(&self, ctx: &Context, id: &ImageIconId, img: &Arc<ColorImage>) -> TextureHandle {
if let Some(texture) = self.get_texture(ctx, id) {
return texture;
}
let texture_handle = ctx.load_texture("icon", img.clone(), TextureOptions::default());
self.insert_texture(ctx, id.clone(), texture_handle.clone());
texture_handle
}
/// Returns the cached texture for the given icon ID if it exists and matches the current [`Context`].
pub fn get_texture(&self, ctx: &Context, id: &ImageIconId) -> Option<TextureHandle> {
let textures_lock = self.textures.read().unwrap();
if textures_lock.0.as_ref() == Some(ctx) {
return textures_lock.1.get(id).cloned();
}
None
}
/// Inserts a texture handle, resetting the cache if the [`Context`] has changed.
pub fn insert_texture(&self, ctx: &Context, id: ImageIconId, texture: TextureHandle) {
let mut textures_lock = self.textures.write().unwrap();
if textures_lock.0.as_ref() != Some(ctx) {
textures_lock.0 = Some(ctx.clone());
textures_lock.1.clear();
}
textures_lock.1.insert(id, texture);
}
/// Returns the cached image for the given icon ID, if available.
pub fn get_image(&self, id: &ImageIconId) -> Option<Arc<ColorImage>> {
self.images.read().unwrap().get(id).cloned()
}
/// Caches a raw [`ColorImage`] associated with the given icon ID.
pub fn insert_image(&self, id: ImageIconId, image: Arc<ColorImage>) {
self.images.write().unwrap().insert(id, image);
}
}
#[inline]
fn rgba_to_color_image(rgba_image: &RgbaImage) -> ColorImage {
let size = [rgba_image.width() as usize, rgba_image.height() as usize];
let pixels = rgba_image.as_flat_samples();
ColorImage::from_rgba_unmultiplied(size, pixels.as_slice())
}
/// Represents an image-based icon with a unique ID and pixel data.
#[derive(Clone, Debug)]
pub struct ImageIcon {
/// Unique identifier for the image icon, used for texture caching.
pub id: ImageIconId,
/// Shared pixel data of the icon in `ColorImage` format.
pub image: Arc<ColorImage>,
}
impl ImageIcon {
/// Creates a new [`ImageIcon`] from the given ID and image data.
#[inline]
pub fn new(id: ImageIconId, image: Arc<ColorImage>) -> Self {
Self { id, image }
}
/// Loads an [`ImageIcon`] from [`ICONS_CACHE`] or calls `loader` if not cached.
/// The loaded image is converted to a [`ColorImage`], cached, and returned.
#[inline]
pub fn try_load<F, I>(id: impl Into<ImageIconId>, loader: F) -> Option<Self>
where
F: FnOnce() -> Option<I>,
I: Into<RgbaImage>,
{
let id = id.into();
let image = ICONS_CACHE.get_image(&id).or_else(|| {
let img = loader()?;
let img = Arc::new(rgba_to_color_image(&img.into()));
ICONS_CACHE.insert_image(id.clone(), img.clone());
Some(img)
})?;
Some(ImageIcon::new(id, image))
}
/// Returns a texture handle for the icon, using the given [`Context`].
///
/// If the texture is already cached in [`ICONS_CACHE`], it is reused.
/// Otherwise, a new texture is created from the [`ColorImage`] and cached.
#[inline]
pub fn texture(&self, ctx: &Context) -> TextureHandle {
ICONS_CACHE.texture(ctx, &self.id, &self.image)
}
}
/// Unique identifier for an image-based icon.
///
/// Used to distinguish cached images and textures by either a file path
/// or a Windows window handle.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum ImageIconId {
/// Identifier based on a file system path.
Path(Arc<Path>),
/// Windows HWND handle.
Hwnd(isize),
}
impl From<&Path> for ImageIconId {
#[inline]
fn from(value: &Path) -> Self {
Self::Path(value.into())
}
}
impl From<isize> for ImageIconId {
#[inline]
fn from(value: isize) -> Self {
Self::Hwnd(value)
}
}