feat(bar): add komorebi-bar

This commit adds an initial version of the komorebi status bar.

At this point the bar is still considered "alpha" and the configuration
format may see some small breaking changes based on usage and feedback.

There is an accompanying video series which details the creation of this
bar on YouTube: https://www.youtube.com/watch?v=x2Z5-K05bHs

Some high level notes on the bar:

* An external application which exclusively consumes komorebi_client's
  public API surface - anyone can create this without hacking directly
  on komorebi's internals
* Generally a very simple bar with limited configuration options - users
  who want more configurability should use alternatives such as yasb or
  zebar
* Scope is deliberately limited to provide a tighter, more focused
  experience: Windows-only, komorebi-only, single-monitor-only,
  horizontal-only
* No support for custom widgets or templating
* Colours are controlled exclusively through themes which adhere to a
  palette framework such as Base16 or Catppuccin (and possibly others in
  the future)

This commit contains all of the commits listed:

e5fa03c33c
feat(bar): initial commit

b3990590f3
feat(bar): add config struct with basic opts

bc2f4a172e
feat(bar): handle komorebi restarts gracefully

ca6bf69ac7
feat(bar): add basic widget config opts

18358efed8
feat(bar): add interactive layout and media widgets

92bb9f680b
perf(bar): use explicit redraw and data refresh strategies

8e74e97706
feat(bar): add battery and network widgets

fdc7706d23
feat(bar): add custom font loader

025162769b
feat(bar): allow right side widget ordering

a1688691cf
feat(bar): add app icon next to focused window title

9f78739c3f
feat(bar): add komorebi widget (+config) and themes

a4ef85859e
feat(bar): use phosphor icons for uniformity

e99138a97e
feat(bar): add first pass at configuration loader

d6ccf4cf9a
feat(bar): add logging and config hotwatch

34d2431947
feat(bar): handle monocle containers in komorebi widget

7907dfeb79
feat(bar): add optional data refresh intervals to config

96a9cb320e
feat(bar): add flag to list system fonts

42b7a13693
feat(bar): add activity to network widget

ac38f52407
feat(bar): to_pretty_bytes on network activity

6803ffd741
feat(bar): configurable network activity fill char len

7d7a5d758d99808cd2b81da2b3ddbb11c52aa92f
ci(github): add bar to wix and goreleaser configs

da307e36fc1faf84ecca3f91811fdd15f70ef2ff
feat(bar): expand theme sources

c580ff7899889309dfa849ad4fb05b80b6af8d9b
feat(bar): add accent config for themes

bc4dabda4a941c0c9764fae2c8d11abbfdc0a9f5
feat(bar): add accents to widget emojis

a574837529dd6c5add73edf394c1c9c2e6cc6315
feat(bar): add to hard-coded float identifiers

ff41b552613f911e56b1790e68389525ee7e603c
chore(deps): bump base16-egui-themes
This commit is contained in:
LGUG2Z
2024-07-25 17:30:01 -07:00
parent c06d9afa3b
commit bc67936dd3
23 changed files with 4565 additions and 61 deletions

View File

@@ -95,6 +95,7 @@ jobs:
target/${{ matrix.target }}/release/komorebi.exe
target/${{ matrix.target }}/release/komorebic.exe
target/${{ matrix.target }}/release/komorebic-no-console.exe
target/${{ matrix.target }}/release/komorebi-bar.exe
target/${{ matrix.target }}/release/komorebi-gui.exe
target/${{ matrix.target }}/release/komorebi.pdb
target/${{ matrix.target }}/release/komorebic.pdb

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@
CHANGELOG.md
dummy.go
komorebic/applications.yaml
/.vs

View File

@@ -44,6 +44,15 @@ builds:
post:
- mkdir -p dist/windows_amd64
- cp ".\target\x86_64-pc-windows-msvc\release\komorebi-gui.exe" ".\dist\komorebi-gui_windows_amd64_v1\komorebi-gui.exe"
- id: komorebi-bar
main: dummy.go
goos: [ "windows" ]
goarch: [ "amd64" ]
binary: komorebi-bar
hooks:
post:
- mkdir -p dist/windows_amd64
- cp ".\target\x86_64-pc-windows-msvc\release\komorebi-bar.exe" ".\dist\komorebi-bar_windows_amd64_v1\komorebi-bar.exe"
archives:
- name_template: "{{ .ProjectName }}-{{ .Version }}-x86_64-pc-windows-msvc"

1148
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,12 +7,14 @@ members = [
"komorebi-gui",
"komorebic",
"komorebic-no-console",
"komorebi-bar"
]
[workspace.dependencies]
color-eyre = "0.6"
dirs = "5"
dunce = "1"
schemars = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = { package = "serde_json_lenient", version = "0.2" }
serde_yaml = "0.9"
@@ -21,7 +23,7 @@ uds_windows = "1"
win32-display-data = { git = "https://github.com/LGUG2Z/win32-display-data", rev = "32a45cebf132c3d651ee22c0c40033a6b7edc945" }
windows-implement = { version = "0.53" }
windows-interface = { version = "0.53" }
shadow-rs = "0.29"
shadow-rs = "0.34"
[workspace.dependencies.windows]
version = "0.54"
@@ -42,5 +44,7 @@ features = [
"Win32_UI_Shell_Common",
"Win32_UI_WindowsAndMessaging",
"Win32_System_SystemServices",
"Win32_System_WindowsProgramming"
"Win32_System_WindowsProgramming",
"Media",
"Media_Control"
]

37
komorebi-bar/Cargo.toml Normal file
View File

@@ -0,0 +1,37 @@
[package]
name = "komorebi-bar"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
komorebi-client = { path = "../komorebi-client" }
base16-egui-themes = { git = "https://github.com/LGUG2Z/base16-egui-themes", rev = "a2c48f45782c5604bf5482d3873021a9fe45ea1a" }
catppuccin-egui = { version = "5.1", default-features = false, features = ["egui28"] }
chrono = "0.4"
clap = { version = "4", features = ["derive", "wrap_help"] }
color-eyre = "0.6"
crossbeam-channel = "0.5"
dirs = { workspace = true }
eframe = "0.28"
egui-phosphor = "0.6.0"
font-loader = "0.11"
hotwatch = "0.5"
image = "0.25"
netdev = "0.30"
num = "0.4.3"
num-derive = "0.4.2"
num-traits = "0.2.19"
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = "0.8"
starship-battery = "0.10"
sysinfo = "0.31"
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
windows = { workspace = true }
windows-icons = "0.1"

437
komorebi-bar/src/bar.rs Normal file
View File

