Files
komorebi/komorebi-bar/src/bar.rs
T
Csaba ea35d818a1 feat(bar): windows systray widget
A new System Tray widget has been added to komorebi-bar, bringing native
Windows system tray functionality directly into the bar.

Special thanks to the Zebar project and its contributors for developing
the systray-util crate library that makes Windows system tray
integration possible.

The widget intercepts system tray icon data by creating a hidden "spy"
window that mimics the Windows taskbar. When applications use the
Shell_NotifyIcon API to manage their tray icons, the widget receives the
same broadcast messages, allowing it to monitor all system tray activity
while forwarding messages to the real taskbar to avoid disruption.

Users can configure which icons to hide using flexible rules. A plain
string matches by exe name (case-insensitive). A structured object can
match on exe, tooltip, and/or GUID fields using AND logic. Each field
supports matching strategies from komorebi's window rules (Equals,
StartsWith, EndsWith, Contains, Regex, and their negated variants),
allowing precise filtering even when multiple icons share the same exe
and GUID. An info button opens a floating panel listing all icons with
their properties and copy buttons, making it easy to identify which
values to use in filter rules.

The widget fully supports mouse interactions including left-click,
right-click, middle-click, and double-click actions on tray icons.
Double-click support uses the LeftDoubleClick action from systray-util
0.2.0, which sends WM_LBUTTONDBLCLK and NIN_SELECT messages. It handles
right-aligned placement correctly by adjusting the rendering order and
toggle button arrow directions to maintain consistent visual appearance
regardless of which panel the widget is placed in.

Some system tray icons register a click callback but never actually
respond to click messages, effectively becoming "zombie" icons from an
interaction standpoint. The widget includes fallback commands for known
problematic icons that override the native click action with a direct
shell command (e.g. opening Windows Security or volume settings).

The implementation uses a background thread with its own tokio runtime
to handle the async systray events, communicating with the UI thread
through crossbeam channels. Icon images are cached efficiently using
hash-based keys that update when icons change. Rapid icon updates are
deduplicated to prevent UI freezing, and image conversion (RgbaImage to
ColorImage) is performed on the background thread to keep the UI
responsive.

The widget automatically detects and removes stale icons whose owning
process has exited, using the Win32 IsWindow API on a configurable
interval. A manual refresh button is also available for immediate
cleanup.

A shortcuts button can be configured to toggle komorebi-shortcuts by
killing the process if running or starting it otherwise. The refresh,
info, and shortcuts buttons can each be placed in the main visible area
or in the overflow section.
2026-03-21 12:35:39 -07:00

1404 lines
57 KiB
Rust

