mirror of
https://github.com/LGUG2Z/komorebi.git
synced 2026-07-05 20:51:42 +02:00
ea35d818a1
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.
425 lines
14 KiB
Rust
425 lines
14 KiB
Rust
mod bar;
|
|
mod config;
|
|
mod render;
|
|
mod selected_frame;
|
|
mod ui;
|
|
mod widgets;
|
|
|
|
use crate::bar::Komobar;
|
|
use crate::config::KomobarConfig;
|
|
use crate::config::Position;
|
|
use crate::config::PositionConfig;
|
|
use clap::Parser;
|
|
use config::MonitorConfigOrIndex;
|
|
use eframe::egui::ViewportBuilder;
|
|
use font_loader::system_fonts;
|
|
use hotwatch::EventKind;
|
|
use hotwatch::Hotwatch;
|
|
use komorebi_client::PathExt;
|
|
use komorebi_client::SocketMessage;
|
|
use komorebi_client::SubscribeOptions;
|
|
use komorebi_client::replace_env_in_path;
|
|
use std::io::BufReader;
|
|
use std::io::Read;
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::AtomicI32;
|
|
use std::sync::atomic::AtomicU32;
|
|
use std::sync::atomic::AtomicUsize;
|
|
use std::sync::atomic::Ordering;
|
|
use std::time::Duration;
|
|
use tracing_subscriber::EnvFilter;
|
|
use windows::Win32::Foundation::HWND;
|
|
use windows::Win32::Foundation::LPARAM;
|
|
use windows::Win32::System::Threading::GetCurrentProcessId;
|
|
use windows::Win32::System::Threading::GetCurrentThreadId;
|
|
use windows::Win32::UI::HiDpi::DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2;
|
|
use windows::Win32::UI::HiDpi::SetProcessDpiAwarenessContext;
|
|
use windows::Win32::UI::WindowsAndMessaging::EnumThreadWindows;
|
|
use windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId;
|
|
use windows_core::BOOL;
|
|
|
|
use std::sync::atomic::AtomicBool;
|
|
|
|
pub static MAX_LABEL_WIDTH: AtomicI32 = AtomicI32::new(400);
|
|
pub static MONITOR_LEFT: AtomicI32 = AtomicI32::new(0);
|
|
pub static MONITOR_TOP: AtomicI32 = AtomicI32::new(0);
|
|
pub static MONITOR_RIGHT: AtomicI32 = AtomicI32::new(0);
|
|
pub static MONITOR_INDEX: AtomicUsize = AtomicUsize::new(0);
|
|
pub static BAR_HEIGHT: f32 = 50.0;
|
|
pub static DEFAULT_PADDING: f32 = 10.0;
|
|
|
|
/// Flag to indicate that a widget has consumed a click event this frame.
|
|
/// This prevents the bar's global mouse handler from also processing the click.
|
|
pub static WIDGET_CLICKED: AtomicBool = AtomicBool::new(false);
|
|
|
|
/// Mark that a widget has consumed a click event this frame.
|
|
pub fn mark_widget_clicked() {
|
|
WIDGET_CLICKED.store(true, Ordering::SeqCst);
|
|
}
|
|
|
|
/// Check if a widget has consumed a click event this frame and reset the flag.
|
|
pub fn take_widget_clicked() -> bool {
|
|
WIDGET_CLICKED.swap(false, Ordering::SeqCst)
|
|
}
|
|
|
|
pub static AUTO_SELECT_FILL_COLOUR: AtomicU32 = AtomicU32::new(0);
|
|
pub static AUTO_SELECT_TEXT_COLOUR: AtomicU32 = AtomicU32::new(0);
|
|
|
|
#[derive(Parser)]
|
|
#[clap(author, about, version)]
|
|
struct Opts {
|
|
/// Print the JSON schema of the configuration file and exit
|
|
#[clap(long)]
|
|
schema: bool,
|
|
/// Print a list of fonts available on this system and exit
|
|
#[clap(long)]
|
|
fonts: bool,
|
|
/// Path to a JSON or YAML configuration file
|
|
#[clap(short, long)]
|
|
#[clap(value_parser = replace_env_in_path)]
|
|
config: Option<PathBuf>,
|
|
/// Write an example komorebi.bar.json to disk
|
|
#[clap(long)]
|
|
quickstart: bool,
|
|
/// Print a list of aliases that can be renamed to canonical variants
|
|
#[clap(long)]
|
|
#[clap(hide = true)]
|
|
aliases: bool,
|
|
}
|
|
|
|
extern "system" fn enum_window(hwnd: HWND, lparam: LPARAM) -> BOOL {
|
|
unsafe {
|
|
let mut process_id = 0;
|
|
GetWindowThreadProcessId(hwnd, Some(&mut process_id));
|
|
|
|
if process_id == GetCurrentProcessId() {
|
|
*(lparam.0 as *mut HWND) = hwnd;
|
|
BOOL::from(false) // Stop enumeration
|
|
} else {
|
|
BOOL::from(true) // Continue enumeration
|
|
}
|
|
}
|
|
}
|
|
|
|
fn process_hwnd() -> Option<isize> {
|
|
unsafe {
|
|
let mut hwnd = HWND::default();
|
|
let _ = EnumThreadWindows(
|
|
GetCurrentThreadId(),
|
|
Some(enum_window),
|
|
LPARAM(&mut hwnd as *mut HWND as isize),
|
|
);
|
|
|
|
if hwnd.0 as isize == 0 {
|
|
None
|
|
} else {
|
|
Some(hwnd.0 as isize)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub enum KomorebiEvent {
|
|
Notification(Box<komorebi_client::Notification>),
|
|
Reconnect,
|
|
}
|
|
|
|
fn main() -> color_eyre::Result<()> {
|
|
unsafe { SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) }?;
|
|
|
|
let opts: Opts = Opts::parse();
|
|
|
|
#[cfg(feature = "schemars")]
|
|
if opts.schema {
|
|
let bar_config = schemars::schema_for!(KomobarConfig);
|
|
let schema = serde_json::to_string_pretty(&bar_config)?;
|
|
|
|
println!("{schema}");
|
|
std::process::exit(0);
|
|
}
|
|
|
|
if opts.fonts {
|
|
for font in system_fonts::query_all() {
|
|
println!("{font}");
|
|
}
|
|
|
|
std::process::exit(0);
|
|
}
|
|
|
|
if std::env::var("RUST_LIB_BACKTRACE").is_err() {
|
|
unsafe {
|
|
std::env::set_var("RUST_LIB_BACKTRACE", "1");
|
|
}
|
|
}
|
|
|
|
color_eyre::install()?;
|
|
|
|
if std::env::var("RUST_LOG").is_err() {
|
|
unsafe {
|
|
std::env::set_var("RUST_LOG", "info");
|
|
}
|
|
}
|
|
|
|
tracing::subscriber::set_global_default(
|
|
tracing_subscriber::fmt::Subscriber::builder()
|
|
.with_env_filter(EnvFilter::from_default_env())
|
|
.finish(),
|
|
)?;
|
|
|
|
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
|
|
},
|
|
);
|
|
|
|
if opts.quickstart {
|
|
let komorebi_bar_json = include_str!("../../docs/komorebi.bar.example.json").to_string();
|
|
std::fs::write(home_dir.join("komorebi.bar.json"), komorebi_bar_json)?;
|
|
println!(
|
|
"Example komorebi.bar.json file written to {}",
|
|
home_dir.display()
|
|
);
|
|
|
|
std::process::exit(0);
|
|
}
|
|
|
|
let default_config_path = home_dir.join("komorebi.bar.json");
|
|
|
|
let config_path = opts.config.or_else(|| {
|
|
default_config_path
|
|
.is_file()
|
|
.then_some(default_config_path.clone())
|
|
});
|
|
|
|
let mut config = match config_path {
|
|
None => {
|
|
let komorebi_bar_json =
|
|
include_str!("../../docs/komorebi.bar.example.json").to_string();
|
|
|
|
std::fs::write(&default_config_path, komorebi_bar_json)?;
|
|
tracing::info!(
|
|
"created example configuration file: {}",
|
|
default_config_path.display()
|
|
);
|
|
|
|
KomobarConfig::read(&default_config_path)?
|
|
}
|
|
Some(ref config) => {
|
|
if !opts.aliases {
|
|
tracing::info!("found configuration file: {}", config.display());
|
|
}
|
|
|
|
KomobarConfig::read(config)?
|
|
}
|
|
};
|
|
|
|
let config_path = config_path.unwrap_or(default_config_path);
|
|
|
|
if opts.aliases {
|
|
KomobarConfig::aliases(&std::fs::read_to_string(&config_path)?);
|
|
std::process::exit(0);
|
|
}
|
|
|
|
let state = serde_json::from_str::<komorebi_client::State>(&komorebi_client::send_query(
|
|
&SocketMessage::State,
|
|
)?)?;
|
|
|
|
let (usr_monitor_index, work_area_offset) = match &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 monitor_index = state
|
|
.monitor_usr_idx_map
|
|
.get(&usr_monitor_index)
|
|
.map_or(usr_monitor_index, |i| *i);
|
|
|
|
MONITOR_RIGHT.store(
|
|
state.monitors.elements()[monitor_index].size.right,
|
|
Ordering::SeqCst,
|
|
);
|
|
|
|
MONITOR_TOP.store(
|
|
state.monitors.elements()[monitor_index].size.top,
|
|
Ordering::SeqCst,
|
|
);
|
|
|
|
MONITOR_LEFT.store(
|
|
state.monitors.elements()[monitor_index].size.left,
|
|
Ordering::SeqCst,
|
|
);
|
|
|
|
MONITOR_INDEX.store(monitor_index, Ordering::SeqCst);
|
|
|
|
match config.position {
|
|
None => {
|
|
config.position = Some(PositionConfig {
|
|
start: Some(Position {
|
|
x: state.monitors.elements()[monitor_index].size.left as f32,
|
|
y: state.monitors.elements()[monitor_index].size.top as f32,
|
|
}),
|
|
end: Some(Position {
|
|
x: state.monitors.elements()[monitor_index].size.right as f32,
|
|
y: 50.0,
|
|
}),
|
|
})
|
|
}
|
|
Some(ref mut position) => {
|
|
if position.start.is_none() {
|
|
position.start = Some(Position {
|
|
x: state.monitors.elements()[monitor_index].size.left as f32,
|
|
y: state.monitors.elements()[monitor_index].size.top as f32,
|
|
});
|
|
}
|
|
|
|
if position.end.is_none() {
|
|
position.end = Some(Position {
|
|
x: state.monitors.elements()[monitor_index].size.right as f32,
|
|
y: 50.0,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
let viewport_builder = ViewportBuilder::default()
|
|
.with_decorations(false)
|
|
.with_transparent(true)
|
|
.with_taskbar(false);
|
|
|
|
let native_options = eframe::NativeOptions {
|
|
viewport: viewport_builder,
|
|
..Default::default()
|
|
};
|
|
|
|
if let Some(rect) = &work_area_offset {
|
|
komorebi_client::send_message(&SocketMessage::MonitorWorkAreaOffset(monitor_index, *rect))?;
|
|
tracing::info!("work area offset applied to monitor: {}", monitor_index);
|
|
}
|
|
|
|
let (tx_gui, rx_gui) = crossbeam_channel::unbounded();
|
|
let (tx_config, rx_config) = crossbeam_channel::unbounded();
|
|
|
|
let mut hotwatch = Hotwatch::new()?;
|
|
let config_path_cl = config_path.clone();
|
|
|
|
hotwatch.watch(config_path, move |event| match event.kind {
|
|
EventKind::Modify(_) | EventKind::Remove(_) => match KomobarConfig::read(&config_path_cl) {
|
|
Ok(updated) => {
|
|
tracing::info!("configuration file updated: {}", config_path_cl.display());
|
|
|
|
if let Err(error) = tx_config.send(updated) {
|
|
tracing::error!("could not send configuration update to gui: {error}")
|
|
}
|
|
}
|
|
Err(error) => {
|
|
tracing::error!("{error}");
|
|
}
|
|
},
|
|
_ => {}
|
|
})?;
|
|
|
|
tracing::info!("watching configuration file for changes");
|
|
|
|
eframe::run_native(
|
|
"komorebi-bar",
|
|
native_options,
|
|
Box::new(|cc| {
|
|
let ctx_repainter = cc.egui_ctx.clone();
|
|
std::thread::spawn(move || loop {
|
|
std::thread::sleep(Duration::from_secs(1));
|
|
ctx_repainter.request_repaint();
|
|
});
|
|
|
|
let ctx_komorebi = cc.egui_ctx.clone();
|
|
std::thread::spawn(move || {
|
|
let subscriber_name = format!("komorebi-bar-{}", random_word::get(random_word::Lang::En));
|
|
|
|
let listener = komorebi_client::subscribe_with_options(&subscriber_name, SubscribeOptions {
|
|
filter_state_changes: true,
|
|
})
|
|
.expect("could not subscribe to komorebi notifications");
|
|
|
|
tracing::info!("subscribed to komorebi notifications: \"{}\"", subscriber_name);
|
|
|
|
for client in listener.incoming() {
|
|
match client {
|
|
Ok(subscription) => {
|
|
match subscription.set_read_timeout(Some(Duration::from_secs(1))) {
|
|
Ok(()) => {}
|
|
Err(error) => tracing::error!("{}", error),
|
|
}
|
|
let mut buffer = Vec::new();
|
|
let mut reader = BufReader::new(subscription);
|
|
|
|
// this is when we know a shutdown has been sent
|
|
if matches!(reader.read_to_end(&mut buffer), Ok(0)) {
|
|
tracing::info!("disconnected from komorebi");
|
|
|
|
// keep trying to reconnect to komorebi
|
|
while komorebi_client::send_message(
|
|
&SocketMessage::AddSubscriberSocket(subscriber_name.clone()),
|
|
)
|
|
.is_err()
|
|
{
|
|
std::thread::sleep(Duration::from_secs(1));
|
|
}
|
|
|
|
tracing::info!("reconnected to komorebi");
|
|
|
|
if let Err(error) = tx_gui.send(KomorebiEvent::Reconnect) {
|
|
tracing::error!("could not send komorebi reconnect event to gui thread: {error}")
|
|
}
|
|
|
|
ctx_komorebi.request_repaint();
|
|
continue;
|
|
}
|
|
|
|
match String::from_utf8(buffer) {
|
|
Ok(notification_string) => {
|
|
match serde_json::from_str::<komorebi_client::Notification>(
|
|
¬ification_string,
|
|
) {
|
|
Ok(notification) => {
|
|
tracing::debug!("received notification from komorebi");
|
|
|
|
if let Err(error) = tx_gui.send(KomorebiEvent::Notification(Box::new(notification))) {
|
|
tracing::error!("could not send komorebi notification update to gui thread: {error}")
|
|
}
|
|
|
|
ctx_komorebi.request_repaint();
|
|
}
|
|
Err(error) => {
|
|
tracing::error!("could not deserialize komorebi notification: {error}");
|
|
}
|
|
}
|
|
}
|
|
Err(error) => {
|
|
tracing::error!(
|
|
"komorebi notification string was invalid utf8: {error}"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
Err(error) => {
|
|
tracing::error!("{error}");
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
Ok(Box::new(Komobar::new(cc, rx_gui, rx_config, config)))
|
|
}),
|
|
)
|
|
.map_err(|error| color_eyre::eyre::Error::msg(error.to_string()))
|
|
}
|