@@ -0,0 +1,437 @@
use crate::config::Base16Value;
use crate::config::Catppuccin;
use crate::config::CatppuccinValue;
use crate::config::KomobarConfig;
use crate::config::Theme;
use crate::komorebi::Komorebi;
use crate::komorebi::KomorebiNotificationState;
use crate::widget::BarWidget;
use crate::widget::WidgetConfig;
use crossbeam_channel::Receiver;
use eframe::egui::Align;
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::Frame;
use eframe::egui::Layout;
use eframe::egui::Margin;
use eframe::egui::Style;
use font_loader::system_fonts;
use font_loader::system_fonts::FontPropertyBuilder;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
pub struct Komobar {
pub config: Arc<KomobarConfig>,
pub komorebi_notification_state: Option<Rc<RefCell<KomorebiNotificationState>>>,
pub left_widgets: Vec<Box<dyn BarWidget>>,
pub right_widgets: Vec<Box<dyn BarWidget>>,
pub rx_gui: Receiver<komorebi_client::Notification>,
pub rx_config: Receiver<KomobarConfig>,
pub bg_color: Color32,
}
impl Komobar {
pub fn apply_config(
&mut self,
ctx: &Context,
config: &KomobarConfig,
previous_notification_state: Option<Rc<RefCell<KomorebiNotificationState>>>,
) {
if let Some(font_family) = &config.font_family {
tracing::info!("attempting to add custom font family: {font_family}");
Self::add_custom_font(ctx, font_family);
}
match config.theme {
None => {
ctx.set_style(Style::default());
self.bg_color = Style::default().visuals.panel_fill;
}
Some(theme) => match theme {
Theme::Catppuccin {
name: catppuccin,
accent: catppuccin_value,
} => match catppuccin {
Catppuccin::Frappe => {
catppuccin_egui::set_theme(ctx, catppuccin_egui::FRAPPE);
let catppuccin_value = catppuccin_value.unwrap_or_default();
let accent = match catppuccin_value {
CatppuccinValue::Rosewater => catppuccin_egui::FRAPPE.rosewater,
CatppuccinValue::Flamingo => catppuccin_egui::FRAPPE.flamingo,
CatppuccinValue::Pink => catppuccin_egui::FRAPPE.pink,
CatppuccinValue::Mauve => catppuccin_egui::FRAPPE.mauve,
CatppuccinValue::Red => catppuccin_egui::FRAPPE.red,
CatppuccinValue::Maroon => catppuccin_egui::FRAPPE.maroon,
CatppuccinValue::Peach => catppuccin_egui::FRAPPE.peach,
CatppuccinValue::Yellow => catppuccin_egui::FRAPPE.yellow,
CatppuccinValue::Green => catppuccin_egui::FRAPPE.green,
CatppuccinValue::Teal => catppuccin_egui::FRAPPE.teal,
CatppuccinValue::Sky => catppuccin_egui::FRAPPE.sky,
CatppuccinValue::Sapphire => catppuccin_egui::FRAPPE.sapphire,
CatppuccinValue::Blue => catppuccin_egui::FRAPPE.blue,
CatppuccinValue::Lavender => catppuccin_egui::FRAPPE.lavender,
CatppuccinValue::Text => catppuccin_egui::FRAPPE.text,
CatppuccinValue::Subtext1 => catppuccin_egui::FRAPPE.subtext1,
CatppuccinValue::Subtext0 => catppuccin_egui::FRAPPE.subtext0,
CatppuccinValue::Overlay2 => catppuccin_egui::FRAPPE.overlay2,
CatppuccinValue::Overlay1 => catppuccin_egui::FRAPPE.overlay1,
CatppuccinValue::Overlay0 => catppuccin_egui::FRAPPE.overlay0,
CatppuccinValue::Surface2 => catppuccin_egui::FRAPPE.surface2,
CatppuccinValue::Surface1 => catppuccin_egui::FRAPPE.surface1,
CatppuccinValue::Surface0 => catppuccin_egui::FRAPPE.surface0,
CatppuccinValue::Base => catppuccin_egui::FRAPPE.base,
CatppuccinValue::Mantle => catppuccin_egui::FRAPPE.mantle,
CatppuccinValue::Crust => catppuccin_egui::FRAPPE.crust,
};
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;
});
self.bg_color = catppuccin_egui::FRAPPE.base;
}
Catppuccin::Latte => {
catppuccin_egui::set_theme(ctx, catppuccin_egui::LATTE);
let catppuccin_value = catppuccin_value.unwrap_or_default();
let accent = match catppuccin_value {
CatppuccinValue::Rosewater => catppuccin_egui::LATTE.rosewater,
CatppuccinValue::Flamingo => catppuccin_egui::LATTE.flamingo,
CatppuccinValue::Pink => catppuccin_egui::LATTE.pink,
CatppuccinValue::Mauve => catppuccin_egui::LATTE.mauve,
CatppuccinValue::Red => catppuccin_egui::LATTE.red,
CatppuccinValue::Maroon => catppuccin_egui::LATTE.maroon,
CatppuccinValue::Peach => catppuccin_egui::LATTE.peach,
CatppuccinValue::Yellow => catppuccin_egui::LATTE.yellow,
CatppuccinValue::Green => catppuccin_egui::LATTE.green,
CatppuccinValue::Teal => catppuccin_egui::LATTE.teal,
CatppuccinValue::Sky => catppuccin_egui::LATTE.sky,
CatppuccinValue::Sapphire => catppuccin_egui::LATTE.sapphire,
CatppuccinValue::Blue => catppuccin_egui::LATTE.blue,
CatppuccinValue::Lavender => catppuccin_egui::LATTE.lavender,
CatppuccinValue::Text => catppuccin_egui::LATTE.text,
CatppuccinValue::Subtext1 => catppuccin_egui::LATTE.subtext1,
CatppuccinValue::Subtext0 => catppuccin_egui::LATTE.subtext0,
CatppuccinValue::Overlay2 => catppuccin_egui::LATTE.overlay2,
CatppuccinValue::Overlay1 => catppuccin_egui::LATTE.overlay1,
CatppuccinValue::Overlay0 => catppuccin_egui::LATTE.overlay0,
CatppuccinValue::Surface2 => catppuccin_egui::LATTE.surface2,
CatppuccinValue::Surface1 => catppuccin_egui::LATTE.surface1,
CatppuccinValue::Surface0 => catppuccin_egui::LATTE.surface0,
CatppuccinValue::Base => catppuccin_egui::LATTE.base,
CatppuccinValue::Mantle => catppuccin_egui::LATTE.mantle,
CatppuccinValue::Crust => catppuccin_egui::LATTE.crust,
};
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;
});
self.bg_color = catppuccin_egui::LATTE.base;
}
Catppuccin::Macchiato => {
catppuccin_egui::set_theme(ctx, catppuccin_egui::MACCHIATO);
let catppuccin_value = catppuccin_value.unwrap_or_default();
let accent = match catppuccin_value {
CatppuccinValue::Rosewater => catppuccin_egui::MACCHIATO.rosewater,
CatppuccinValue::Flamingo => catppuccin_egui::MACCHIATO.flamingo,
CatppuccinValue::Pink => catppuccin_egui::MACCHIATO.pink,
CatppuccinValue::Mauve => catppuccin_egui::MACCHIATO.mauve,
CatppuccinValue::Red => catppuccin_egui::MACCHIATO.red,
CatppuccinValue::Maroon => catppuccin_egui::MACCHIATO.maroon,
CatppuccinValue::Peach => catppuccin_egui::MACCHIATO.peach,
CatppuccinValue::Yellow => catppuccin_egui::MACCHIATO.yellow,
CatppuccinValue::Green => catppuccin_egui::MACCHIATO.green,
CatppuccinValue::Teal => catppuccin_egui::MACCHIATO.teal,
CatppuccinValue::Sky => catppuccin_egui::MACCHIATO.sky,
CatppuccinValue::Sapphire => catppuccin_egui::MACCHIATO.sapphire,
CatppuccinValue::Blue => catppuccin_egui::MACCHIATO.blue,
CatppuccinValue::Lavender => catppuccin_egui::MACCHIATO.lavender,
CatppuccinValue::Text => catppuccin_egui::MACCHIATO.text,
CatppuccinValue::Subtext1 => catppuccin_egui::MACCHIATO.subtext1,
CatppuccinValue::Subtext0 => catppuccin_egui::MACCHIATO.subtext0,
CatppuccinValue::Overlay2 => catppuccin_egui::MACCHIATO.overlay2,
CatppuccinValue::Overlay1 => catppuccin_egui::MACCHIATO.overlay1,
CatppuccinValue::Overlay0 => catppuccin_egui::MACCHIATO.overlay0,
CatppuccinValue::Surface2 => catppuccin_egui::MACCHIATO.surface2,
CatppuccinValue::Surface1 => catppuccin_egui::MACCHIATO.surface1,
CatppuccinValue::Surface0 => catppuccin_egui::MACCHIATO.surface0,
CatppuccinValue::Base => catppuccin_egui::MACCHIATO.base,
CatppuccinValue::Mantle => catppuccin_egui::MACCHIATO.mantle,
CatppuccinValue::Crust => catppuccin_egui::MACCHIATO.crust,
};
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;
});
self.bg_color = catppuccin_egui::MACCHIATO.base;
}
Catppuccin::Mocha => {
catppuccin_egui::set_theme(ctx, catppuccin_egui::MOCHA);
let catppuccin_value = catppuccin_value.unwrap_or_default();
let accent = match catppuccin_value {
CatppuccinValue::Rosewater => catppuccin_egui::MOCHA.rosewater,
CatppuccinValue::Flamingo => catppuccin_egui::MOCHA.flamingo,
CatppuccinValue::Pink => catppuccin_egui::MOCHA.pink,
CatppuccinValue::Mauve => catppuccin_egui::MOCHA.mauve,
CatppuccinValue::Red => catppuccin_egui::MOCHA.red,
CatppuccinValue::Maroon => catppuccin_egui::MOCHA.maroon,
CatppuccinValue::Peach => catppuccin_egui::MOCHA.peach,
CatppuccinValue::Yellow => catppuccin_egui::MOCHA.yellow,
CatppuccinValue::Green => catppuccin_egui::MOCHA.green,
CatppuccinValue::Teal => catppuccin_egui::MOCHA.teal,
CatppuccinValue::Sky => catppuccin_egui::MOCHA.sky,
CatppuccinValue::Sapphire => catppuccin_egui::MOCHA.sapphire,
CatppuccinValue::Blue => catppuccin_egui::MOCHA.blue,
CatppuccinValue::Lavender => catppuccin_egui::MOCHA.lavender,
CatppuccinValue::Text => catppuccin_egui::MOCHA.text,
CatppuccinValue::Subtext1 => catppuccin_egui::MOCHA.subtext1,
CatppuccinValue::Subtext0 => catppuccin_egui::MOCHA.subtext0,
CatppuccinValue::Overlay2 => catppuccin_egui::MOCHA.overlay2,
CatppuccinValue::Overlay1 => catppuccin_egui::MOCHA.overlay1,
CatppuccinValue::Overlay0 => catppuccin_egui::MOCHA.overlay0,
CatppuccinValue::Surface2 => catppuccin_egui::MOCHA.surface2,
CatppuccinValue::Surface1 => catppuccin_egui::MOCHA.surface1,
CatppuccinValue::Surface0 => catppuccin_egui::MOCHA.surface0,
CatppuccinValue::Base => catppuccin_egui::MOCHA.base,
CatppuccinValue::Mantle => catppuccin_egui::MOCHA.mantle,
CatppuccinValue::Crust => catppuccin_egui::MOCHA.crust,
};
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;
});
self.bg_color = catppuccin_egui::MOCHA.base;
}
},
Theme::Base16 {
name: base16,
accent: base16_value,
} => {
ctx.set_style(base16.style());
let base16_value = base16_value.unwrap_or_default();
let accent = match base16_value {
Base16Value::Base00 => base16.base00(),
Base16Value::Base01 => base16.base01(),
Base16Value::Base02 => base16.base02(),
Base16Value::Base03 => base16.base03(),
Base16Value::Base04 => base16.base04(),
Base16Value::Base05 => base16.base05(),
Base16Value::Base06 => base16.base06(),
Base16Value::Base07 => base16.base07(),
Base16Value::Base08 => base16.base08(),
Base16Value::Base09 => base16.base09(),
Base16Value::Base0A => base16.base0a(),
Base16Value::Base0B => base16.base0b(),
Base16Value::Base0C => base16.base0c(),
Base16Value::Base0D => base16.base0d(),
Base16Value::Base0E => base16.base0e(),
Base16Value::Base0F => base16.base0f(),
};
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;
});
self.bg_color = base16.background();
}
},
}
let mut komorebi_widget = None;
let mut komorebi_widget_idx = None;
let mut komorebi_notification_state = previous_notification_state;
let mut side = None;
for (idx, widget_config) in config.left_widgets.iter().enumerate() {
if let WidgetConfig::Komorebi(config) = widget_config {
komorebi_widget = Some(Komorebi::from(*config));
komorebi_widget_idx = Some(idx);
side = Some(Side::Left);
}
}
for (idx, widget_config) in config.right_widgets.iter().enumerate() {
if let WidgetConfig::Komorebi(config) = widget_config {
komorebi_widget = Some(Komorebi::from(*config));
komorebi_widget_idx = Some(idx);
side = Some(Side::Right);
}
}
let mut left_widgets = config
.left_widgets
.iter()
.map(|config| config.as_boxed_bar_widget())
.collect::<Vec<Box<dyn BarWidget>>>();
let mut right_widgets = config
.right_widgets
.iter()
.map(|config| config.as_boxed_bar_widget())
.collect::<Vec<Box<dyn BarWidget>>>();
if let (Some(idx), Some(mut widget), Some(side)) =
(komorebi_widget_idx, komorebi_widget, side)
{
match komorebi_notification_state {
None => {
komorebi_notification_state = Some(widget.komorebi_notification_state.clone());
}
Some(ref previous) => {
previous
.borrow_mut()
.update_from_config(&widget.komorebi_notification_state.borrow());
widget.komorebi_notification_state = previous.clone();
}
}
let boxed: Box<dyn BarWidget> = Box::new(widget);
match side {
Side::Left => left_widgets[idx] = boxed,
Side::Right => right_widgets[idx] = boxed,
}
}
right_widgets.reverse();
self.left_widgets = left_widgets;
self.right_widgets = right_widgets;
tracing::info!("widget configuration options applied");
self.komorebi_notification_state = komorebi_notification_state;
}
pub fn new(
cc: &eframe::CreationContext<'_>,
rx_gui: Receiver<komorebi_client::Notification>,
rx_config: Receiver<KomobarConfig>,
config: Arc<KomobarConfig>,
) -> Self {
let mut komobar = Self {
config: config.clone(),
komorebi_notification_state: None,
left_widgets: vec![],
right_widgets: vec![],
rx_gui,
rx_config,
bg_color: Style::default().visuals.panel_fill,
};
komobar.apply_config(&cc.egui_ctx, &config, None);
komobar
}
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 property = FontPropertyBuilder::new().family(name).build();
if let Some((font, _)) = system_fonts::get(&property) {
fonts
.font_data
.insert(name.to_owned(), FontData::from_owned(font));
fonts
.families
.entry(FontFamily::Proportional)
.or_default()
.insert(0, name.to_owned());
fonts
.families
.entry(FontFamily::Monospace)
.or_default()
.push(name.to_owned());
// Tell egui to use these fonts:
ctx.set_fonts(fonts);
}
}
}
impl eframe::App for Komobar {
// TODO: I think this is needed for transparency??
// fn clear_color(&self, _visuals: &Visuals) -> [f32; 4] {
// egui::Rgba::TRANSPARENT.to_array()
// let mut background = Color32::from_gray(18).to_normalized_gamma_f32();
// background[3] = 0.9;
// background
// }
fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
if let Ok(updated_config) = self.rx_config.try_recv() {
self.apply_config(
ctx,
&updated_config,
self.komorebi_notification_state.clone(),
);
}
if let Some(komorebi_notification_state) = &self.komorebi_notification_state {
komorebi_notification_state
.borrow_mut()
.handle_notification(self.config.monitor.index, self.rx_gui.clone());
}
let frame = if let Some(frame) = &self.config.frame {
Frame::none()
.inner_margin(Margin::symmetric(
frame.inner_margin.x,
frame.inner_margin.y,
))
.fill(self.bg_color)
} else {
Frame::none().fill(self.bg_color)
};
CentralPanel::default().frame(frame).show(ctx, |ui| {
ui.horizontal_centered(|ui| {
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
for w in &mut self.left_widgets {
w.render(ctx, ui);
}
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
for w in &mut self.right_widgets {
w.render(ctx, ui);
}
})
})
});
}
}
#[derive(Copy, Clone)]
enum Side {
Left,
Right,
}