use crate::AUTO_SELECT_FILL_COLOUR;
use crate::AUTO_SELECT_TEXT_COLOUR;
use crate::BAR_HEIGHT;
use crate::DEFAULT_PADDING;
use crate::KomorebiEvent;
use crate::MAX_LABEL_WIDTH;
use crate::MONITOR_LEFT;
use crate::MONITOR_RIGHT;
use crate::MONITOR_TOP;
use crate::config::KomobarConfig;
use crate::config::KomobarTheme;
use crate::config::MonitorConfigOrIndex;
use crate::config::Position;
use crate::config::PositionConfig;
use crate::config::get_individual_spacing;
use crate::process_hwnd;
use crate::render::Color32Ext;
use crate::render::Grouping;
use crate::render::RenderConfig;
use crate::render::RenderExt;
use crate::take_widget_clicked;
use crate::widgets::komorebi::Komorebi;
use crate::widgets::komorebi::MonitorInfo;
use crate::widgets::widget::BarWidget;
use crate::widgets::widget::WidgetConfig;
use color_eyre::eyre;
use crossbeam_channel::Receiver;
use crossbeam_channel::TryRecvError;
use eframe::egui::Align;
use eframe::egui::Align2;
use eframe::egui::Area;
use eframe::egui::CentralPanel;
use eframe::egui::Color32;
use eframe::egui::Context;
use eframe::egui::FontData;
use eframe::egui::FontDefinitions;
use eframe::egui::FontFamily;
use eframe::egui::FontId;
use eframe::egui::Frame;
use eframe::egui::Id;
use eframe::egui::Layout;
use eframe::egui::Margin;
use eframe::egui::PointerButton;
use eframe::egui::Rgba;
use eframe::egui::Style;
use eframe::egui::TextStyle;
use eframe::egui::Vec2;
use eframe::egui::Visuals;
use font_loader::system_fonts;
use font_loader::system_fonts::FontPropertyBuilder;
use komorebi_client::Colour;
use komorebi_client::MonitorNotification;
use komorebi_client::NotificationEvent;
use komorebi_client::PathExt;
use komorebi_client::SocketMessage;
use komorebi_client::VirtualDesktopNotification;
use komorebi_themes::Base16Wrapper;
use komorebi_themes::Catppuccin;
use komorebi_themes::KomobarThemeBase16;
use komorebi_themes::KomobarThemeCatppuccin;
use komorebi_themes::KomobarThemeCustom;
use komorebi_themes::catppuccin_egui;
use lazy_static::lazy_static;
use parking_lot::Mutex;
use std::cell::RefCell;
use std::collections::HashMap;
use std::io::Error;
use std::io::ErrorKind;
use std::io::Write;
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
use std::process::ChildStdin;
use std::process::Command;
use std::process::Stdio;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::Ordering;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
lazy_static! {
static ref SESSION_STDIN: Mutex<Option<ChildStdin>> = Mutex::new(None);
}
fn start_powershell() -> eyre::Result<()> {
// found running session, do nothing
if SESSION_STDIN.lock().as_mut().is_some() {
tracing::debug!("PowerShell session already started");
return Ok(());
}
tracing::debug!("Starting PowerShell session");
let mut child = Command::new("powershell.exe")
.args(["-NoLogo", "-NoProfile", "-Command", "-"])
.stdin(Stdio::piped())
.creation_flags(CREATE_NO_WINDOW)
.spawn()?;
let stdin = child.stdin.take().expect("stdin piped");
// Store stdin for later commands
let mut session_stdin = SESSION_STDIN.lock();
*session_stdin = Option::from(stdin);
Ok(())
}
fn stop_powershell() -> eyre::Result<()> {
tracing::debug!("Stopping PowerShell session");
if let Some(mut session_stdin) = SESSION_STDIN.lock().take() {
if let Err(e) = session_stdin.write_all(b"exit\n") {
tracing::error!(error = %e, "failed to write exit command to PowerShell stdin");
return Err(e.into());
}
if let Err(e) = session_stdin.flush() {
tracing::error!(error = %e, "failed to flush PowerShell stdin");
return Err(e.into());
}
tracing::debug!("PowerShell session stopped");
} else {
tracing::debug!("PowerShell session already stopped");
}
Ok(())
}
pub fn exec_powershell(cmd: &str) -> eyre::Result<()> {
if let Some(session_stdin) = SESSION_STDIN.lock().as_mut() {
if let Err(e) = writeln!(session_stdin, "{cmd}") {
tracing::error!(error = %e, cmd = cmd, "failed to write command to PowerShell stdin");
return Err(e.into());
}
if let Err(e) = session_stdin.flush() {
tracing::error!(error = %e, "failed to flush PowerShell stdin");
return Err(e.into());
}
return Ok(());
}
Err(Error::new(ErrorKind::NotFound, "PowerShell session not started").into())
}
pub struct Komobar {
pub hwnd: Option<isize>,
pub monitor_index: Option<usize>,
pub disabled: bool,
pub config: KomobarConfig,
pub render_config: Rc<RefCell<RenderConfig>>,
pub monitor_info: Option<Rc<RefCell<MonitorInfo>>>,
pub left_widgets: Vec<Box<dyn BarWidget>>,
pub center_widgets: Vec<Box<dyn BarWidget>>,
pub right_widgets: Vec<Box<dyn BarWidget>>,
pub rx_gui: Receiver<KomorebiEvent>,
pub rx_config: Receiver<KomobarConfig>,
pub bg_color: Rc<RefCell<Color32>>,
pub bg_color_with_alpha: Rc<RefCell<Color32>>,
pub scale_factor: f32,
pub size_rect: komorebi_client::Rect,
pub work_area_offset: komorebi_client::Rect,
applied_theme_on_first_frame: bool,
mouse_follows_focus: bool,
input_config: InputConfig,
}
struct InputConfig {
accumulated_scroll_delta: Vec2,
act_on_vertical_scroll: bool,
act_on_horizontal_scroll: bool,
vertical_scroll_threshold: f32,
horizontal_scroll_threshold: f32,
vertical_scroll_max_threshold: f32,
horizontal_scroll_max_threshold: f32,
}
pub fn apply_theme(
ctx: &Context,
theme: KomobarTheme,
bg_color: Rc<RefCell<Color32>>,
bg_color_with_alpha: Rc<RefCell<Color32>>,
transparency_alpha: Option<u8>,
grouping: Option<Grouping>,
render_config: Rc<RefCell<RenderConfig>>,
) {
let (auto_select_fill, auto_select_text) = match theme {
KomobarTheme::Catppuccin(KomobarThemeCatppuccin {
name: catppuccin,
accent: catppuccin_value,
auto_select_fill: catppuccin_auto_select_fill,
auto_select_text: catppuccin_auto_select_text,
}) => {
match catppuccin {
Catppuccin::Frappe => {
catppuccin_egui::set_theme(ctx, catppuccin_egui::FRAPPE);
let catppuccin_value = catppuccin_value.unwrap_or_default();
let accent = catppuccin_value.color32(catppuccin.as_theme());
ctx.style_mut(|style| {
style.visuals.selection.stroke.color = accent;
style.visuals.widgets.hovered.fg_stroke.color = accent;
style.visuals.widgets.active.fg_stroke.color = accent;
style.visuals.override_text_color = None;
});
bg_color.replace(catppuccin_egui::FRAPPE.base);
}
Catppuccin::Latte => {
catppuccin_egui::set_theme(ctx, catppuccin_egui::LATTE);
let catppuccin_value = catppuccin_value.unwrap_or_default();
let accent = catppuccin_value.color32(catppuccin.as_theme());
ctx.style_mut(|style| {
style.visuals.selection.stroke.color = accent;
style.visuals.widgets.hovered.fg_stroke.color = accent;
style.visuals.widgets.active.fg_stroke.color = accent;
style.visuals.override_text_color = None;
});
bg_color.replace(catppuccin_egui::LATTE.base);
}
Catppuccin::Macchiato => {
catppuccin_egui::set_theme(ctx, catppuccin_egui::MACCHIATO);
let catppuccin_value = catppuccin_value.unwrap_or_default();
let accent = catppuccin_value.color32(catppuccin.as_theme());
ctx.style_mut(|style| {
style.visuals.selection.stroke.color = accent;
style.visuals.widgets.hovered.fg_stroke.color = accent;
style.visuals.widgets.active.fg_stroke.color = accent;
style.visuals.override_text_color = None;
});
bg_color.replace(catppuccin_egui::MACCHIATO.base);
}
Catppuccin::Mocha => {
catppuccin_egui::set_theme(ctx, catppuccin_egui::MOCHA);
let catppuccin_value = catppuccin_value.unwrap_or_default();
let accent = catppuccin_value.color32(catppuccin.as_theme());
ctx.style_mut(|style| {
style.visuals.selection.stroke.color = accent;
style.visuals.widgets.hovered.fg_stroke.color = accent;
style.visuals.widgets.active.fg_stroke.color = accent;
style.visuals.override_text_color = None;
});
bg_color.replace(catppuccin_egui::MOCHA.base);
}
}
(
catppuccin_auto_select_fill.map(|c| c.color32(catppuccin.as_theme())),
catppuccin_auto_select_text.map(|c| c.color32(catppuccin.as_theme())),
)
}
KomobarTheme::Base16(KomobarThemeBase16 {
name: base16,
accent: base16_value,
auto_select_fill: base16_auto_select_fill,
auto_select_text: base16_auto_select_text,
}) => {
ctx.set_style(base16.style());
let base16_value = base16_value.unwrap_or_default();
let accent = base16_value.color32(Base16Wrapper::Base16(base16));
ctx.style_mut(|style| {
style.visuals.selection.stroke.color = accent;
style.visuals.widgets.hovered.fg_stroke.color = accent;
style.visuals.widgets.active.fg_stroke.color = accent;
});
bg_color.replace(base16.background());
(
base16_auto_select_fill.map(|c| c.color32(Base16Wrapper::Base16(base16))),
base16_auto_select_text.map(|c| c.color32(Base16Wrapper::Base16(base16))),
)
}
KomobarTheme::Custom(KomobarThemeCustom {
colours,
accent: base16_value,
auto_select_fill: base16_auto_select_fill,
auto_select_text: base16_auto_select_text,
}) => {
let background = colours.background();
ctx.set_style(colours.style());
let base16_value = base16_value.unwrap_or_default();
let accent = base16_value.color32(Base16Wrapper::Custom(colours.clone()));
ctx.style_mut(|style| {
style.visuals.selection.stroke.color = accent;
style.visuals.widgets.hovered.fg_stroke.color = accent;
style.visuals.widgets.active.fg_stroke.color = accent;
});
bg_color.replace(background);
(
base16_auto_select_fill.map(|c| c.color32(Base16Wrapper::Custom(colours.clone()))),
base16_auto_select_text.map(|c| c.color32(Base16Wrapper::Custom(colours.clone()))),
)
}
};
AUTO_SELECT_FILL_COLOUR.store(
auto_select_fill.map_or(0, |c| Colour::from(c).into()),
Ordering::SeqCst,
);
AUTO_SELECT_TEXT_COLOUR.store(
auto_select_text.map_or(0, |c| Colour::from(c).into()),
Ordering::SeqCst,
);
// Apply transparency_alpha
let theme_color = *bg_color.borrow();
bg_color_with_alpha.replace(theme_color.try_apply_alpha(transparency_alpha));
// apply rounding to the widgets
if let Some(Grouping::Bar(config) | Grouping::Alignment(config) | Grouping::Widget(config)) =
&grouping
&& let Some(rounding) = config.rounding
{
ctx.style_mut(|style| {
style.visuals.widgets.noninteractive.corner_radius = rounding.into();
style.visuals.widgets.inactive.corner_radius = rounding.into();
style.visuals.widgets.hovered.corner_radius = rounding.into();
style.visuals.widgets.active.corner_radius = rounding.into();
style.visuals.widgets.open.corner_radius = rounding.into();
});
}
// Update RenderConfig's background_color so that widgets will have the new color
render_config.borrow_mut().background_color = *bg_color.borrow();
}
impl Komobar {
pub fn apply_config(
&mut self,
ctx: &Context,
previous_monitor_info: Option<Rc<RefCell<MonitorInfo>>>,
) {
MAX_LABEL_WIDTH.store(
self.config.max_label_width.unwrap_or(400.0) as i32,
Ordering::SeqCst,
);
if let Some(font_family) = &self.config.font_family {
tracing::info!("attempting to add custom font family: {font_family}");
Self::add_custom_font(ctx, font_family);
}
// Update the `size_rect` so that the bar position can be changed on the EGUI update
// function
self.update_size_rect();
self.try_apply_theme(ctx);
if let Some(font_size) = &self.config.font_size {
tracing::info!("attempting to set custom font size: {font_size}");
Self::set_font_size(ctx, *font_size);
}
self.render_config.replace((&self.config).new_renderconfig(
ctx,
*self.bg_color.borrow(),
self.config.icon_scale,
));
let mut monitor_info = previous_monitor_info;
let mut komorebi_widgets = Vec::new();
for (idx, widget_config) in self.config.left_widgets.iter().enumerate() {
if let WidgetConfig::Komorebi(config) = widget_config {
komorebi_widgets.push((Komorebi::from(config), idx, Alignment::Left));
}
}
if let Some(center_widgets) = &self.config.center_widgets {
for (idx, widget_config) in center_widgets.iter().enumerate() {
if let WidgetConfig::Komorebi(config) = widget_config {
komorebi_widgets.push((Komorebi::from(config), idx, Alignment::Center));
}
}
}
for (idx, widget_config) in self.config.right_widgets.iter().enumerate() {
if let WidgetConfig::Komorebi(config) = widget_config {
komorebi_widgets.push((Komorebi::from(config), idx, Alignment::Right));
}
}
let mut left_widgets = self
.config
.left_widgets
.iter()
.filter(|config| config.enabled())
.map(|config| config.as_boxed_bar_widget())
.collect::<Vec<Box<dyn BarWidget>>>();
let mut center_widgets = match &self.config.center_widgets {
Some(center_widgets) => center_widgets
.iter()
.filter(|config| config.enabled())
.map(|config| config.as_boxed_bar_widget())
.collect::<Vec<Box<dyn BarWidget>>>(),
None => vec![],
};
let mut right_widgets = self
.config
.right_widgets
.iter()
.filter(|config| config.enabled())
.map(|config| config.as_boxed_bar_widget())
.collect::<Vec<Box<dyn BarWidget>>>();
if !komorebi_widgets.is_empty() {
komorebi_widgets
.into_iter()
.for_each(|(mut widget, idx, side)| {
match monitor_info {
None => {
monitor_info = Some(widget.monitor_info.clone());
}
Some(ref previous) => {
if widget.workspaces.is_some() {
previous
.borrow_mut()
.update_from_self(&widget.monitor_info.borrow());
}
widget.monitor_info = previous.clone();
}
}
let boxed: Box<dyn BarWidget> = Box::new(widget);
match side {
Alignment::Left => left_widgets[idx] = boxed,
Alignment::Center => center_widgets[idx] = boxed,
Alignment::Right => right_widgets[idx] = boxed,
}
});
}
right_widgets.reverse();
self.left_widgets = left_widgets;
self.center_widgets = center_widgets;
self.right_widgets = right_widgets;
let (usr_monitor_index, config_work_area_offset) = match &self.config.monitor {
Some(MonitorConfigOrIndex::MonitorConfig(monitor_config)) => {
(monitor_config.index, monitor_config.work_area_offset)
}
Some(MonitorConfigOrIndex::Index(idx)) => (*idx, None),
None => (0, None),
};
let mapped_info = self.monitor_info.as_ref().map(|info| {
let monitor = info.borrow();
(
monitor.monitor_usr_idx_map.get(&usr_monitor_index).copied(),
monitor.mouse_follows_focus,
)
});
if let Some(info) = mapped_info {
self.monitor_index = info.0;
self.mouse_follows_focus = info.1;
}
if let Some(monitor_index) = self.monitor_index {
if let (prev_rect, Some(new_rect)) = (&self.work_area_offset, &config_work_area_offset)
{
if new_rect != prev_rect {
self.work_area_offset = *new_rect;
if let Err(error) = komorebi_client::send_message(
&SocketMessage::MonitorWorkAreaOffset(monitor_index, *new_rect),
) {
tracing::error!(
"error applying work area offset to monitor '{}': {}",
monitor_index,
error,
);
} else {
tracing::info!("work area offset applied to monitor: {}", monitor_index);
}
}
} else if let Some(height) = self.config.height.or(Some(BAR_HEIGHT)) {
// We only add the `bottom_margin` to the work_area_offset since the top margin is
// already considered on the `size_rect.top`
let bottom_margin = self
.config
.margin
.as_ref()
.map_or(0, |v| v.to_individual(0.0).bottom as i32);
let new_rect = komorebi_client::Rect {
left: 0,
top: (height as i32)
+ (self.size_rect.top - MONITOR_TOP.load(Ordering::SeqCst))
+ bottom_margin,
right: 0,
bottom: (height as i32)
+ (self.size_rect.top - MONITOR_TOP.load(Ordering::SeqCst))
+ bottom_margin,
};
if new_rect != self.work_area_offset {
self.work_area_offset = new_rect;
if let Err(error) = komorebi_client::send_message(
&SocketMessage::MonitorWorkAreaOffset(monitor_index, new_rect),
) {
tracing::error!(
"error applying work area offset to monitor '{monitor_index}': {error}"
);
} else {
tracing::info!("work area offset applied to monitor: {monitor_index}",);
}
}
}
} else if self.monitor_info.is_some() && !self.disabled {
tracing::warn!(
"couldn't find the monitor index of this bar! Disabling the bar until the monitor connects..."
);
self.disabled = true;
} else {
tracing::warn!(
"couldn't find the monitor index of this bar, if the bar is starting up this is normal until it receives the first state from komorebi."
);
self.disabled = true;
}
if let Some(mouse) = &self.config.mouse {
self.input_config.act_on_vertical_scroll =
mouse.on_scroll_up.is_some() || mouse.on_scroll_down.is_some();
self.input_config.act_on_horizontal_scroll =
mouse.on_scroll_left.is_some() || mouse.on_scroll_right.is_some();
self.input_config.vertical_scroll_threshold = mouse
.vertical_scroll_threshold
.unwrap_or(30.0)
.clamp(10.0, 300.0);
self.input_config.horizontal_scroll_threshold = mouse
.horizontal_scroll_threshold
.unwrap_or(30.0)
.clamp(10.0, 300.0);
// limit how many "ticks" can be accumulated
self.input_config.vertical_scroll_max_threshold =
self.input_config.vertical_scroll_threshold * 3.0;
self.input_config.horizontal_scroll_max_threshold =
self.input_config.horizontal_scroll_threshold * 3.0;
if mouse.has_command() {
start_powershell().unwrap_or_else(|_| {
tracing::error!("failed to start powershell session");
});
} else {
stop_powershell().unwrap_or_else(|_| {
tracing::error!("failed to stop powershell session");
});
}
}
tracing::info!("widget configuration options applied");
self.monitor_info = monitor_info;
}
/// Updates the `size_rect` field. Returns a bool indicating if the field was changed or not
fn update_size_rect(&mut self) {
let position = self.config.position.clone().unwrap_or(PositionConfig {
start: Some(Position {
x: MONITOR_LEFT.load(Ordering::SeqCst) as f32,
y: MONITOR_TOP.load(Ordering::SeqCst) as f32,
}),
end: Some(Position {
x: MONITOR_RIGHT.load(Ordering::SeqCst) as f32,
y: BAR_HEIGHT,
}),
});
let mut start = position.start.unwrap_or(Position {
x: MONITOR_LEFT.load(Ordering::SeqCst) as f32,
y: MONITOR_TOP.load(Ordering::SeqCst) as f32,
});
let mut end = position.end.unwrap_or(Position {
x: MONITOR_RIGHT.load(Ordering::SeqCst) as f32,
y: BAR_HEIGHT,
});
if let Some(height) = self.config.height {
end.y = height;
}
let margin = get_individual_spacing(0.0, &self.config.margin);
start.y += margin.top;
start.x += margin.left;
end.x -= margin.left + margin.right;
if end.y == 0.0 {
tracing::warn!(
"position.end.y is set to 0.0 which will make your bar invisible on a config reload - this is usually set to 50.0 by default"
)
}
self.size_rect = komorebi_client::Rect {
left: start.x as i32,
top: start.y as i32,
right: end.x as i32,
bottom: end.y as i32,
};
}
fn try_apply_theme(&mut self, ctx: &Context) {
match &self.config.theme {
Some(theme) => {
apply_theme(
ctx,
theme.clone(),
self.bg_color.clone(),
self.bg_color_with_alpha.clone(),
self.config.transparency_alpha,
self.config.grouping,
self.render_config.clone(),
);
}
None => {
let home_dir: PathBuf = std::env::var("KOMOREBI_CONFIG_HOME").map_or_else(
|_| dirs::home_dir().expect("there is no home directory"),
|home_path| {
let home = home_path.replace_env();
assert!(
home.is_dir(),
"$Env:KOMOREBI_CONFIG_HOME is set to '{home_path}', which is not a valid directory"
);
home
},
);
let bar_transparency_alpha = self.config.transparency_alpha;
let bar_grouping = self.config.grouping;
let config = home_dir.join("komorebi.json");
match komorebi_client::StaticConfig::read(&config) {
Ok(config) => {
if let Some(theme) = config.theme {
apply_theme(
ctx,
KomobarTheme::from(theme),
self.bg_color.clone(),
self.bg_color_with_alpha.clone(),
bar_transparency_alpha,
bar_grouping,
self.render_config.clone(),
);
}
}
Err(_) => {
ctx.set_style(Style::default());
self.bg_color.replace(Style::default().visuals.panel_fill);
// apply rounding to the widgets since we didn't call `apply_theme`
if let Some(
Grouping::Bar(config)
| Grouping::Alignment(config)
| Grouping::Widget(config),
) = &bar_grouping
&& let Some(rounding) = config.rounding
{
ctx.style_mut(|style| {
style.visuals.widgets.noninteractive.corner_radius =
rounding.into();
style.visuals.widgets.inactive.corner_radius = rounding.into();
style.visuals.widgets.hovered.corner_radius = rounding.into();
style.visuals.widgets.active.corner_radius = rounding.into();
style.visuals.widgets.open.corner_radius = rounding.into();
});
}
}
}
}
}
}
pub fn new(
cc: &eframe::CreationContext<'_>,
rx_gui: Receiver<KomorebiEvent>,
rx_config: Receiver<KomobarConfig>,
config: KomobarConfig,
) -> Self {
let mut komobar = Self {
hwnd: process_hwnd(),
monitor_index: None,
disabled: false,
config,
render_config: Rc::new(RefCell::new(RenderConfig::new())),
monitor_info: None,
left_widgets: vec![],
center_widgets: vec![],
right_widgets: vec![],
rx_gui,
rx_config,
bg_color: Rc::new(RefCell::new(Style::default().visuals.panel_fill)),
bg_color_with_alpha: Rc::new(RefCell::new(Style::default().visuals.panel_fill)),
scale_factor: cc.egui_ctx.native_pixels_per_point().unwrap_or(1.0),
size_rect: komorebi_client::Rect::default(),
work_area_offset: komorebi_client::Rect::default(),
applied_theme_on_first_frame: false,
mouse_follows_focus: false,
input_config: InputConfig {
accumulated_scroll_delta: Vec2::new(0.0, 0.0),
act_on_vertical_scroll: false,
act_on_horizontal_scroll: false,
vertical_scroll_threshold: 0.0,
horizontal_scroll_threshold: 0.0,
vertical_scroll_max_threshold: 0.0,
horizontal_scroll_max_threshold: 0.0,
},
};
komobar.apply_config(&cc.egui_ctx, None);
// needs a double apply the first time for some reason
komobar.apply_config(&cc.egui_ctx, None);
komobar
}
fn set_font_size(ctx: &Context, font_size: f32) {
ctx.style_mut(|style| {
style.text_styles = [
(TextStyle::Small, FontId::new(9.0, FontFamily::Proportional)),
(
TextStyle::Body,
FontId::new(font_size, FontFamily::Proportional),
),
(
TextStyle::Button,
FontId::new(font_size, FontFamily::Proportional),
),
(
TextStyle::Heading,
FontId::new(18.0, FontFamily::Proportional),
),
(
TextStyle::Monospace,
FontId::new(font_size, FontFamily::Monospace),
),
]
.into();
});
}
fn add_custom_font(ctx: &Context, name: &str) {
let mut fonts = FontDefinitions::default();
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
let mut fallbacks = HashMap::new();
fallbacks.insert("Microsoft YaHei", "C:\\Windows\\Fonts\\msyh.ttc"); // chinese
fallbacks.insert("Malgun Gothic", "C:\\Windows\\Fonts\\malgun.ttf"); // korean
fallbacks.insert("Leelawadee UI", "C:\\Windows\\Fonts\\LeelawUI.ttf"); // thai
for (name, path) in fallbacks {
if let Ok(bytes) = std::fs::read(path) {
fonts
.font_data
.insert(name.to_owned(), Arc::from(FontData::from_owned(bytes)));
for family in [FontFamily::Proportional, FontFamily::Monospace] {
fonts
.families
.entry(family)
.or_default()
.insert(0, name.to_owned());
}
}
}
let property = FontPropertyBuilder::new().family(name).build();
if let Some((font, _)) = system_fonts::get(&property) {
fonts
.font_data
.insert(name.to_owned(), Arc::new(FontData::from_owned(font)));
for family in [FontFamily::Proportional, FontFamily::Monospace] {
fonts
.families
.entry(family)
.or_default()
.insert(0, name.to_owned());
}
}
// Tell egui to use these fonts:
ctx.set_fonts(fonts);
}
pub fn position_bar(&self) {
if let Some(hwnd) = self.hwnd {
let window = komorebi_client::Window::from(hwnd);
match window.set_position(&self.size_rect, false) {
Ok(_) => {
tracing::info!("updated bar position");
}
Err(error) => {
tracing::error!("{error}")
}
}
}
}
fn update_monitor_coordinates(&mut self, monitor_size: &komorebi_client::Rect) {
// Store the new monitor coordinates
MONITOR_TOP.store(monitor_size.top, Ordering::SeqCst);
MONITOR_LEFT.store(monitor_size.left, Ordering::SeqCst);
MONITOR_RIGHT.store(monitor_size.right, Ordering::SeqCst);
// Since the `config.position` is changed on `main.rs` we need to update it here.
// If the user had set up some `start` position, that will be overriden here
// since we have no way to know what was that value since it might have been
// changed on `main.rs`. However if the users use the new configs this won't be
// a problem for them.
if let Some(start) = self.config.position.as_mut().and_then(|p| p.start.as_mut()) {
start.x = monitor_size.left as f32;
start.y = monitor_size.top as f32;
}
}
}
impl eframe::App for Komobar {
// Needed for transparency
fn clear_color(&self, _visuals: &Visuals) -> [f32; 4] {
Rgba::TRANSPARENT.to_array()
}
fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
if self.hwnd.is_none() {
self.hwnd = process_hwnd();
}
if self.scale_factor != ctx.native_pixels_per_point().unwrap_or(1.0) {
self.scale_factor = ctx.native_pixels_per_point().unwrap_or(1.0);
self.apply_config(ctx, self.monitor_info.clone());
}
if let Ok(updated_config) = self.rx_config.try_recv() {
self.config = updated_config;
self.apply_config(ctx, self.monitor_info.clone());
}
match self.rx_gui.try_recv() {
Err(error) => match error {
TryRecvError::Empty => {}
TryRecvError::Disconnected => {
tracing::error!(
"failed to receive komorebi notification on gui thread: {error}"
);
}
},
Ok(KomorebiEvent::Notification(notification)) => {
let state = &notification.state;
let usr_monitor_index = match &self.config.monitor {
Some(MonitorConfigOrIndex::MonitorConfig(monitor_config)) => {
monitor_config.index
}
Some(MonitorConfigOrIndex::Index(idx)) => *idx,
None => 0,
};
let monitor_index = state.monitor_usr_idx_map.get(&usr_monitor_index).copied();
self.monitor_index = monitor_index;
let mut should_apply_config = false;
match notification.event {
NotificationEvent::VirtualDesktop(
VirtualDesktopNotification::EnteredAssociatedVirtualDesktop,
) => {
tracing::debug!(
"back on komorebi's associated virtual desktop - restoring bar"
);
if let Some(hwnd) = self.hwnd {
komorebi_client::WindowsApi::restore_window(hwnd);
}
}
NotificationEvent::VirtualDesktop(
VirtualDesktopNotification::LeftAssociatedVirtualDesktop,
) => {
tracing::debug!(
"no longer on komorebi's associated virtual desktop - minimizing bar"
);
if let Some(hwnd) = self.hwnd {
komorebi_client::WindowsApi::minimize_window(hwnd);
}
}
_ => {}
}
if self.monitor_index.is_none()
|| self
.monitor_index
.is_some_and(|idx| idx >= state.monitors.elements().len())
{
if !self.disabled {
// Monitor for this bar got disconnected lets disable the bar until it
// reconnects
self.disabled = true;
tracing::warn!(
"This bar's monitor got disconnected. The bar will be disabled until it reconnects..."
);
}
return;
} else {
if self.disabled {
tracing::info!("Found this bar's monitor. The bar will be enabled!");
// Restore the bar in case it has been minimized when the monitor
// disconnected
if let Some(hwnd) = self.hwnd {
let window = komorebi_client::Window::from(hwnd);
if window.is_miminized() {
komorebi_client::WindowsApi::restore_window(hwnd);
}
}
// Reset the current `work_area_offset` so that it gets recalculated and
// properly applied again, since if the monitor has connected for the first
// time it won't have the work_area_offset applied but the bar thinks it
// does.
self.work_area_offset = komorebi_client::Rect::default();
should_apply_config = true;
}
self.disabled = false;
}
if matches!(
notification.event,
NotificationEvent::Monitor(MonitorNotification::DisplayConnectionChange)
) {
let monitor_index = self.monitor_index.expect("should have a monitor index");
let monitor_size = state.monitors.elements()[monitor_index].size;
self.update_monitor_coordinates(&monitor_size);
should_apply_config = true;
}
if self.disabled {
return;
}
// Check if monitor coordinates/size has changed
if let Some(monitor_index) = self.monitor_index {
let monitor_size = state.monitors.elements()[monitor_index].size;
let top = MONITOR_TOP.load(Ordering::SeqCst);
let left = MONITOR_LEFT.load(Ordering::SeqCst);
let right = MONITOR_RIGHT.load(Ordering::SeqCst);
let rect = komorebi_client::Rect {
top,
left,
bottom: monitor_size.bottom,
right,
};
if monitor_size != rect {
tracing::info!(
"Monitor coordinates/size has changed, storing new coordinates: {:#?}",
monitor_size
);
self.update_monitor_coordinates(&monitor_size);
should_apply_config = true;
}
}
if let Some(monitor_info) = &self.monitor_info {
monitor_info.borrow_mut().update(
self.monitor_index,
notification.state,
self.render_config.borrow().show_all_icons,
);
handle_notification(
ctx,
notification.event,
self.bg_color.clone(),
self.bg_color_with_alpha.clone(),
self.config.transparency_alpha,
self.config.grouping,
self.config.theme.clone(),
self.render_config.clone(),
);
}
if should_apply_config {
self.apply_config(ctx, self.monitor_info.clone());
// Reposition the Bar
self.position_bar();
}
}
Ok(KomorebiEvent::Reconnect) => {
if let Some(monitor_index) = self.monitor_index {
if let Err(error) = komorebi_client::send_message(
&SocketMessage::MonitorWorkAreaOffset(monitor_index, self.work_area_offset),
) {
tracing::error!(
"error applying work area offset to monitor '{}': {}",
monitor_index,
error,
);
} else {
tracing::info!("work area offset applied to monitor: {}", monitor_index);
}
}
}
}
if self.disabled {
// The check for disabled is performed above, if we get here and the bar is still
// disabled then we should return without drawing anything.
return;
}
if !self.applied_theme_on_first_frame {
self.try_apply_theme(ctx);
self.applied_theme_on_first_frame = true;
}
// Check if egui's Window size is the expected one, if not, update it
if let Some(current_rect) = ctx.input(|i| i.viewport().outer_rect) {
// Get the correct size according to scale factor
let current_rect = komorebi_client::Rect {
left: (current_rect.min.x * self.scale_factor) as i32,
top: (current_rect.min.y * self.scale_factor) as i32,
right: ((current_rect.max.x - current_rect.min.x) * self.scale_factor) as i32,
bottom: ((current_rect.max.y - current_rect.min.y) * self.scale_factor) as i32,
};
if self.size_rect != current_rect {
self.position_bar();
}
}
let frame = match &self.config.padding {
None => {
if let Some(frame) = &self.config.frame {
Frame::NONE
.inner_margin(Margin::symmetric(
frame.inner_margin.x as i8,
frame.inner_margin.y as i8,
))
.fill(*self.bg_color_with_alpha.borrow())
} else {
Frame::NONE
.inner_margin(Margin::same(0))
.fill(*self.bg_color_with_alpha.borrow())
}
}
Some(padding) => {
let padding = padding.to_individual(DEFAULT_PADDING);
Frame::NONE
.inner_margin(Margin {
top: padding.top as i8,
bottom: padding.bottom as i8,
left: padding.left as i8,
right: padding.right as i8,
})
.fill(*self.bg_color_with_alpha.borrow())
}
};
let mut render_config = self.render_config.borrow_mut();
let frame = render_config.change_frame_on_bar(frame, &ctx.style());
CentralPanel::default().frame(frame).show(ctx, |ui| {
// Variable to store command to execute after widgets are rendered
// This allows widgets to mark clicks as consumed before bar processes them
let mut pending_command: Option<crate::config::MouseMessage> = None;
if let Some(mouse_config) = &self.config.mouse {
let command = if ui
.input(|i| i.pointer.button_double_clicked(PointerButton::Primary))
{
tracing::debug!("Input: primary button double clicked");
&mouse_config.on_primary_double_click
} else if ui.input(|i| i.pointer.button_clicked(PointerButton::Secondary)) {
tracing::debug!("Input: secondary button clicked");
&mouse_config.on_secondary_click
} else if ui.input(|i| i.pointer.button_clicked(PointerButton::Middle)) {
tracing::debug!("Input: middle button clicked");
&mouse_config.on_middle_click
} else if ui.input(|i| i.pointer.button_clicked(PointerButton::Extra1)) {
tracing::debug!("Input: extra1 button clicked");
&mouse_config.on_extra1_click
} else if ui.input(|i| i.pointer.button_clicked(PointerButton::Extra2)) {
tracing::debug!("Input: extra2 button clicked");
&mouse_config.on_extra2_click
} else if self.input_config.act_on_vertical_scroll
|| self.input_config.act_on_horizontal_scroll
{
let scroll_delta = ui.input(|input| input.smooth_scroll_delta);
self.input_config.accumulated_scroll_delta += scroll_delta;
if scroll_delta.y != 0.0 && self.input_config.act_on_vertical_scroll {
// Do not store more than the max threshold
self.input_config.accumulated_scroll_delta.y =
self.input_config.accumulated_scroll_delta.y.clamp(
-self.input_config.vertical_scroll_max_threshold,
self.input_config.vertical_scroll_max_threshold,
);
// When the accumulated scroll passes the threshold, trigger a tick.
if self.input_config.accumulated_scroll_delta.y.abs()
>= self.input_config.vertical_scroll_threshold
{
let direction_command =
if self.input_config.accumulated_scroll_delta.y > 0.0 {
&mouse_config.on_scroll_up
} else {
&mouse_config.on_scroll_down
};
// Remove one tick's worth of scroll from the accumulator, preserving any excess.
self.input_config.accumulated_scroll_delta.y -=
self.input_config.vertical_scroll_threshold
* self.input_config.accumulated_scroll_delta.y.signum();
tracing::debug!(
"Input: vertical scroll ticked. excess: {} | threshold: {}",
self.input_config.accumulated_scroll_delta.y,
self.input_config.vertical_scroll_threshold
);
direction_command
} else {
&None
}
} else if scroll_delta.x != 0.0 && self.input_config.act_on_horizontal_scroll {
// Do not store more than the max threshold
self.input_config.accumulated_scroll_delta.x =
self.input_config.accumulated_scroll_delta.x.clamp(
-self.input_config.horizontal_scroll_max_threshold,
self.input_config.horizontal_scroll_max_threshold,
);
// When the accumulated scroll passes the threshold, trigger a tick.
if self.input_config.accumulated_scroll_delta.x.abs()
>= self.input_config.horizontal_scroll_threshold
{
let direction_command =
if self.input_config.accumulated_scroll_delta.x > 0.0 {
&mouse_config.on_scroll_left
} else {
&mouse_config.on_scroll_right
};
// Remove one tick's worth of scroll from the accumulator, preserving any excess.
self.input_config.accumulated_scroll_delta.x -=
self.input_config.horizontal_scroll_threshold
* self.input_config.accumulated_scroll_delta.x.signum();
tracing::debug!(
"Input: horizontal scroll ticked. excess: {} | threshold: {}",
self.input_config.accumulated_scroll_delta.x,
self.input_config.horizontal_scroll_threshold
);
direction_command
} else {
&None
}
} else {
&None
}
} else {
&None
};
// Store the command to execute after widgets are rendered
// This allows widgets to mark clicks as consumed
pending_command = command.clone();
}
// Apply grouping logic for the bar as a whole
let area_frame = if let Some(frame) = &self.config.frame {
Frame::NONE
.inner_margin(Margin::symmetric(0, frame.inner_margin.y as i8))
.outer_margin(Margin::same(0))
} else {
Frame::NONE
.inner_margin(Margin::same(0))
.outer_margin(Margin::same(0))
};
let available_height = ui.max_rect().max.y;
ctx.style_mut(|style| {
style.spacing.interact_size.y = available_height;
});
if !self.left_widgets.is_empty() {
// Left-aligned widgets layout
Area::new(Id::new("left_panel"))
.anchor(Align2::LEFT_CENTER, [0.0, 0.0]) // Align in the left center of the window
.show(ctx, |ui| {
let mut left_area_frame = area_frame;
if let Some(padding) = self
.config
.padding
.as_ref()
.map(|s| s.to_individual(DEFAULT_PADDING))
{
left_area_frame.inner_margin.left = padding.left as i8;
left_area_frame.inner_margin.top = padding.top as i8;
left_area_frame.inner_margin.bottom = padding.bottom as i8;
} else if let Some(frame) = &self.config.frame {
left_area_frame.inner_margin.left = frame.inner_margin.x as i8;
left_area_frame.inner_margin.top = frame.inner_margin.y as i8;
left_area_frame.inner_margin.bottom = frame.inner_margin.y as i8;
}
left_area_frame.show(ui, |ui| {
ui.horizontal(|ui| {
let mut render_conf = render_config.clone();
render_conf.alignment = Some(Alignment::Left);
render_config.apply_on_alignment(ui, |ui| {
for w in &mut self.left_widgets {
w.render(ctx, ui, &mut render_conf);
}
});
});
});
});
}
if !self.right_widgets.is_empty() {
// Right-aligned widgets layout
Area::new(Id::new("right_panel"))
.anchor(Align2::RIGHT_CENTER, [0.0, 0.0]) // Align in the right center of the window
.show(ctx, |ui| {
let mut right_area_frame = area_frame;
if let Some(padding) = self
.config
.padding
.as_ref()
.map(|s| s.to_individual(DEFAULT_PADDING))
{
right_area_frame.inner_margin.right = padding.right as i8;
right_area_frame.inner_margin.top = padding.top as i8;
right_area_frame.inner_margin.bottom = padding.bottom as i8;
} else if let Some(frame) = &self.config.frame {
right_area_frame.inner_margin.right = frame.inner_margin.x as i8;
right_area_frame.inner_margin.top = frame.inner_margin.y as i8;
right_area_frame.inner_margin.bottom = frame.inner_margin.y as i8;
}
right_area_frame.show(ui, |ui| {
let initial_size = Vec2 {
x: ui.available_size_before_wrap().x,
y: ui.spacing().interact_size.y,
};
ui.allocate_ui_with_layout(
initial_size,
Layout::right_to_left(Align::Center),
|ui| {
let mut render_conf = render_config.clone();
render_conf.alignment = Some(Alignment::Right);
render_config.apply_on_alignment(ui, |ui| {
for w in &mut self.right_widgets {
w.render(ctx, ui, &mut render_conf);
}
});
},
);
});
});
}
if !self.center_widgets.is_empty() {
// Floating center widgets
Area::new(Id::new("center_panel"))
.anchor(Align2::CENTER_CENTER, [0.0, 0.0]) // Align in the center of the window
.show(ctx, |ui| {
let mut center_area_frame = area_frame;
if let Some(padding) = self
.config
.padding
.as_ref()
.map(|s| s.to_individual(DEFAULT_PADDING))
{
center_area_frame.inner_margin.top = padding.top as i8;
center_area_frame.inner_margin.bottom = padding.bottom as i8;
} else if let Some(frame) = &self.config.frame {
center_area_frame.inner_margin.top = frame.inner_margin.y as i8;
center_area_frame.inner_margin.bottom = frame.inner_margin.y as i8;
}
center_area_frame.show(ui, |ui| {
ui.horizontal(|ui| {
let mut render_conf = render_config.clone();
render_conf.alignment = Some(Alignment::Center);
render_config.apply_on_alignment(ui, |ui| {
for w in &mut self.center_widgets {
w.render(ctx, ui, &mut render_conf);
}
});
});
});
});
}
// Execute the deferred mouse command only if no widget consumed the click
if let Some(command) = pending_command
&& !take_widget_clicked()
{
command.execute(self.mouse_follows_focus);
}
});
}
}
#[derive(Copy, Clone)]
pub enum Alignment {
Left,
Center,
Right,
}
#[allow(clippy::too_many_arguments)]
fn handle_notification(
ctx: &Context,
event: komorebi_client::NotificationEvent,
bg_color: Rc<RefCell<Color32>>,
bg_color_with_alpha: Rc<RefCell<Color32>>,
transparency_alpha: Option<u8>,
grouping: Option<Grouping>,
default_theme: Option<KomobarTheme>,
render_config: Rc<RefCell<RenderConfig>>,
) {
if let NotificationEvent::Socket(message) = event {
match message {
SocketMessage::ReloadStaticConfiguration(path) => {
if let Ok(config) = komorebi_client::StaticConfig::read(&path) {
if let Some(theme) = config.theme {
apply_theme(
ctx,
KomobarTheme::from(theme),
bg_color.clone(),
bg_color_with_alpha.clone(),
transparency_alpha,
grouping,
render_config,
);
tracing::info!("applied theme from updated komorebi.json");
} else if let Some(default_theme) = default_theme {
apply_theme(
ctx,
default_theme,
bg_color.clone(),
bg_color_with_alpha.clone(),
transparency_alpha,
grouping,
render_config,
);
tracing::info!(
"removed theme from updated komorebi.json and applied default theme"
);
} else {
tracing::warn!(
"theme was removed from updated komorebi.json but there was no default theme to apply"
);
}
}
}
SocketMessage::Theme(theme) => {
apply_theme(
ctx,
KomobarTheme::from(*theme),
bg_color,
bg_color_with_alpha.clone(),
transparency_alpha,
grouping,
render_config,
);
tracing::info!("applied theme from komorebi socket message");
}
_ => {}
}
}
}