feat(bar): add applications widget

This pull request introduces a new Applications widget that displays a
user-defined list of application launchers in the UI. Each app entry
supports an icon, a label, and executes its configured command on click.

The design of this widget is inspired by the Applications Widget of YASB
Reborn. I personally missed this functionality and aimed to bring a
similar experience to komorebi-bar.

Further information is in the text of PR #1415
This commit is contained in:
Alisher Galiev
2025-04-21 09:17:25 +05:00
committed by LGUG2Z
parent 6e7d8fb922
commit 10424b696f
5 changed files with 688 additions and 1 deletions

View File

@@ -0,0 +1,333 @@
use super::komorebi::img_to_texture;
use crate::render::RenderConfig;
use crate::selected_frame::SelectableFrame;
use crate::widgets::widget::BarWidget;
use eframe::egui::vec2;
use eframe::egui::Color32;
use eframe::egui::Context;
use eframe::egui::CornerRadius;
use eframe::egui::FontId;
use eframe::egui::Frame;
use eframe::egui::Image;
use eframe::egui::Label;
use eframe::egui::Margin;
use eframe::egui::RichText;
use eframe::egui::Sense;
use eframe::egui::Stroke;
use eframe::egui::StrokeKind;
use eframe::egui::Ui;
use eframe::egui::Vec2;
use image::DynamicImage;
use image::RgbaImage;
use serde::Deserialize;
use serde::Serialize;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use std::time::Instant;
use tracing;
/// Minimum interval between consecutive application launches to prevent accidental spamming.
const MIN_LAUNCH_INTERVAL: Duration = Duration::from_millis(800);
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ApplicationsConfig {
/// Enables or disables the applications widget.
pub enable: bool,
/// Whether to show the launch command on hover (optional).
/// Could be overridden per application. Defaults to `false` if not set.
pub show_command_on_hover: Option<bool>,
/// Horizontal spacing between application buttons.
pub spacing: Option<f32>,
/// Default display format for all applications (optional).
/// Could be overridden per application. Defaults to `Icon`.
pub display: Option<DisplayFormat>,
/// List of configured applications to display.
pub items: Vec<AppConfig>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct AppConfig {
/// Whether to enable this application button (optional).
/// Inherits from the global `Applications` setting if omitted.
pub enable: Option<bool>,
/// Whether to show the launch command on hover (optional).
/// Inherits from the global `Applications` setting if omitted.
pub show_command_on_hover: Option<bool>,
/// Display name of the application.
pub name: String,
/// Optional icon: a path to an image or a text-based glyph (e.g., from Nerd Fonts).
/// If not set, and if the `command` is a path to an executable, an icon might be extracted from it.
/// Note: glyphs require a compatible `font_family`.
pub icon: Option<String>,
/// Command to execute (e.g. path to the application or shell command).
pub command: String,
/// Display format for this application button (optional). Overrides global format if set.
pub display: Option<DisplayFormat>,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum DisplayFormat {
/// Show only the application icon.
#[default]
Icon,
/// Show only the application name as text.
Text,
/// Show both the application icon and name.
IconAndText,
}
#[derive(Clone, Debug)]
pub struct Applications {
/// Whether the applications widget is enabled.
pub enable: bool,
/// Horizontal spacing between application buttons.
pub spacing: Option<f32>,
/// Applications to be rendered in the UI.
pub items: Vec<App>,
}
impl BarWidget for Applications {
fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) {
if !self.enable {
return;
}
let icon_config = IconConfig {
font_id: config.icon_font_id.clone(),
size: config.icon_font_id.size,
color: ctx.style().visuals.selection.stroke.color,
};
if let Some(spacing) = self.spacing {
ui.spacing_mut().item_spacing.x = spacing;
}
config.apply_on_widget(false, ui, |ui| {
for app in &mut self.items {
app.render(ctx, ui, &icon_config);
}
});
}
}
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 items = applications_config
.items
.iter()
.enumerate()
.map(|(index, app_config)| App {
enable: app_config.enable.unwrap_or(applications_config.enable),
name: app_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
.display
.or(applications_config.display)
.unwrap_or_default(),
show_command_on_hover: app_config
.show_command_on_hover
.or(applications_config.show_command_on_hover)
.unwrap_or(false),
last_launch,
})
.collect();
Self {
enable: applications_config.enable,
items,
spacing: applications_config.spacing,
}
}
}
/// A single resolved application entry used at runtime.
#[derive(Clone, Debug)]
pub struct App {
/// Whether this application is enabled.
pub enable: bool,
/// Display name of the application. Defaults to "App N" if not set.
pub name: String,
/// Icon to display for this application, if available.
pub icon: Option<Icon>,
/// Command to execute when the application is launched.
pub command: String,
/// 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 {
/// Renders the application button in the provided `Ui` context with a given icon size.
#[inline]
pub fn render(&mut self, ctx: &Context, ui: &mut Ui, icon_config: &IconConfig) {
if self.enable
&& SelectableFrame::new(false)
.show(ui, |ui| {
ui.spacing_mut().item_spacing = Vec2::splat(4.0);
match self.display {
DisplayFormat::Icon => self.draw_icon(ctx, ui, icon_config),
DisplayFormat::Text => self.draw_name(ui),
DisplayFormat::IconAndText => {
self.draw_icon(ctx, ui, icon_config);
self.draw_name(ui);
}
}
// Add hover text with command information
if self.show_command_on_hover {
ui.response()
.on_hover_text(format!("Launch: {}", self.command));
} else {
ui.response();
}
})
.clicked()
{
// Launch the application when clicked
self.launch_if_ready();
}
}
/// Draws the application's icon within the UI if available,
/// or falls back to a default placeholder icon.
#[inline]
fn draw_icon(&self, ctx: &Context, ui: &mut Ui, icon_config: &IconConfig) {
if let Some(icon) = &self.icon {
icon.draw(ctx, ui, icon_config);
} else {
Icon::draw_fallback(ui, Vec2::splat(icon_config.size));
}
}
/// Displays the application's name as a non-selectable label within the UI.
#[inline]
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.
#[derive(Clone, Debug)]
pub enum Icon {
/// RGBA image used for rendering the icon.
Image(RgbaImage),
/// 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.
#[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()));
}
}
}
if Path::new(&config.command).is_file() {
return windows_icons::get_icon_by_path(&config.command)
.or_else(|| windows_icons_fallback::get_icon_by_path(&config.command))
.map(Icon::Image);
}
None
}
/// Renders the icon in the given `Ui` context with the specified size.
#[inline]
pub fn draw(&self, ctx: &Context, ui: &mut Ui, icon_config: &IconConfig) {
match self {
Icon::Image(image) => {
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))
.maintain_aspect_ratio(true)
.fit_to_exact_size(Vec2::splat(icon_config.size)),
);
});
}
Icon::Text(icon) => {
let rich_text = RichText::new(icon)
.font(icon_config.font_id.clone())
.size(icon_config.size)
.color(icon_config.color);
ui.add(Label::new(rich_text).selectable(false));
}
}
}
/// Draws a fallback icon when the specified icon cannot be loaded.
/// Displays a simple crossed-out rectangle as a placeholder.
#[inline]
pub fn draw_fallback(ui: &mut Ui, icon_size: Vec2) {
let (response, painter) = ui.allocate_painter(icon_size, Sense::hover());
let stroke = Stroke::new(1.0, ui.style().visuals.text_color());
let mut rect = response.rect;
let rounding = CornerRadius::same((rect.width() * 0.1) as u8);
rect = rect.shrink(stroke.width);
let c = rect.center();
let r = rect.width() / 2.0;
painter.rect_stroke(rect, rounding, stroke, StrokeKind::Outside);
painter.line_segment([c - vec2(r, r), c + vec2(r, r)], stroke);
}
}
/// Configuration structure for icon rendering
#[derive(Clone, Debug)]
pub struct IconConfig {
/// Font used for text-based icons
pub font_id: FontId,
/// Size of the icon
pub size: f32,
/// Color of the icon used for text-based icons
pub color: Color32,
}