141
komorebi-bar/src/battery.rs Normal file
View File

@@ -0,0 +1,141 @@
use crate::widget::BarWidget;
use crate::WIDGET_SPACING;
use eframe::egui::text::LayoutJob;
use eframe::egui::Context;
use eframe::egui::FontId;
use eframe::egui::Label;
use eframe::egui::Sense;
use eframe::egui::TextFormat;
use eframe::egui::TextStyle;
use eframe::egui::Ui;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use starship_battery::units::ratio::percent;
use starship_battery::Manager;
use starship_battery::State;
use std::time::Duration;
use std::time::Instant;
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct BatteryConfig {
/// Enable the Battery widget
pub enable: bool,
/// Data refresh interval (default: 10 seconds)
pub data_refresh_interval: Option<u64>,
}
impl From<BatteryConfig> for Battery {
fn from(value: BatteryConfig) -> Self {
let manager = Manager::new().unwrap();
let mut last_state = String::new();
let mut state = None;
if let Ok(mut batteries) = manager.batteries() {
if let Some(Ok(first)) = batteries.nth(0) {
let percentage = first.state_of_charge().get::<percent>();
match first.state() {
State::Charging => state = Some(BatteryState::Charging),
State::Discharging => state = Some(BatteryState::Discharging),
_ => {}
}
last_state = format!("{percentage}%");
}
}
Self {
enable: value.enable,
manager,
last_state,
data_refresh_interval: value.data_refresh_interval.unwrap_or(10),
state: state.unwrap_or(BatteryState::Discharging),
last_updated: Instant::now(),
}
}
}
pub enum BatteryState {
Charging,
Discharging,
}
pub struct Battery {
pub enable: bool,
manager: Manager,
pub state: BatteryState,
data_refresh_interval: u64,
last_state: String,
last_updated: Instant,
}
impl Battery {
fn output(&mut self) -> String {
let mut output = self.last_state.clone();
let now = Instant::now();
if now.duration_since(self.last_updated) > Duration::from_secs(self.data_refresh_interval) {
output.clear();
if let Ok(mut batteries) = self.manager.batteries() {
if let Some(Ok(first)) = batteries.nth(0) {
let percentage = first.state_of_charge().get::<percent>();
match first.state() {
State::Charging => self.state = BatteryState::Charging,
State::Discharging => self.state = BatteryState::Discharging,
_ => {}
}
output = format!("{percentage}%");
}
}
self.last_state.clone_from(&output);
self.last_updated = now;
}
output
}
}
impl BarWidget for Battery {
fn render(&mut self, ctx: &Context, ui: &mut Ui) {
if self.enable {
let output = self.output();
if !output.is_empty() {
let emoji = match self.state {
BatteryState::Charging => egui_phosphor::regular::BATTERY_CHARGING,
BatteryState::Discharging => egui_phosphor::regular::BATTERY_FULL,
};
let font_id = ctx
.style()
.text_styles
.get(&TextStyle::Body)
.cloned()
.unwrap_or_else(FontId::default);
let mut layout_job = LayoutJob::simple(
emoji.to_string(),
font_id.clone(),
ctx.style().visuals.selection.stroke.color,
100.0,
);
layout_job.append(
&output,
10.0,
TextFormat::simple(font_id, ctx.style().visuals.text_color()),
);
ui.add(
Label::new(layout_job)
.selectable(false)
.sense(Sense::click()),
);
}
ui.add_space(WIDGET_SPACING);
}
}
}

