From 10424b696f4abcc80b9c1931600d9fc54b290e0b Mon Sep 17 00:00:00 2001 From: Alisher Galiev Date: Mon, 21 Apr 2025 09:17:25 +0500 Subject: [PATCH] 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 --- komorebi-bar/src/widgets/applications.rs | 333 ++++++++++++++++++++++ komorebi-bar/src/widgets/komorebi.rs | 2 +- komorebi-bar/src/widgets/mod.rs | 1 + komorebi-bar/src/widgets/widget.rs | 5 + schema.bar.json | 348 +++++++++++++++++++++++ 5 files changed, 688 insertions(+), 1 deletion(-) create mode 100644 komorebi-bar/src/widgets/applications.rs diff --git a/komorebi-bar/src/widgets/applications.rs b/komorebi-bar/src/widgets/applications.rs new file mode 100644 index 00000000..16ee2ade --- /dev/null +++ b/komorebi-bar/src/widgets/applications.rs @@ -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, + /// Horizontal spacing between application buttons. + pub spacing: Option, + /// Default display format for all applications (optional). + /// Could be overridden per application. Defaults to `Icon`. + pub display: Option, + /// List of configured applications to display. + pub items: Vec, +} + +#[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, + /// Whether to show the launch command on hover (optional). + /// Inherits from the global `Applications` setting if omitted. + pub show_command_on_hover: Option, + /// 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, + /// 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, +} + +#[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, + /// Applications to be rendered in the UI. + pub items: Vec, +} + +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, + /// 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 { + 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, +} diff --git a/komorebi-bar/src/widgets/komorebi.rs b/komorebi-bar/src/widgets/komorebi.rs index 36bc7ef9..04ddd29b 100644 --- a/komorebi-bar/src/widgets/komorebi.rs +++ b/komorebi-bar/src/widgets/komorebi.rs @@ -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()); diff --git a/komorebi-bar/src/widgets/mod.rs b/komorebi-bar/src/widgets/mod.rs index 478960bf..17e9ee82 100644 --- a/komorebi-bar/src/widgets/mod.rs +++ b/komorebi-bar/src/widgets/mod.rs @@ -1,3 +1,4 @@ +pub mod applications; pub mod battery; pub mod cpu; pub mod date; diff --git a/komorebi-bar/src/widgets/widget.rs b/komorebi-bar/src/widgets/widget.rs index 630a5808..a111d6e2 100644 --- a/komorebi-bar/src/widgets/widget.rs +++ b/komorebi-bar/src/widgets/widget.rs @@ -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 { 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, diff --git a/schema.bar.json b/schema.bar.json index 694d6d81..1f1bd4f9 100644 --- a/schema.bar.json +++ b/schema.bar.json @@ -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": [