View File

@@ -670,7 +670,7 @@ impl BarWidget for Komorebi {
}
}
fn img_to_texture(ctx: &Context, rgba_image: &RgbaImage) -> TextureHandle {
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());

View File

@@ -1,3 +1,4 @@
pub mod applications;
pub mod battery;
pub mod cpu;
pub mod date;

View File

@@ -1,4 +1,6 @@
use crate::render::RenderConfig;
use crate::widgets::applications::Applications;
use crate::widgets::applications::ApplicationsConfig;
use crate::widgets::battery::Battery;
use crate::widgets::battery::BatteryConfig;
use crate::widgets::cpu::Cpu;
@@ -33,6 +35,7 @@ pub trait BarWidget {
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum WidgetConfig {
Applications(ApplicationsConfig),
Battery(BatteryConfig),
Cpu(CpuConfig),
Date(DateConfig),
@@ -49,6 +52,7 @@ pub enum WidgetConfig {
impl WidgetConfig {
pub fn as_boxed_bar_widget(&self) -> Box<dyn BarWidget> {
match self {
WidgetConfig::Applications(config) => Box::new(Applications::from(config)),
WidgetConfig::Battery(config) => Box::new(Battery::from(*config)),
WidgetConfig::Cpu(config) => Box::new(Cpu::from(*config)),
WidgetConfig::Date(config) => Box::new(Date::from(config.clone())),
@@ -65,6 +69,7 @@ impl WidgetConfig {
pub fn enabled(&self) -> bool {
match self {
WidgetConfig::Applications(config) => config.enable,
WidgetConfig::Battery(config) => config.enable,
WidgetConfig::Cpu(config) => config.enable,
WidgetConfig::Date(config) => config.enable,

View File

@@ -14,6 +14,122 @@
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"required": [
"Applications"
],
"properties": {
"Applications": {
"type": "object",
"required": [
"enable",
"items"
],
"properties": {
"display": {
"description": "Default display format for all applications (optional). Could be overridden per application. Defaults to `Icon`.",
"oneOf": [
{
"description": "Show only the application icon.",
"type": "string",
"enum": [
"Icon"
]
},
{
"description": "Show only the application name as text.",
"type": "string",
"enum": [
"Text"
]
},
{
"description": "Show both the application icon and name.",
"type": "string",
"enum": [
"IconAndText"
]
}
]
},
"enable": {
"description": "Enables or disables the applications widget.",
"type": "boolean"
},
"items": {
"description": "List of configured applications to display.",
"type": "array",
"items": {
"type": "object",
"required": [
"command",
"name"
],
"properties": {
"command": {
"description": "Command to execute (e.g. path to the application or shell command).",
"type": "string"
},
"display": {
"description": "Display format for this application button (optional). Overrides global format if set.",
"oneOf": [
{
"description": "Show only the application icon.",
"type": "string",
"enum": [
"Icon"
]
},
{
"description": "Show only the application name as text.",
"type": "string",
"enum": [
"Text"
]
},
{
"description": "Show both the application icon and name.",
"type": "string",
"enum": [
"IconAndText"
]
}
]
},
"enable": {
"description": "Whether to enable this application button (optional). Inherits from the global `Applications` setting if omitted.",
"type": "boolean"
},
"icon": {
"description": "Optional icon: a path to an image or a text-based glyph (e.g., from Nerd Fonts). If not set, and if the `command` is a path to an executable, an icon might be extracted from it. Note: glyphs require a compatible `font_family`.",
"type": "string"
},
"name": {
"description": "Display name of the application.",
"type": "string"
},
"show_command_on_hover": {
"description": "Whether to show the launch command on hover (optional). Inherits from the global `Applications` setting if omitted.",
"type": "boolean"
}
}
}
},
"show_command_on_hover": {
"description": "Whether to show the launch command on hover (optional). Could be overridden per application. Defaults to `false` if not set.",
"type": "boolean"
},
"spacing": {
"description": "Horizontal spacing between application buttons.",
"type": "number",
"format": "float"
}
}
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
@@ -1541,6 +1657,122 @@
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"required": [
"Applications"
],
"properties": {
"Applications": {
"type": "object",
"required": [
"enable",
"items"
],
"properties": {
"display": {
"description": "Default display format for all applications (optional). Could be overridden per application. Defaults to `Icon`.",
"oneOf": [
{
"description": "Show only the application icon.",
"type": "string",
"enum": [
"Icon"
]
},
{
"description": "Show only the application name as text.",
"type": "string",
"enum": [
"Text"
]
},
{
"description": "Show both the application icon and name.",
"type": "string",
"enum": [
"IconAndText"
]
}
]
},
"enable": {
"description": "Enables or disables the applications widget.",
"type": "boolean"
},
"items": {
"description": "List of configured applications to display.",
"type": "array",
"items": {
"type": "object",
"required": [
"command",
"name"
],
"properties": {
"command": {
"description": "Command to execute (e.g. path to the application or shell command).",
"type": "string"
},
"display": {
"description": "Display format for this application button (optional). Overrides global format if set.",
"oneOf": [
{
"description": "Show only the application icon.",
"type": "string",
"enum": [
"Icon"
]
},
{
"description": "Show only the application name as text.",
"type": "string",
"enum": [
"Text"
]
},
{
"description": "Show both the application icon and name.",
"type": "string",
"enum": [
"IconAndText"
]
}
]
},
"enable": {
"description": "Whether to enable this application button (optional). Inherits from the global `Applications` setting if omitted.",
"type": "boolean"
},
"icon": {
"description": "Optional icon: a path to an image or a text-based glyph (e.g., from Nerd Fonts). If not set, and if the `command` is a path to an executable, an icon might be extracted from it. Note: glyphs require a compatible `font_family`.",
"type": "string"
},
"name": {
"description": "Display name of the application.",
"type": "string"
},
"show_command_on_hover": {
"description": "Whether to show the launch command on hover (optional). Inherits from the global `Applications` setting if omitted.",
"type": "boolean"
}
}
}
},
"show_command_on_hover": {
"description": "Whether to show the launch command on hover (optional). Could be overridden per application. Defaults to `false` if not set.",
"type": "boolean"
},
"spacing": {
"description": "Horizontal spacing between application buttons.",
"type": "number",
"format": "float"
}
}
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [
@@ -3001,6 +3233,122 @@
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"required": [
"Applications"
],
"properties": {
"Applications": {
"type": "object",
"required": [
"enable",
"items"
],
"properties": {
"display": {
"description": "Default display format for all applications (optional). Could be overridden per application. Defaults to `Icon`.",
"oneOf": [
{
"description": "Show only the application icon.",
"type": "string",
"enum": [
"Icon"
]
},
{
"description": "Show only the application name as text.",
"type": "string",
"enum": [
"Text"
]
},
{
"description": "Show both the application icon and name.",
"type": "string",
"enum": [
"IconAndText"
]
}
]
},
"enable": {
"description": "Enables or disables the applications widget.",
"type": "boolean"
},
"items": {
"description": "List of configured applications to display.",
"type": "array",
"items": {
"type": "object",
"required": [
"command",
"name"
],
"properties": {
"command": {
"description": "Command to execute (e.g. path to the application or shell command).",
"type": "string"
},
"display": {
"description": "Display format for this application button (optional). Overrides global format if set.",
"oneOf": [
{
"description": "Show only the application icon.",
"type": "string",
"enum": [
"Icon"
]
},
{
"description": "Show only the application name as text.",
"type": "string",
"enum": [
"Text"
]
},
{
"description": "Show both the application icon and name.",
"type": "string",
"enum": [
"IconAndText"
]
}
]
},
"enable": {
"description": "Whether to enable this application button (optional). Inherits from the global `Applications` setting if omitted.",
"type": "boolean"
},
"icon": {
"description": "Optional icon: a path to an image or a text-based glyph (e.g., from Nerd Fonts). If not set, and if the `command` is a path to an executable, an icon might be extracted from it. Note: glyphs require a compatible `font_family`.",
"type": "string"
},
"name": {
"description": "Display name of the application.",
"type": "string"
},
"show_command_on_hover": {
"description": "Whether to show the launch command on hover (optional). Inherits from the global `Applications` setting if omitted.",
"type": "boolean"
}
}
}
},
"show_command_on_hover": {
"description": "Whether to show the launch command on hover (optional). Could be overridden per application. Defaults to `false` if not set.",
"type": "boolean"
},
"spacing": {
"description": "Horizontal spacing between application buttons.",
"type": "number",
"format": "float"
}
}
}
},
"additionalProperties": false
},
{
"type": "object",
"required": [