170
komorebi-bar/src/config.rs Normal file
View File

@@ -0,0 +1,170 @@
use crate::widget::WidgetConfig;
use base16_egui_themes::Base16;
use eframe::egui::Pos2;
use eframe::egui::TextBuffer;
use eframe::egui::Vec2;
use komorebi_client::Rect;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use std::path::PathBuf;
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct KomobarConfig {
/// Viewport options (see: https://docs.rs/egui/latest/egui/viewport/struct.ViewportBuilder.html)
pub viewport: Option<ViewportConfig>,
/// Frame options (see: https://docs.rs/egui/latest/egui/containers/struct.Frame.html)
pub frame: Option<FrameConfig>,
/// Monitor options
pub monitor: MonitorConfig,
/// Font family
pub font_family: Option<String>,
/// Theme
pub theme: Option<Theme>,
/// Left side widgets (ordered left-to-right)
pub left_widgets: Vec<WidgetConfig>,
/// Right side widgets (ordered left-to-right)
pub right_widgets: Vec<WidgetConfig>,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct ViewportConfig {
/// The desired starting position of the bar (0,0 = top left of the screen)
pub position: Option<Position>,
/// The desired size of the bar from the starting position (usually monitor width x desired height)
pub inner_size: Option<Position>,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct FrameConfig {
/// Margin inside the painted frame
pub inner_margin: Position,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct MonitorConfig {
/// Komorebi monitor index of the monitor on which to render the bar
pub index: usize,
/// Automatically apply a work area offset for this monitor to accommodate the bar
pub work_area_offset: Option<Rect>,
}
impl KomobarConfig {
pub fn read(path: &PathBuf) -> color_eyre::Result<Self> {
let content = std::fs::read_to_string(path)?;
let mut value: Self = match path.extension().unwrap().to_string_lossy().as_str() {
"yaml" => serde_yaml::from_str(&content)?,
"json" => serde_json::from_str(&content)?,
_ => panic!("unsupported format"),
};
if value.frame.is_none() {
value.frame = Some(FrameConfig {
inner_margin: Position { x: 10.0, y: 10.0 },
});
}
Ok(value)
}
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct Position {
/// X coordinate
pub x: f32,
/// Y coordinate
pub y: f32,
}
impl From<Position> for Vec2 {
fn from(value: Position) -> Self {
Self {
x: value.x,
y: value.y,
}
}
}
impl From<Position> for Pos2 {
fn from(value: Position) -> Self {
Self {
x: value.x,
y: value.y,
}
}
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type")]
pub enum Theme {
/// A theme from catppuccin-egui
Catppuccin {
name: Catppuccin,
accent: Option<CatppuccinValue>,
},
/// A theme from base16-egui-themes
Base16 {
name: Base16,
accent: Option<Base16Value>,
},
}
#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
pub enum Base16Value {
Base00,
Base01,
Base02,
Base03,
Base04,
Base05,
#[default]
Base06,
Base07,
Base08,
Base09,
Base0A,
Base0B,
Base0C,
Base0D,
Base0E,
Base0F,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub enum Catppuccin {
Frappe,
Latte,
Macchiato,
Mocha,
}
#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
pub enum CatppuccinValue {
Rosewater,
Flamingo,
Pink,
Mauve,
Red,
Maroon,
Peach,
Yellow,
Green,
Teal,
Sky,
Sapphire,
Blue,
Lavender,
#[default]
Text,
Subtext1,
Subtext0,
Overlay2,
Overlay1,
Overlay0,
Surface2,
Surface1,
Surface0,
Base,
Mantle,
Crust,
}

124
komorebi-bar/src/date.rs Normal file
View File

@@ -0,0 +1,124 @@
use crate::widget::BarWidget;
use crate::WIDGET_SPACING;
use eframe::egui::text::LayoutJob;
use eframe::egui::Context;
use eframe::egui::FontId;
use eframe::egui::Label;
use eframe::egui::Sense;
use eframe::egui::TextFormat;
use eframe::egui::TextStyle;
use eframe::egui::Ui;
use eframe::egui::WidgetText;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct DateConfig {
/// Enable the Date widget
pub enable: bool,
/// Set the Date format
pub format: DateFormat,
}
impl From<DateConfig> for Date {
fn from(value: DateConfig) -> Self {
Self {
enable: value.enable,
format: value.format,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub enum DateFormat {
/// Month/Date/Year format (09/08/24)
MonthDateYear,
/// Year-Month-Date format (2024-09-08)
YearMonthDate,
/// Date-Month-Year format (8-Sep-2024)
DateMonthYear,
/// Day Date Month Year format (8 September 2024)
DayDateMonthYear,
/// Custom format (https://docs.rs/chrono/latest/chrono/format/strftime/index.html)
Custom(String),
}
impl DateFormat {
pub fn next(&mut self) {
match self {
DateFormat::MonthDateYear => *self = Self::YearMonthDate,
DateFormat::YearMonthDate => *self = Self::DateMonthYear,
DateFormat::DateMonthYear => *self = Self::DayDateMonthYear,
DateFormat::DayDateMonthYear => *self = Self::MonthDateYear,
_ => {}
};
}
fn fmt_string(&self) -> String {
match self {
DateFormat::MonthDateYear => String::from("%D"),
DateFormat::YearMonthDate => String::from("%F"),
DateFormat::DateMonthYear => String::from("%v"),
DateFormat::DayDateMonthYear => String::from("%A %e %B %Y"),
DateFormat::Custom(custom) => custom.to_string(),
}
}
}
#[derive(Clone, Debug)]
pub struct Date {
pub enable: bool,
pub format: DateFormat,
}
impl Date {
fn output(&mut self) -> String {
chrono::Local::now()
.format(&self.format.fmt_string())
.to_string()
}
}
impl BarWidget for Date {
fn render(&mut self, ctx: &Context, ui: &mut Ui) {
if self.enable {
let output = self.output();
if !output.is_empty() {
let font_id = ctx
.style()
.text_styles
.get(&TextStyle::Body)
.cloned()
.unwrap_or_else(FontId::default);
let mut layout_job = LayoutJob::simple(
egui_phosphor::regular::CALENDAR_DOTS.to_string(),
font_id.clone(),
ctx.style().visuals.selection.stroke.color,
100.0,
);
layout_job.append(
&output,
10.0,
TextFormat::simple(font_id, ctx.style().visuals.text_color()),
);
if ui
.add(
Label::new(WidgetText::LayoutJob(layout_job.clone()))
.selectable(false)
.sense(Sense::click()),
)
.clicked()
{
self.format.next()
}
}
// TODO: make spacing configurable
ui.add_space(WIDGET_SPACING);
}
}
}

View File

@@ -0,0 +1,245 @@
use crate::widget::BarWidget;
use crate::WIDGET_SPACING;
use crossbeam_channel::Receiver;
use eframe::egui::ColorImage;
use eframe::egui::Context;
use eframe::egui::Image;
use eframe::egui::Label;
use eframe::egui::SelectableLabel;
use eframe::egui::Sense;
use eframe::egui::TextureHandle;
use eframe::egui::TextureOptions;
use eframe::egui::Ui;
use image::RgbaImage;
use komorebi_client::CycleDirection;
use komorebi_client::SocketMessage;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct KomorebiConfig {
/// Configure the Workspaces widget
pub workspaces: KomorebiWorkspacesConfig,
/// Configure the Layout widget
pub layout: KomorebiLayoutConfig,
/// Configure the Focused Window widget
pub focused_window: KomorebiFocusedWindowConfig,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct KomorebiWorkspacesConfig {
/// Enable the Komorebi Workspaces widget
pub enable: bool,
/// Hide workspaces without any windows
pub hide_empty_workspaces: bool,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct KomorebiLayoutConfig {
/// Enable the Komorebi Layout widget
pub enable: bool,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct KomorebiFocusedWindowConfig {
/// Enable the Komorebi Focused Window widget
pub enable: bool,
/// Show the icon of the currently focused window
pub show_icon: bool,
}
impl From<KomorebiConfig> for Komorebi {
fn from(value: KomorebiConfig) -> Self {
Self {
komorebi_notification_state: Rc::new(RefCell::new(KomorebiNotificationState {
selected_workspace: String::new(),
focused_window_title: String::new(),
focused_window_pid: None,
focused_window_icon: None,
layout: String::new(),
workspaces: vec![],
hide_empty_workspaces: value.workspaces.hide_empty_workspaces,
})),
workspaces: value.workspaces,
layout: value.layout,
focused_window: value.focused_window,
}
}
}
#[derive(Clone, Debug)]
pub struct Komorebi {
pub komorebi_notification_state: Rc<RefCell<KomorebiNotificationState>>,
pub workspaces: KomorebiWorkspacesConfig,
pub layout: KomorebiLayoutConfig,
pub focused_window: KomorebiFocusedWindowConfig,
}
impl BarWidget for Komorebi {
fn render(&mut self, ctx: &Context, ui: &mut Ui) {
let mut komorebi_notification_state = self.komorebi_notification_state.borrow_mut();
if self.workspaces.enable {
let mut update = None;
for (i, ws) in komorebi_notification_state.workspaces.iter().enumerate() {
if ui
.add(SelectableLabel::new(
komorebi_notification_state.selected_workspace.eq(ws),
ws.to_string(),
))
.clicked()
{
update = Some(ws.to_string());
komorebi_client::send_message(&SocketMessage::MouseFollowsFocus(false))
.unwrap();
komorebi_client::send_message(&SocketMessage::FocusWorkspaceNumber(i)).unwrap();
// TODO: store MFF value from state and restore that here instead of "true"
komorebi_client::send_message(&SocketMessage::MouseFollowsFocus(true)).unwrap();
komorebi_client::send_message(&SocketMessage::Retile).unwrap();
}
}
if let Some(update) = update {
komorebi_notification_state.selected_workspace = update;
}
ui.add_space(WIDGET_SPACING);
}
if self.layout.enable {
if ui
.add(
Label::new(&komorebi_notification_state.layout)
.selectable(false)
.sense(Sense::click()),
)
.clicked()
{
komorebi_client::send_message(&SocketMessage::CycleLayout(CycleDirection::Next))
.unwrap();
}
ui.add_space(WIDGET_SPACING);
}
if self.focused_window.enable {
if self.focused_window.show_icon {
if let Some(img) = &komorebi_notification_state.focused_window_icon {
ui.add(
Image::from(&img_to_texture(ctx, img))
.maintain_aspect_ratio(true)
.max_height(15.0),
);
}
}
ui.add(Label::new(&komorebi_notification_state.focused_window_title).selectable(false));
ui.add_space(WIDGET_SPACING);
}
}
}
fn img_to_texture(ctx: &Context, rgba_image: &RgbaImage) -> TextureHandle {
let size = [rgba_image.width() as usize, rgba_image.height() as usize];
let pixels = rgba_image.as_flat_samples();
let color_image = ColorImage::from_rgba_unmultiplied(size, pixels.as_slice());
ctx.load_texture("icon", color_image, TextureOptions::default())
}
#[derive(Clone, Debug)]
pub struct KomorebiNotificationState {
pub workspaces: Vec<String>,
pub selected_workspace: String,
pub focused_window_title: String,
pub focused_window_pid: Option<u32>,
pub focused_window_icon: Option<RgbaImage>,
pub layout: String,
pub hide_empty_workspaces: bool,
}
impl KomorebiNotificationState {
pub fn update_from_config(&mut self, config: &Self) {
self.hide_empty_workspaces = config.hide_empty_workspaces;
}
pub fn handle_notification(
&mut self,
monitor_index: usize,
rx_gui: Receiver<komorebi_client::Notification>,
) {
if let Ok(notification) = rx_gui.try_recv() {
let monitor = &notification.state.monitors.elements()[monitor_index];
let focused_workspace_idx = monitor.focused_workspace_idx();
let mut workspaces = vec![];
self.selected_workspace = monitor.workspaces()[focused_workspace_idx]
.name()
.to_owned()
.unwrap_or_else(|| format!("{}", focused_workspace_idx + 1));
for (i, ws) in monitor.workspaces().iter().enumerate() {
let should_add = if self.hide_empty_workspaces {
focused_workspace_idx == i || !ws.containers().is_empty()
} else {
true
};
if should_add {
workspaces.push(ws.name().to_owned().unwrap_or_else(|| format!("{}", i + 1)));
}
}
self.workspaces = workspaces;
self.layout = match monitor.workspaces()[focused_workspace_idx].layout() {
komorebi_client::Layout::Default(layout) => layout.to_string(),
komorebi_client::Layout::Custom(_) => String::from("Custom"),
};
if let Some(container) = monitor.workspaces()[focused_workspace_idx].monocle_container()
{
if let Some(window) = container.focused_window() {
if let Ok(title) = window.title() {
self.focused_window_title.clone_from(&title);
self.focused_window_pid = Some(window.process_id());
let img = windows_icons::get_icon_by_process_id(window.process_id());
self.focused_window_icon = Some(img);
}
}
} else if let Some(container) =
monitor.workspaces()[focused_workspace_idx].focused_container()
{
if let Some(window) = container.focused_window() {
if let Ok(title) = window.title() {
self.focused_window_title.clone_from(&title);
self.focused_window_pid = Some(window.process_id());
let img = windows_icons::get_icon_by_process_id(window.process_id());
self.focused_window_icon = Some(img);
}
}
} else {
self.focused_window_title.clear();
self.focused_window_icon = None;
}
if let Some(container) = monitor.workspaces()[focused_workspace_idx].monocle_container()
{
if let Some(window) = container.focused_window() {
if let Ok(title) = window.title() {
self.focused_window_title.clone_from(&title);
}
}
}
if let Some(window) = monitor.workspaces()[focused_workspace_idx].maximized_window() {
if let Ok(title) = window.title() {
self.focused_window_title.clone_from(&title);
}
}
}
}
}

280
komorebi-bar/src/main.rs Normal file
View File

@@ -0,0 +1,280 @@
mod bar;
mod battery;
mod config;
mod date;
mod komorebi;
mod media;
mod memory;
mod network;
mod storage;
mod time;
mod widget;
use crate::bar::Komobar;
use crate::config::KomobarConfig;
use crate::config::Position;
use clap::Parser;
use eframe::egui::ViewportBuilder;
use font_loader::system_fonts;
use hotwatch::EventKind;
use hotwatch::Hotwatch;
use komorebi_client::SocketMessage;
use schemars::gen::SchemaSettings;
use std::io::BufReader;
use std::io::Read;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tracing_subscriber::EnvFilter;
pub static WIDGET_SPACING: f32 = 10.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)]
config: Option<PathBuf>,
}
fn main() -> color_eyre::Result<()> {
let opts: Opts = Opts::parse();
if opts.schema {
let settings = SchemaSettings::default().with(|s| {
s.option_nullable = false;
s.option_add_null_type = false;
s.inline_subschemas = true;
});
let gen = settings.into_generator();
let socket_message = gen.into_root_schema_for::<KomobarConfig>();
let schema = serde_json::to_string_pretty(&socket_message)?;
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() {
std::env::set_var("RUST_LIB_BACKTRACE", "1");
}
color_eyre::install()?;
if std::env::var("RUST_LOG").is_err() {
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 = PathBuf::from(&home_path);
if home.as_path().is_dir() {
home
} else {
panic!("$Env:KOMOREBI_CONFIG_HOME is set to '{home_path}', which is not a valid directory");
}
},
);
let config_path = opts.config.map_or_else(
|| {
let mut config = home_dir.join("komorebi.bar.json");
if !config.is_file() {
config.pop();
config.push("komorebi.bar.yaml");
}
if !config.is_file() {
None
} else {
Some(config)
}
},
Option::from,
);
let config = match config_path {
None => panic!(
"no komorebi.bar.json or komorebi.bar.yaml found in {}",
home_dir.as_path().to_string_lossy()
),
Some(ref config) => {
tracing::info!(
"found configuration file: {}",
config.as_path().to_string_lossy()
);
KomobarConfig::read(config)?
}
};
let config_path = config_path.unwrap();
let mut viewport_builder = ViewportBuilder::default()
.with_decorations(false)
// .with_transparent(config.transparent)
.with_taskbar(false)
.with_position(Position { x: 0.0, y: 0.0 })
.with_inner_size({
let state = serde_json::from_str::<komorebi_client::State>(
&komorebi_client::send_query(&SocketMessage::State).unwrap(),
)?;
Position {
x: state.monitors.elements()[config.monitor.index].size().right as f32,
y: 20.0,
}
});
if let Some(viewport) = &config.viewport {
if let Some(position) = &viewport.position {
let b = viewport_builder.clone();
viewport_builder = b.with_position(*position);
}
if let Some(inner_size) = &viewport.inner_size {
let b = viewport_builder.clone();
viewport_builder = b.with_inner_size(*inner_size);
}
}
let native_options = eframe::NativeOptions {
viewport: viewport_builder,
..Default::default()
};
if let Some(rect) = &config.monitor.work_area_offset {
komorebi_client::send_message(&SocketMessage::MonitorWorkAreaOffset(
config.monitor.index,
*rect,
))?;
tracing::info!(
"work area offset applied to monitor: {}",
config.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) => {
tx_config.send(updated).unwrap();
tracing::info!(
"configuration file updated: {}",
config_path_cl.as_path().to_string_lossy()
);
}
Err(error) => {
tracing::error!("{error}");
}
},
_ => {}
})?;
tracing::info!("watching configuration file for changes");
let config_arc = Arc::new(config);
eframe::run_native(
"komorebi-bar",
native_options,
Box::new(|cc| {
let config_cl = config_arc.clone();
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 listener = komorebi_client::subscribe("komorebi-bar").unwrap();
tracing::info!("subscribed to komorebi notifications: \"komorebi-bar\"");
for client in listener.incoming() {
match client {
Ok(subscription) => {
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(String::from(
"komorebi-bar",
)),
)
.is_err()
{
std::thread::sleep(Duration::from_secs(1));
}
tracing::info!("reconnected to komorebi");
if let Some(rect) = &config_cl.monitor.work_area_offset {
while komorebi_client::send_message(
&SocketMessage::MonitorWorkAreaOffset(
config_cl.monitor.index,
*rect,
),
)
.is_err()
{
std::thread::sleep(Duration::from_secs(1));
}
}
}
if let Ok(notification) =
serde_json::from_str::<komorebi_client::Notification>(
&String::from_utf8(buffer).unwrap(),
)
{
tracing::debug!("received notification from komorebi");
tx_gui.send(notification).unwrap();
ctx_komorebi.request_repaint();
}
}
Err(error) => {
tracing::error!("{error}");
}
}
}
});
Ok(Box::new(Komobar::new(cc, rx_gui, rx_config, config_arc)))
}),
)
.map_err(|error| color_eyre::eyre::Error::msg(error.to_string()))
}

116
komorebi-bar/src/media.rs Normal file
View File

@@ -0,0 +1,116 @@
use crate::widget::BarWidget;
use crate::WIDGET_SPACING;
use eframe::egui::text::LayoutJob;
use eframe::egui::Context;
use eframe::egui::FontId;
use eframe::egui::Label;
use eframe::egui::Sense;
use eframe::egui::TextFormat;
use eframe::egui::TextStyle;
use eframe::egui::Ui;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use windows::Media::Control::GlobalSystemMediaTransportControlsSessionManager;
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct MediaConfig {
/// Enable the Media widget
pub enable: bool,
}
impl From<MediaConfig> for Media {
fn from(value: MediaConfig) -> Self {
Self::new(value.enable)
}
}
#[derive(Clone, Debug)]
pub struct Media {
pub enable: bool,
pub session_manager: GlobalSystemMediaTransportControlsSessionManager,
}
impl Media {
pub fn new(enable: bool) -> Self {
Self {
enable,
session_manager: GlobalSystemMediaTransportControlsSessionManager::RequestAsync()
.unwrap()
.get()
.unwrap(),
}
}
pub fn toggle(&self) {
if let Ok(session) = self.session_manager.GetCurrentSession() {
if let Ok(op) = session.TryTogglePlayPauseAsync() {
op.get().unwrap_or_default();
}
}
}
fn output(&mut self) -> String {
if let Ok(session) = self.session_manager.GetCurrentSession() {
if let Ok(operation) = session.TryGetMediaPropertiesAsync() {
if let Ok(properties) = operation.get() {
if let (Ok(artist), Ok(title)) = (properties.Artist(), properties.Title()) {
if artist.is_empty() {
return format!("{title}");
}
if title.is_empty() {
return format!("{artist}");
}
return format!("{artist} - {title}");
}
}
}
}
String::new()
}
}
impl BarWidget for Media {
fn render(&mut self, ctx: &Context, ui: &mut Ui) {
if self.enable {
let output = self.output();
if !output.is_empty() {
let font_id = ctx
.style()
.text_styles
.get(&TextStyle::Body)
.cloned()
.unwrap_or_else(FontId::default);
let mut layout_job = LayoutJob::simple(
egui_phosphor::regular::HEADPHONES.to_string(),
font_id.clone(),
ctx.style().visuals.selection.stroke.color,
100.0,
);
layout_job.append(
&output,
10.0,
TextFormat::simple(font_id, ctx.style().visuals.text_color()),
);
if ui
.add(
Label::new(layout_job)
.selectable(false)
.sense(Sense::click()),
)
.clicked()
{
self.toggle();
}
ui.add_space(WIDGET_SPACING);
}
}
}
}

108
komorebi-bar/src/memory.rs Normal file
View File

@@ -0,0 +1,108 @@
use crate::widget::BarWidget;
use crate::WIDGET_SPACING;
use eframe::egui::text::LayoutJob;
use eframe::egui::Context;
use eframe::egui::FontId;
use eframe::egui::Label;
use eframe::egui::Sense;
use eframe::egui::TextFormat;
use eframe::egui::TextStyle;
use eframe::egui::Ui;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use std::process::Command;
use std::time::Duration;
use std::time::Instant;
use sysinfo::RefreshKind;
use sysinfo::System;
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct MemoryConfig {
/// Enable the Memory widget
pub enable: bool,
/// Data refresh interval (default: 10 seconds)
pub data_refresh_interval: Option<u64>,
}
impl From<MemoryConfig> for Memory {
fn from(value: MemoryConfig) -> Self {
let mut system =
System::new_with_specifics(RefreshKind::default().without_cpu().without_processes());
system.refresh_memory();
Self {
enable: value.enable,
system,
data_refresh_interval: value.data_refresh_interval.unwrap_or(10),
last_updated: Instant::now(),
}
}
}
pub struct Memory {
pub enable: bool,
system: System,
data_refresh_interval: u64,
last_updated: Instant,
}
impl Memory {
fn output(&mut self) -> String {
let now = Instant::now();
if now.duration_since(self.last_updated) > Duration::from_secs(self.data_refresh_interval) {
self.system.refresh_memory();
self.last_updated = now;
}
let used = self.system.used_memory();
let total = self.system.total_memory();
format!("RAM: {}%", (used * 100) / total)
}
}
impl BarWidget for Memory {
fn render(&mut self, ctx: &Context, ui: &mut Ui) {
if self.enable {
let output = self.output();
if !output.is_empty() {
let font_id = ctx
.style()
.text_styles
.get(&TextStyle::Body)
.cloned()
.unwrap_or_else(FontId::default);
let mut layout_job = LayoutJob::simple(
egui_phosphor::regular::MEMORY.to_string(),
font_id.clone(),
ctx.style().visuals.selection.stroke.color,
100.0,
);
layout_job.append(
&output,
10.0,
TextFormat::simple(font_id, ctx.style().visuals.text_color()),
);
if ui
.add(
Label::new(layout_job)
.selectable(false)
.sense(Sense::click()),
)
.clicked()
{
if let Err(error) = Command::new("cmd.exe").args(["/C", "taskmgr.exe"]).spawn()
{
eprintln!("{}", error)
}
}
}
ui.add_space(WIDGET_SPACING);
}
}
}

297
komorebi-bar/src/network.rs Normal file
View File

@@ -0,0 +1,297 @@
use crate::widget::BarWidget;
use crate::WIDGET_SPACING;
use eframe::egui::text::LayoutJob;
use eframe::egui::Context;
use eframe::egui::FontId;
use eframe::egui::Label;
use eframe::egui::Sense;
use eframe::egui::TextFormat;
use eframe::egui::TextStyle;
use eframe::egui::Ui;
use num_derive::FromPrimitive;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use std::fmt;
use std::process::Command;
use std::time::Duration;
use std::time::Instant;
use sysinfo::Networks;
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct NetworkConfig {
/// Enable the Network widget
pub enable: bool,
/// Show total data transmitted
pub show_total_data_transmitted: bool,
/// Show network activity
pub show_network_activity: bool,
/// Characters to reserve for network activity data
pub network_activity_fill_characters: Option<usize>,
/// Data refresh interval (default: 10 seconds)
pub data_refresh_interval: Option<u64>,
}
impl From<NetworkConfig> for Network {
fn from(value: NetworkConfig) -> Self {
let mut last_state_data = vec![];
let mut last_state_transmitted = vec![];
let mut networks_total_data_transmitted = Networks::new_with_refreshed_list();
let mut networks_network_activity = Networks::new_with_refreshed_list();
let mut default_interface = String::new();
if let Ok(interface) = netdev::get_default_interface() {
if let Some(friendly_name) = interface.friendly_name {
default_interface.clone_from(&friendly_name);
if value.show_total_data_transmitted {
networks_total_data_transmitted.refresh();
for (interface_name, data) in &networks_total_data_transmitted {
if friendly_name.eq(interface_name) {
last_state_data.push(format!(
"{} {} / {} {}",
egui_phosphor::regular::ARROW_FAT_DOWN,
to_pretty_bytes(data.total_received(), 1),
egui_phosphor::regular::ARROW_FAT_UP,
to_pretty_bytes(data.total_transmitted(), 1),
))
}
}
}
if value.show_network_activity {
networks_network_activity.refresh();
for (interface_name, data) in &networks_network_activity {
if friendly_name.eq(interface_name) {
last_state_transmitted.push(format!(
"{} {: >width$}/s {} {: >width$}/s",
egui_phosphor::regular::ARROW_FAT_DOWN,
to_pretty_bytes(data.received(), 1),
egui_phosphor::regular::ARROW_FAT_UP,
to_pretty_bytes(data.transmitted(), 1),
width = value.network_activity_fill_characters.unwrap_or_default(),
))
}
}
}
}
}
Self {
enable: value.enable,
networks_total_data_transmitted,
networks_network_activity,
default_interface,
data_refresh_interval: value.data_refresh_interval.unwrap_or(10),
show_total_data_transmitted: value.show_total_data_transmitted,
show_network_activity: value.show_network_activity,
network_activity_fill_characters: value
.network_activity_fill_characters
.unwrap_or_default(),
last_state_total_data_transmitted: last_state_data,
last_state_network_activity: last_state_transmitted,
last_updated_total_data_transmitted: Instant::now(),
last_updated_network_activity: Instant::now(),
}
}
}
pub struct Network {
pub enable: bool,
pub show_total_data_transmitted: bool,
pub show_network_activity: bool,
networks_total_data_transmitted: Networks,
networks_network_activity: Networks,
data_refresh_interval: u64,
default_interface: String,
last_state_total_data_transmitted: Vec<String>,
last_state_network_activity: Vec<String>,
last_updated_total_data_transmitted: Instant,
last_updated_network_activity: Instant,
network_activity_fill_characters: usize,
}
impl Network {
fn default_interface(&mut self) {
if let Ok(interface) = netdev::get_default_interface() {
if let Some(friendly_name) = &interface.friendly_name {
self.default_interface.clone_from(friendly_name);
}
}
}
fn network_activity(&mut self) -> Vec<String> {
let mut outputs = self.last_state_network_activity.clone();
let now = Instant::now();
if self.show_network_activity
&& now.duration_since(self.last_updated_network_activity)
> Duration::from_secs(self.data_refresh_interval)
{
outputs.clear();
if let Ok(interface) = netdev::get_default_interface() {
if let Some(friendly_name) = &interface.friendly_name {
if self.show_network_activity {
self.networks_network_activity.refresh();
for (interface_name, data) in &self.networks_network_activity {
if friendly_name.eq(interface_name) {
outputs.push(format!(
"{} {: >width$}/s {} {: >width$}/s",
egui_phosphor::regular::ARROW_FAT_DOWN,
to_pretty_bytes(data.received(), self.data_refresh_interval),
egui_phosphor::regular::ARROW_FAT_UP,
to_pretty_bytes(data.transmitted(), self.data_refresh_interval),
width = self.network_activity_fill_characters,
))
}
}
}
}
}
self.last_state_network_activity.clone_from(&outputs);
self.last_updated_network_activity = now;
}
outputs
}
fn total_data_transmitted(&mut self) -> Vec<String> {
let mut outputs = self.last_state_total_data_transmitted.clone();
let now = Instant::now();
if self.show_total_data_transmitted
&& now.duration_since(self.last_updated_total_data_transmitted)
> Duration::from_secs(self.data_refresh_interval)
{
outputs.clear();
if let Ok(interface) = netdev::get_default_interface() {
if let Some(friendly_name) = &interface.friendly_name {
if self.show_total_data_transmitted {
self.networks_total_data_transmitted.refresh();
for (interface_name, data) in &self.networks_total_data_transmitted {
if friendly_name.eq(interface_name) {
outputs.push(format!(
"{} {} / {} {}",
egui_phosphor::regular::ARROW_FAT_DOWN,
to_pretty_bytes(data.total_received(), 1),
egui_phosphor::regular::ARROW_FAT_UP,
to_pretty_bytes(data.total_transmitted(), 1),
))
}
}
}
}
}
self.last_state_total_data_transmitted.clone_from(&outputs);
self.last_updated_total_data_transmitted = now;
}
outputs
}
}
impl BarWidget for Network {
fn render(&mut self, ctx: &Context, ui: &mut Ui) {
if self.show_total_data_transmitted {
for output in self.total_data_transmitted() {
ui.add(Label::new(output).selectable(false));
}
ui.add_space(WIDGET_SPACING);
}
if self.show_network_activity {
for output in self.network_activity() {
ui.add(Label::new(output).selectable(false));
}
ui.add_space(WIDGET_SPACING);
}
if self.enable {
self.default_interface();
if !self.default_interface.is_empty() {
let font_id = ctx
.style()
.text_styles
.get(&TextStyle::Body)
.cloned()
.unwrap_or_else(FontId::default);
let mut layout_job = LayoutJob::simple(
egui_phosphor::regular::WIFI_HIGH.to_string(),
font_id.clone(),
ctx.style().visuals.selection.stroke.color,
100.0,
);
layout_job.append(
&self.default_interface,
10.0,
TextFormat::simple(font_id, ctx.style().visuals.text_color()),
);
if ui
.add(
Label::new(layout_job)
.selectable(false)
.sense(Sense::click()),
)
.clicked()
{
if let Err(error) = Command::new("cmd.exe").args(["/C", "ncpa"]).spawn() {
eprintln!("{}", error)
}
}
}
ui.add_space(WIDGET_SPACING);
}
}
}
#[derive(Debug, FromPrimitive)]
enum DataUnit {
B = 0,
K = 1,
M = 2,
G = 3,
T = 4,
P = 5,
E = 6,
Z = 7,
Y = 8,
}
impl fmt::Display for DataUnit {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:?}", self)
}
}
fn to_pretty_bytes(input_in_bytes: u64, timespan_in_s: u64) -> String {
let input = input_in_bytes as f32 / timespan_in_s as f32;
let mut magnitude = input.log(1024f32) as u32;
// let the base unit be KiB
if magnitude < 1 {
magnitude = 1;
}
let base: Option<DataUnit> = num::FromPrimitive::from_u32(magnitude);
let result = input / ((1u64) << (magnitude * 10)) as f32;
match base {
Some(DataUnit::B) => format!("{result:.1} B"),
Some(unit) => format!("{result:.1} {unit}iB"),
None => String::from("Unknown data unit"),
}
}

123
komorebi-bar/src/storage.rs Normal file
View File

@@ -0,0 +1,123 @@
use crate::widget::BarWidget;
use crate::WIDGET_SPACING;
use eframe::egui::text::LayoutJob;
use eframe::egui::Context;
use eframe::egui::FontId;
use eframe::egui::Label;
use eframe::egui::Sense;
use eframe::egui::TextFormat;
use eframe::egui::TextStyle;
use eframe::egui::Ui;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use std::process::Command;
use std::time::Duration;
use std::time::Instant;
use sysinfo::Disks;
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct StorageConfig {
/// Enable the Storage widget
pub enable: bool,
/// Data refresh interval (default: 10 seconds)
pub data_refresh_interval: Option<u64>,
}
impl From<StorageConfig> for Storage {
fn from(value: StorageConfig) -> Self {
Self {
enable: value.enable,
disks: Disks::new_with_refreshed_list(),
data_refresh_interval: value.data_refresh_interval.unwrap_or(10),
last_updated: Instant::now(),
}
}
}
pub struct Storage {
pub enable: bool,
disks: Disks,
data_refresh_interval: u64,
last_updated: Instant,
}
impl Storage {
fn output(&mut self) -> Vec<String> {
let now = Instant::now();
if now.duration_since(self.last_updated) > Duration::from_secs(self.data_refresh_interval) {
self.disks.refresh();
self.last_updated = now;
}
let mut disks = vec![];
for disk in &self.disks {
let mount = disk.mount_point();
let total = disk.total_space();
let available = disk.available_space();
let used = total - available;
disks.push(format!(
"{} {}%",
mount.to_string_lossy(),
(used * 100) / total
))
}
disks.sort();
disks.reverse();
disks
}
}
impl BarWidget for Storage {
fn render(&mut self, ctx: &Context, ui: &mut Ui) {
if self.enable {
let font_id = ctx
.style()
.text_styles
.get(&TextStyle::Body)
.cloned()
.unwrap_or_else(FontId::default);
for output in self.output() {
let mut layout_job = LayoutJob::simple(
egui_phosphor::regular::HARD_DRIVES.to_string(),
font_id.clone(),
ctx.style().visuals.selection.stroke.color,
100.0,
);
layout_job.append(
&output,
10.0,
TextFormat::simple(font_id.clone(), ctx.style().visuals.text_color()),
);
if ui
.add(
Label::new(layout_job)
.selectable(false)
.sense(Sense::click()),
)
.clicked()
{
if let Err(error) = Command::new("cmd.exe")
.args([
"/C",
"explorer.exe",
output.split(' ').collect::<Vec<&str>>()[0],
])
.spawn()
{
eprintln!("{}", error)
}
}
ui.add_space(WIDGET_SPACING);
}
}
}
}

115
komorebi-bar/src/time.rs Normal file
View File

@@ -0,0 +1,115 @@
use crate::widget::BarWidget;
use crate::WIDGET_SPACING;
use eframe::egui::text::LayoutJob;
use eframe::egui::Context;
use eframe::egui::FontId;
use eframe::egui::Label;
use eframe::egui::Sense;
use eframe::egui::TextFormat;
use eframe::egui::TextStyle;
use eframe::egui::Ui;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct TimeConfig {
/// Enable the Time widget
pub enable: bool,
/// Set the Time format
pub format: TimeFormat,
}
impl From<TimeConfig> for Time {
fn from(value: TimeConfig) -> Self {
Self {
enable: value.enable,
format: value.format,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub enum TimeFormat {
/// Twelve-hour format (with seconds)
TwelveHour,
/// Twenty-four-hour format (with seconds)
TwentyFourHour,
/// Custom format (https://docs.rs/chrono/latest/chrono/format/strftime/index.html)
Custom(String),
}
impl TimeFormat {
pub fn toggle(&mut self) {
match self {
TimeFormat::TwelveHour => *self = TimeFormat::TwentyFourHour,
TimeFormat::TwentyFourHour => *self = TimeFormat::TwelveHour,
_ => {}
};
}
fn fmt_string(&self) -> String {
match self {
TimeFormat::TwelveHour => String::from("%l:%M:%S %p"),
TimeFormat::TwentyFourHour => String::from("%T"),
TimeFormat::Custom(format) => format.to_string(),
}
}
}
#[derive(Clone, Debug)]
pub struct Time {
pub enable: bool,
pub format: TimeFormat,
}
impl Time {
fn output(&mut self) -> String {
chrono::Local::now()
.format(&self.format.fmt_string())
.to_string()
}
}
impl BarWidget for Time {
fn render(&mut self, ctx: &Context, ui: &mut Ui) {
if self.enable {
let output = self.output();
if !output.is_empty() {
let font_id = ctx
.style()
.text_styles
.get(&TextStyle::Body)
.cloned()
.unwrap_or_else(FontId::default);
let mut layout_job = LayoutJob::simple(
egui_phosphor::regular::CLOCK.to_string(),
font_id.clone(),
ctx.style().visuals.selection.stroke.color,
100.0,
);
layout_job.append(
&output,
10.0,
TextFormat::simple(font_id, ctx.style().visuals.text_color()),
);
if ui
.add(
Label::new(layout_job)
.selectable(false)
.sense(Sense::click()),
)
.clicked()
{
self.format.toggle()
}
}
// TODO: make spacing configurable
ui.add_space(WIDGET_SPACING);
}
}
}

View File

@@ -0,0 +1,52 @@
use crate::battery::Battery;
use crate::battery::BatteryConfig;
use crate::date::Date;
use crate::date::DateConfig;
use crate::komorebi::Komorebi;
use crate::komorebi::KomorebiConfig;
use crate::media::Media;
use crate::media::MediaConfig;
use crate::memory::Memory;
use crate::memory::MemoryConfig;
use crate::network::Network;
use crate::network::NetworkConfig;
use crate::storage::Storage;
use crate::storage::StorageConfig;
use crate::time::Time;
use crate::time::TimeConfig;
use eframe::egui::Context;
use eframe::egui::Ui;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
pub trait BarWidget {
fn render(&mut self, ctx: &Context, ui: &mut Ui);
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub enum WidgetConfig {
Battery(BatteryConfig),
Date(DateConfig),
Komorebi(KomorebiConfig),
Media(MediaConfig),
Memory(MemoryConfig),
Network(NetworkConfig),
Storage(StorageConfig),
Time(TimeConfig),
}
impl WidgetConfig {
pub fn as_boxed_bar_widget(&self) -> Box<dyn BarWidget> {
match self {
WidgetConfig::Battery(config) => Box::new(Battery::from(*config)),
WidgetConfig::Date(config) => Box::new(Date::from(config.clone())),
WidgetConfig::Komorebi(config) => Box::new(Komorebi::from(*config)),
WidgetConfig::Media(config) => Box::new(Media::from(*config)),
WidgetConfig::Memory(config) => Box::new(Memory::from(*config)),
WidgetConfig::Network(config) => Box::new(Network::from(*config)),
WidgetConfig::Storage(config) => Box::new(Storage::from(*config)),
WidgetConfig::Time(config) => Box::new(Time::from(config.clone())),
}
}
}

View File

@@ -30,7 +30,7 @@ os_info = "3.8"
parking_lot = "0.12"
paste = "1"
regex = "1"
schemars = "0.8"
schemars = { workspace = true }
serde = { version = "1", features = ["derive"] }
serde_json = { workspace = true }
serde_yaml = { workspace = true }

View File

@@ -151,9 +151,13 @@ lazy_static! {
kind: ApplicationIdentifier::Class,
id: String::from("IHWindowClass"),
matching_strategy: Option::from(MatchingStrategy::Equals),
}),
MatchingRule::Simple(IdWithIdentifier {
kind: ApplicationIdentifier::Exe,
id: String::from("komorebi-bar.exe"),
matching_strategy: Option::from(MatchingStrategy::Equals),
})
]));
static ref PERMAIGNORE_CLASSES: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![
"Chrome_RenderWidgetHostHWND".to_string(),
]));

View File

@@ -393,6 +393,11 @@ impl Window {
exe
}
pub fn process_id(self) -> u32 {
let (process_id, _) = WindowsApi::window_thread_process_id(self.hwnd());
process_id
}
pub fn class(self) -> Result<String> {
WindowsApi::real_window_class_w(self.hwnd())
}

1196
schema.bar.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -98,6 +98,9 @@
<Component Id='binary3' Guid='*'>
<File Id='exe3' Name='komorebi-gui.exe' DiskId='1' Source='$(var.CargoTargetBinDir)\komorebi-gui.exe' KeyPath='yes' />
</Component>
<Component Id='binary4' Guid='*'>
<File Id='exe4' Name='komorebi-bar.exe' DiskId='1' Source='$(var.CargoTargetBinDir)\komorebi-bar.exe' KeyPath='yes' />
</Component>
</Directory>
</Directory>
</Directory>
@@ -118,6 +121,8 @@
<ComponentRef Id='binary3' />
<ComponentRef Id='binary4' />
<Feature Id='Environment' Title='PATH Environment Variable' Description='Add the install location of the [ProductName] executable to the PATH system environment variable. This allows the [ProductName] executable to be called from any location.' Level='1' Absent='allow'>
<ComponentRef Id='Path' />
</Feature>