diff --git a/Cargo.lock b/Cargo.lock index fb13da7c..7f617ef8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3445,14 +3445,22 @@ version = "0.1.42" dependencies = [ "chrono", "chrono-tz", + "color-eyre", "eframe", "egui-phosphor", + "image 0.25.10", + "komorebi-config", + "komorebi-layouts", + "komorebi-protocol", + "komorebi-themes", + "lazy_static", "reqwest", "schemars 1.2.3", "serde", "serde_json_lenient", "starship-battery", "sysinfo 0.39.6", + "tracing", ] [[package]] @@ -3548,6 +3556,17 @@ dependencies = [ name = "komorebi-scrolling" version = "0.1.42" +[[package]] +name = "komorebi-settings" +version = "0.1.42" +dependencies = [ + "komorebi-config", + "komorebi-layouts", + "komorebi-protocol", + "komorebi-themes", + "parking_lot", +] + [[package]] name = "komorebi-shortcuts" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 32f95367..fec65f2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/komorebi-backend", "crates/komorebi-collections", "crates/komorebi-config", + "crates/komorebi-settings", "komorebi-layouts", "komorebi-scrolling", "komorebic", diff --git a/crates/komorebi-bar-core/Cargo.toml b/crates/komorebi-bar-core/Cargo.toml index c339bcbe..c788591e 100644 --- a/crates/komorebi-bar-core/Cargo.toml +++ b/crates/komorebi-bar-core/Cargo.toml @@ -9,15 +9,23 @@ chrono = { workspace = true } chrono-tz = { workspace = true } eframe = { workspace = true } egui-phosphor = { git = "https://github.com/amPerl/egui-phosphor", rev = "d13688738478ecd12b426e3e74c59d6577a85b59" } +lazy_static = { workspace = true } +image = "0.25" +komorebi-config = { path = "../komorebi-config", default-features = false } reqwest = { version = "0.12", features = ["blocking"] } +komorebi-layouts = { path = "../../komorebi-layouts" } +komorebi-themes = { path = "../../komorebi-themes", default-features = false } +komorebi-protocol = { path = "../komorebi-protocol", default-features = false } serde = { workspace = true } -serde_json = { workspace = true } +color-eyre = { workspace = true } +serde_json = { workspace = true, features = ["preserve_order"] } starship-battery = "0.11" sysinfo = { workspace = true } +tracing = { workspace = true } # Optional dependencies schemars = { workspace = true, optional = true } [features] default = ["schemars"] -schemars = ["dep:schemars"] +schemars = ["dep:schemars", "komorebi-config/schemars", "komorebi-layouts/schemars", "komorebi-themes/schemars", "komorebi-protocol/schemars"] diff --git a/crates/komorebi-bar-core/src/config/komobar.rs b/crates/komorebi-bar-core/src/config/komobar.rs new file mode 100644 index 00000000..1298c775 --- /dev/null +++ b/crates/komorebi-bar-core/src/config/komobar.rs @@ -0,0 +1,605 @@ +use crate::config::types::*; +use crate::statics::AUTO_SELECT_FILL_COLOUR; +use crate::statics::AUTO_SELECT_TEXT_COLOUR; +use crate::statics::DEFAULT_PADDING; +use crate::platform::platform; +use crate::render::Grouping; +use crate::render::RenderConfig; +use crate::render::RenderExt; +use crate::widgets::widget::WidgetConfig; +use eframe::egui::Color32; +use eframe::egui::Context; +use eframe::egui::FontId; +use eframe::egui::TextBuffer; +use eframe::egui::TextStyle; +use komorebi_protocol::pathext::PathExt; +use komorebi_layouts::Rect; +use komorebi_protocol::message::SocketMessage; +use komorebi_themes::colour::Colour; +use komorebi_themes::colour::Rgb; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use std::num::NonZeroU32; +use std::path::PathBuf; +use std::sync::atomic::Ordering; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// The `komorebi.bar.json` configuration file reference for `v0.1.42` +pub struct KomobarConfig { + /// Bar height + #[cfg_attr(feature = "schemars", schemars(extend("default" = 50)))] + pub height: Option, + /// Bar padding. Use one value for all sides or use a grouped padding for horizontal and/or + /// vertical definition which can each take a single value for a symmetric padding or two + /// values for each side, i.e.: + /// ```json + /// "padding": { + /// "horizontal": 10 + /// } + /// ``` + /// or: + /// ```json + /// "padding": { + /// "horizontal": [left, right] + /// } + /// ``` + /// You can also set individual padding on each side like this: + /// ```json + /// "padding": { + /// "top": 10, + /// "bottom": 10, + /// "left": 10, + /// "right": 10, + /// } + /// ``` + /// By default, padding is set to 10 on all sides. + pub padding: Option, + /// Bar margin. Use one value for all sides or use a grouped margin for horizontal and/or + /// vertical definition which can each take a single value for a symmetric margin or two + /// values for each side, i.e.: + /// ```json + /// "margin": { + /// "horizontal": 10 + /// } + /// ``` + /// or: + /// ```json + /// "margin": { + /// "vertical": [top, bottom] + /// } + /// ``` + /// You can also set individual margin on each side like this: + /// ```json + /// "margin": { + /// "top": 10, + /// "bottom": 10, + /// "left": 10, + /// "right": 10, + /// } + /// ``` + /// By default, margin is set to 0 on all sides. + pub margin: Option, + /// Bar positioning options + #[serde(alias = "viewport")] + pub position: Option, + /// Frame options (see: https://docs.rs/egui/latest/egui/containers/frame/struct.Frame.html) + pub frame: Option, + /// The monitor index or the full monitor options + #[cfg_attr(feature = "schemars", schemars(extend("default" = MonitorConfigOrIndex::Index(0))))] + pub monitor: Option, + /// Font family + pub font_family: Option, + /// Font size + #[cfg_attr(feature = "schemars", schemars(extend("default" = 12.5)))] + pub font_size: Option, + /// Scale of the icons relative to the font_size [[1.0-2.0]] + #[cfg_attr(feature = "schemars", schemars(extend("default" = 1.4)))] + pub icon_scale: Option, + /// Max label width before text truncation + #[cfg_attr(feature = "schemars", schemars(extend("default" = 400.0)))] + pub max_label_width: Option, + /// Theme + pub theme: Option, + /// Alpha value for the color transparency [[0-255]] + #[cfg_attr(feature = "schemars", schemars(extend("default" = 200)))] + pub transparency_alpha: Option, + /// Spacing between widgets + #[cfg_attr(feature = "schemars", schemars(extend("default" = 10.0)))] + pub widget_spacing: Option, + /// Visual grouping for widgets + pub grouping: Option, + /// Options for mouse interaction on the bar + pub mouse: Option, + /// Left side widgets (ordered left-to-right) + pub left_widgets: Vec, + /// Center widgets (ordered left-to-right) + pub center_widgets: Option>, + /// Right side widgets (ordered left-to-right) + pub right_widgets: Vec, +} + +impl KomobarConfig { + pub fn aliases(raw: &str) { + let mut map = HashMap::new(); + map.insert("position", ["viewport"]); + map.insert("end", ["inner_frame"]); + + let mut display = false; + + for aliases in map.values() { + for a in aliases { + if raw.contains(a) { + display = true; + } + } + } + + if display { + println!( + "\nYour bar configuration file contains some options that have been renamed or deprecated:\n" + ); + for (canonical, aliases) in map { + for alias in aliases { + if raw.contains(alias) { + println!(r#""{alias}" is now "{canonical}""#); + } + } + } + } + } + + pub fn show_all_icons_on_komorebi_workspace(widgets: &[WidgetConfig]) -> bool { + widgets + .iter() + .any(|w| matches!(w, WidgetConfig::Komorebi(config) if config.workspaces.is_some_and(|w| w.enable && w.display.is_some_and(|s| matches!(s, + WorkspacesDisplayFormat::AllIcons + | WorkspacesDisplayFormat::AllIconsAndText + | WorkspacesDisplayFormat::AllIconsAndTextOnSelected))))) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Position configuration +pub struct PositionConfig { + /// The desired starting position of the bar (0,0 = top left of the screen) + #[serde(alias = "position")] + pub start: Option, + /// The desired size of the bar from the starting position (usually monitor width x desired height) + #[serde(alias = "inner_size")] + pub end: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Frame configuration +pub struct FrameConfig { + /// Margin inside the painted frame + pub inner_margin: Position, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +/// Monitor configuration or monitor index +pub enum MonitorConfigOrIndex { + /// The monitor index where you want the bar to show + Index(usize), + /// The full monitor options with the index and an optional work_area_offset + MonitorConfig(MonitorConfig), +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Monitor configuration +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, +} + +pub type Padding = SpacingKind; +pub type Margin = SpacingKind; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +// WARNING: To any developer messing with this code in the future: The order here matters! +// `Grouped` needs to come last, otherwise serde might mistaken an `IndividualSpacingConfig` for a +// `GroupedSpacingConfig` with both `vertical` and `horizontal` set to `None` ignoring the +// individual values. +/// Spacing kind +pub enum SpacingKind { + /// Spacing applied to all sides + All(f32), + /// Individual spacing applied to each side + Individual(IndividualSpacingConfig), + /// Grouped vertical and horizontal spacing + Grouped(GroupedSpacingConfig), +} + +impl SpacingKind { + pub fn to_individual(&self, default: f32) -> IndividualSpacingConfig { + match self { + SpacingKind::All(m) => IndividualSpacingConfig::all(*m), + SpacingKind::Grouped(grouped_spacing_config) => { + let vm = grouped_spacing_config.vertical.as_ref().map_or( + IndividualSpacingConfig::vertical(default), + |vm| match vm { + GroupedSpacingOptions::Symmetrical(m) => { + IndividualSpacingConfig::vertical(*m) + } + GroupedSpacingOptions::Split(tm, bm) => { + IndividualSpacingConfig::vertical(*tm).bottom(*bm) + } + }, + ); + let hm = grouped_spacing_config.horizontal.as_ref().map_or( + IndividualSpacingConfig::horizontal(default), + |hm| match hm { + GroupedSpacingOptions::Symmetrical(m) => { + IndividualSpacingConfig::horizontal(*m) + } + GroupedSpacingOptions::Split(lm, rm) => { + IndividualSpacingConfig::horizontal(*lm).right(*rm) + } + }, + ); + IndividualSpacingConfig { + top: vm.top, + bottom: vm.bottom, + left: hm.left, + right: hm.right, + } + } + SpacingKind::Individual(m) => *m, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Grouped vertical and horizontal spacing +pub struct GroupedSpacingConfig { + /// Vertical grouped spacing + pub vertical: Option, + /// Horizontal grouped spacing + pub horizontal: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +/// Grouped spacing options +pub enum GroupedSpacingOptions { + /// Symmetrical grouped spacing + Symmetrical(f32), + /// Split grouped spacing + Split(f32, f32), +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Individual spacing configuration +pub struct IndividualSpacingConfig { + /// Spacing for the top + pub top: f32, + /// Spacing for the bottom + pub bottom: f32, + /// Spacing for the left + pub left: f32, + /// Spacing for the right + pub right: f32, +} + +#[allow(dead_code)] +impl IndividualSpacingConfig { + pub const ZERO: Self = IndividualSpacingConfig { + top: 0.0, + bottom: 0.0, + left: 0.0, + right: 0.0, + }; + + pub fn all(value: f32) -> Self { + IndividualSpacingConfig { + top: value, + bottom: value, + left: value, + right: value, + } + } + + pub fn horizontal(value: f32) -> Self { + IndividualSpacingConfig { + top: 0.0, + bottom: 0.0, + left: value, + right: value, + } + } + + pub fn vertical(value: f32) -> Self { + IndividualSpacingConfig { + top: value, + bottom: value, + left: 0.0, + right: 0.0, + } + } + + pub fn top(self, value: f32) -> Self { + IndividualSpacingConfig { top: value, ..self } + } + + pub fn bottom(self, value: f32) -> Self { + IndividualSpacingConfig { + bottom: value, + ..self + } + } + + pub fn left(self, value: f32) -> Self { + IndividualSpacingConfig { + left: value, + ..self + } + } + + pub fn right(self, value: f32) -> Self { + IndividualSpacingConfig { + right: value, + ..self + } + } +} + +pub fn get_individual_spacing( + default: f32, + spacing: &Option, +) -> IndividualSpacingConfig { + spacing + .as_ref() + .map_or(IndividualSpacingConfig::all(default), |s| { + s.to_individual(default) + }) +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +/// Mouse message +pub enum MouseMessage { + /// Send a message to the komorebi client. + /// By default, a batch of messages are sent in the following order: + /// FocusMonitorAtCursor => + /// MouseFollowsFocus(false) => + /// {message} => + /// MouseFollowsFocus({original.value}) + /// + /// Example: + /// ```json + /// "on_extra2_click": { + /// "message": { + /// "type": "NewWorkspace" + /// } + /// }, + /// ``` + /// or: + /// ```json + /// "on_middle_click": { + /// "focus_monitor_at_cursor": false, + /// "ignore_mouse_follows_focus": false, + /// "message": { + /// "type": "TogglePause" + /// } + /// } + /// ``` + /// or: + /// ```json + /// "on_scroll_up": { + /// "message": { + /// "type": "CycleFocusWorkspace", + /// "content": "Previous" + /// } + /// } + /// ``` + Komorebi(KomorebiMouseMessage), + /// Execute a custom command. + /// CMD (%variable%), Bash ($variable) and PowerShell ($Env:variable) variables will be resolved. + /// Example: `komorebic toggle-pause` + Command(String), +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Komorebi socket mouse message +pub struct KomorebiMouseMessage { + /// Send the FocusMonitorAtCursor message + #[cfg_attr(feature = "schemars", schemars(extend("default" = true)))] + pub focus_monitor_at_cursor: Option, + /// Wrap the {message} with a MouseFollowsFocus(false) and MouseFollowsFocus({original.value}) message + #[cfg_attr(feature = "schemars", schemars(extend("default" = true)))] + pub ignore_mouse_follows_focus: Option, + /// The message to send to the komorebi client + pub message: komorebi_protocol::message::SocketMessage, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Mouse configuration +pub struct MouseConfig { + /// Command to send on primary/left double button click + pub on_primary_double_click: Option, + /// Command to send on secondary/right button click + pub on_secondary_click: Option, + /// Command to send on middle button click + pub on_middle_click: Option, + /// Command to send on extra1/back button click + pub on_extra1_click: Option, + /// Command to send on extra2/forward button click + pub on_extra2_click: Option, + + /// Defines how many points a user needs to scroll vertically to make a "tick" on a mouse/touchpad/touchscreen + #[cfg_attr(feature = "schemars", schemars(extend("default" = 30.0)))] + pub vertical_scroll_threshold: Option, + /// Command to send on scrolling up (every tick) + pub on_scroll_up: Option, + /// Command to send on scrolling down (every tick) + pub on_scroll_down: Option, + + /// Defines how many points a user needs to scroll horizontally to make a "tick" on a mouse/touchpad/touchscreen + #[cfg_attr(feature = "schemars", schemars(extend("default" = 30.0)))] + pub horizontal_scroll_threshold: Option, + /// Command to send on scrolling left (every tick) + pub on_scroll_left: Option, + /// Command to send on scrolling right (every tick) + pub on_scroll_right: Option, +} + +impl MouseConfig { + pub fn has_command(&self) -> bool { + [ + &self.on_primary_double_click, + &self.on_secondary_click, + &self.on_middle_click, + &self.on_extra1_click, + &self.on_extra2_click, + &self.on_scroll_up, + &self.on_scroll_down, + &self.on_scroll_left, + &self.on_scroll_right, + ] + .iter() + .any(|opt| matches!(opt, Some(MouseMessage::Command(_)))) + } +} + +impl MouseMessage { + pub fn execute(&self, mouse_follows_focus: bool) { + match self { + MouseMessage::Komorebi(config) => { + let mut messages = Vec::new(); + + if config.focus_monitor_at_cursor.unwrap_or(true) { + messages.push(SocketMessage::FocusMonitorAtCursor); + } + + if config.ignore_mouse_follows_focus.unwrap_or(true) { + messages.push(SocketMessage::MouseFollowsFocus(false)); + messages.push(config.message.clone()); + messages.push(SocketMessage::MouseFollowsFocus(mouse_follows_focus)); + } else { + messages.push(config.message.clone()); + } + + tracing::debug!("Sending messages: {messages:?}"); + + if platform().send_komorebi_batch(messages).is_err() { + tracing::error!("could not send commands"); + } + } + MouseMessage::Command(cmd) => { + tracing::debug!("Executing command: {}", cmd); + + let cmd_no_env = cmd.replace_env(); + + if platform() + .exec_shell_command(cmd_no_env.to_str().expect("Invalid command")) + .is_err() + { + tracing::error!("Failed to execute '{}'", cmd); + } + } + }; + } +} + +impl KomobarConfig { + pub fn read(path: &PathBuf) -> color_eyre::Result { + let content = std::fs::read_to_string(path)?; + let mut value: Self = match path.extension().unwrap().to_string_lossy().as_str() { + "json" => serde_json::from_str(&content)?, + _ => panic!("unsupported format"), + }; + + crate::widgets::widget::warn_unsupported( + value + .left_widgets + .iter() + .chain(value.center_widgets.iter().flatten()) + .chain(value.right_widgets.iter()), + ); + + if value.mouse.is_some() && !cfg!(target_os = "windows") { + tracing::warn!("the `mouse` options are not supported on this platform and are ignored"); + } + + if value.frame.is_none() { + value.frame = Some(FrameConfig { + inner_margin: Position { + x: DEFAULT_PADDING, + y: DEFAULT_PADDING, + }, + }); + } + + Ok(value) + } +} + +impl RenderExt for &KomobarConfig { + fn new_renderconfig( + &self, + ctx: &Context, + background_color: Color32, + icon_scale: Option, + ) -> RenderConfig { + let text_font_id = ctx + .style() + .text_styles + .get(&TextStyle::Body) + .cloned() + .unwrap_or_else(FontId::default); + + let mut icon_font_id = text_font_id.clone(); + icon_font_id.size *= icon_scale.unwrap_or(1.4).clamp(1.0, 2.0); + + let monitor_idx = match &self.monitor { + Some(MonitorConfigOrIndex::MonitorConfig(monitor_config)) => monitor_config.index, + Some(MonitorConfigOrIndex::Index(idx)) => *idx, + None => 0, + }; + + // check if any of the alignments have a komorebi widget with the workspace set to show all icons + let show_all_icons = + KomobarConfig::show_all_icons_on_komorebi_workspace(&self.left_widgets) + || self + .center_widgets + .as_ref() + .is_some_and(|list| KomobarConfig::show_all_icons_on_komorebi_workspace(list)) + || KomobarConfig::show_all_icons_on_komorebi_workspace(&self.right_widgets); + + RenderConfig { + monitor_idx, + spacing: self.widget_spacing.unwrap_or(10.0), + grouping: self.grouping.unwrap_or(Grouping::None), + background_color, + alignment: None, + more_inner_margin: false, + applied_on_widget: false, + text_font_id, + icon_font_id, + show_all_icons, + auto_select_fill: NonZeroU32::new(AUTO_SELECT_FILL_COLOUR.load(Ordering::SeqCst)) + .map(|c| Colour::Rgb(Rgb::from(c.get())).into()), + auto_select_text: NonZeroU32::new(AUTO_SELECT_TEXT_COLOUR.load(Ordering::SeqCst)) + .map(|c| Colour::Rgb(Rgb::from(c.get())).into()), + } + } +} + +pub use komorebi_themes::KomobarTheme; diff --git a/crates/komorebi-bar-core/src/config/mod.rs b/crates/komorebi-bar-core/src/config/mod.rs index 26535e72..2f58be3a 100644 --- a/crates/komorebi-bar-core/src/config/mod.rs +++ b/crates/komorebi-bar-core/src/config/mod.rs @@ -1,3 +1,5 @@ +pub mod komobar; pub mod types; +pub use komobar::*; pub use types::*; diff --git a/crates/komorebi-bar-core/src/lib.rs b/crates/komorebi-bar-core/src/lib.rs index 9088d849..fcf4bf3a 100644 --- a/crates/komorebi-bar-core/src/lib.rs +++ b/crates/komorebi-bar-core/src/lib.rs @@ -18,3 +18,5 @@ pub mod render; pub mod widgets; pub mod platform; + +pub mod schema; diff --git a/crates/komorebi-bar-core/src/platform.rs b/crates/komorebi-bar-core/src/platform.rs index 635e5dc3..c7c2316e 100644 --- a/crates/komorebi-bar-core/src/platform.rs +++ b/crates/komorebi-bar-core/src/platform.rs @@ -3,6 +3,8 @@ //! A shell implements [`BarPlatform`] and calls [`install`] first thing in `main`; the widgets //! reach it through [`platform`] when they are built or clicked. +use crate::widgets::icons::ImageIcon; +use komorebi_protocol::message::SocketMessage; use std::io; use std::sync::OnceLock; @@ -17,6 +19,9 @@ pub enum SystemTool { NetworkSettings, /// A URL, in the default browser Url(String), + /// The location a storage widget label names (the label text, as shown: the platform + /// knows how its labels are built), in the file manager + StorageLocation(String), } pub trait BarPlatform: Send + Sync { @@ -26,6 +31,19 @@ pub trait BarPlatform: Send + Sync { /// The GitHub API URL that says which release of this platform's komorebi is the latest. fn latest_release_api_url(&self) -> &'static str; + /// The icon of the program a command string would start (Windows: the executable's icon; + /// macOS: none). + fn command_icon(&self, command: &str) -> Option; + + /// Runs a command line in the bar's shell session (Windows: its PowerShell session). + fn exec_shell_command(&self, command: &str) -> color_eyre::Result<()>; + + /// Sends one message to the komorebi daemon. + fn send_komorebi_message(&self, message: &SocketMessage) -> io::Result<()>; + + /// Sends messages to the komorebi daemon as one batch. + fn send_komorebi_batch(&self, messages: Vec) -> io::Result<()>; + /// The glyph the date widget shows as its calendar icon. fn calendar_icon(&self) -> &'static str; } diff --git a/crates/komorebi-bar-core/src/schema/changes.rs b/crates/komorebi-bar-core/src/schema/changes.rs new file mode 100644 index 00000000..8175560b --- /dev/null +++ b/crates/komorebi-bar-core/src/schema/changes.rs @@ -0,0 +1,99 @@ +//! What the macOS bar's schema says differently from the union, as data. +//! +//! The union is written once: it is the Windows text of `schema.bar.json` (every widget, the +//! mouse options, Windows' spelling of the documentation). The macOS view removes what the macOS +//! bar does not have (the keyboard, media and systray widgets, the mouse options and their +//! definitions, which pruning of the unreachable definitions finishes) and sets the text and the +//! defaults that read differently. Each entry is a convergence candidate: dropping one changes +//! one line of `komorebi-for-mac/schema.bar.json` and of `nix/komorebi-bar-options.nix`. +//! +//! Generated from the difference between the two checked-in files; `tests/schema_oracle.rs` +//! proves that the view of the Windows file is byte-identical to the macOS file. + +/// One change of a view +#[derive(Clone, Copy, Debug)] +pub enum Change { + /// Replace the value at the JSON pointer with this JSON text + Set(&'static str), + /// Remove the key at the JSON pointer + Remove, + /// Remove the elements of the array at the JSON pointer whose `title` is this + RemoveTitled(&'static str), +} + +/// The changes of the macOS view, in order. +pub const MACOS: &[(&str, Change)] = &[ + ( + "/description", + Change::Set( + r####""The `komorebi.bar.json` static configuration file reference for komorebi for Mac `v0.1.0`\n\n`\"$schema\": \"https://komorebi-for-mac.lgug2z.com/komorebi.bar.0.1.0.schema.json\"`""####, + ), + ), + ( + "/properties/height", + Change::Set( + r####"{"description":"Bar height","type":["number","null"],"format":"float","default":50.0}"####, + ), + ), + ( + "/properties/margin", + Change::Set( + r####"{"description":"Bar margin. Use one value for all sides or use a grouped margin for horizontal and/or\nvertical definition which can each take a single value for a symmetric margin or two\nvalues for each side, i.e.:\n```json\n\"margin\": {\n \"horizontal\": 10.0\n}\n```\nor:\n```json\n\"margin\": {\n \"vertical\": [top, bottom]\n}\n```\nYou can also set individual margin on each side like this:\n```json\n\"margin\": {\n \"top\": 10.0,\n \"bottom\": 10.0,\n \"left\": 10.0,\n \"right\": 10.0\n}\n```","anyOf":[{"$ref":"#/$defs/SpacingKind"},{"type":"null"}]}"####, + ), + ), + ( + "/properties/padding", + Change::Set( + r####"{"description":"Bar padding. Use one value for all sides or use a grouped padding for horizontal and/or\nvertical definition which can each take a single value for a symmetric padding or two\nvalues for each side, i.e.:\n```json\n\"padding\": {\n \"horizontal\": 10.0\n}\n```\nor:\n```json\n\"padding\": {\n \"horizontal\": [left, right]\n}\n```\nYou can also set individual padding on each side like this:\n```json\n\"padding\": {\n \"top\": 10.0,\n \"bottom\": 10.0,\n \"left\": 10.0,\n \"right\": 10.0\n}\n```","anyOf":[{"$ref":"#/$defs/SpacingKind"},{"type":"null"}]}"####, + ), + ), + ("/properties/mouse", Change::Remove), + ( + "/$defs/KomorebiFocusedContainerConfig/properties/show_icon", + Change::Set( + r####"{"description":"DEPRECATED: use 'display' instead (Show the icon of the currently focused container)","type":["boolean","null"]}"####, + ), + ), + ( + "/$defs/KomorebiLayout", + Change::Set( + r####"{"description":"Komorebi layout kind","oneOf":[{"title":"Default","description":"Predefined layout","type":"object","properties":{"Default":{"$ref":"#/$defs/DefaultLayout"}},"additionalProperties":false,"required":["Default"]},{"description":"Monocle mode","type":"string","const":"Monocle"},{"description":"Floating layer","type":"string","const":"Floating"},{"description":"Paused","type":"string","const":"Paused"},{"description":"Custom layout","type":"string","const":"Custom"}]}"####, + ), + ), + ( + "/$defs/NetworkConfig/properties/default_interface_options", + Change::Remove, + ), + ( + "/$defs/StorageDisplayName/oneOf/0/description", + Change::Set(r####""Display label as mount point eg. /""####), + ), + ( + "/$defs/StorageDisplayName/oneOf/1/description", + Change::Set(r####""Display label as name eg. Macintosh HD""####), + ), + ( + "/$defs/StorageDisplayName/oneOf/2/description", + Change::Set(r####""Display label as mount then name eg. / Macintosh HD""####), + ), + ( + "/$defs/StorageDisplayName/oneOf/3/description", + Change::Set(r####""Display label as name then mount eg. Macintosh HD /""####), + ), + ( + "/$defs/TimeConfig/properties/changing_icon", + Change::Set( + r####"{"description":"Change the icon depending on the time. The default icon is used between 8:30 and 12:00","type":["boolean","null"],"default":false}"####, + ), + ), + ( + "/$defs/TimeFormat/oneOf/6/title", + Change::Set(r####""Default""####), + ), + ( + "/$defs/WidgetConfig/oneOf", + Change::RemoveTitled("Keyboard"), + ), + ("/$defs/WidgetConfig/oneOf", Change::RemoveTitled("Media")), + ("/$defs/WidgetConfig/oneOf", Change::RemoveTitled("Systray")), +]; diff --git a/crates/komorebi-bar-core/src/schema/mod.rs b/crates/komorebi-bar-core/src/schema/mod.rs new file mode 100644 index 00000000..ee284574 --- /dev/null +++ b/crates/komorebi-bar-core/src/schema/mod.rs @@ -0,0 +1,128 @@ +//! The JSON Schema of `komorebi.bar.json`, one per platform, as views of one union. +//! +//! The union is the Windows schema: every widget and option any bar reads, in Windows' spelling. +//! [`view_of`] turns it into the schema a platform checks in: it applies the platform's +//! [`changes`] and removes the definitions nothing refers to any more. The Windows view is the +//! union itself. +//! +//! The changes are idempotent (setting text that is already there, or removing what is not +//! there, does nothing), so a bar can print its own generated schema through its view. + +pub mod changes; + +use changes::Change; +use serde_json::Map; +use serde_json::Value; + +/// A platform that has a bar +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SchemaPlatform { + Windows, + MacOs, +} + +/// The changes a platform's view applies, in order +#[must_use] +pub const fn changes_of(platform: SchemaPlatform) -> &'static [(&'static str, Change)] { + match platform { + SchemaPlatform::Windows => &[], + SchemaPlatform::MacOs => changes::MACOS, + } +} + +/// The text a bar prints for `--schema`: pretty JSON followed by a newline, of the view of the +/// union of the bar configuration. +#[must_use] +pub fn view_text(union: Value, platform: SchemaPlatform) -> String { + let mut text = + serde_json::to_string_pretty(&view_of(union, platform)).expect("a schema is JSON"); + text.push('\n'); + text +} + +/// The view of `schema` for `platform`. +#[must_use] +pub fn view_of(mut schema: Value, platform: SchemaPlatform) -> Value { + for (pointer, change) in changes_of(platform) { + apply(&mut schema, pointer, *change); + } + prune_unreachable(&mut schema); + schema +} + +fn apply(schema: &mut Value, pointer: &str, change: Change) { + match change { + Change::Set(json) => { + if let Some(target) = schema.pointer_mut(pointer) { + *target = serde_json::from_str(json).expect("a change is JSON"); + } + } + Change::Remove => { + if let Some((parent, key)) = pointer.rsplit_once('/') + && let Some(Value::Object(map)) = schema.pointer_mut(parent) + { + map.retain(|k, _| k != key); + } + } + Change::RemoveTitled(title) => { + if let Some(Value::Array(items)) = schema.pointer_mut(pointer) { + items.retain(|item| item.get("title").and_then(Value::as_str) != Some(title)); + } + } + } +} + +/// Keep the definitions that the schema refers to, directly or through another definition. +fn prune_unreachable(schema: &mut Value) { + let mut reachable = Vec::new(); + collect_refs(schema, &mut reachable); + let Some(defs) = schema.get("$defs").and_then(Value::as_object).cloned() else { + return; + }; + let mut next = 0; + while next < reachable.len() { + if let Some(def) = defs.get(&reachable[next]) { + let mut found = Vec::new(); + collect_refs(def, &mut found); + for name in found { + if !reachable.contains(&name) { + reachable.push(name); + } + } + } + next += 1; + } + let kept: Map = defs + .into_iter() + .filter(|(name, _)| reachable.contains(name)) + .collect(); + schema["$defs"] = Value::Object(kept); +} + +fn collect_refs(value: &Value, found: &mut Vec) { + match value { + Value::Object(map) => { + if let Some(Value::String(target)) = map.get("$ref") + && let Some(name) = target.strip_prefix("#/$defs/") + && !found.iter().any(|n| n == name) + { + found.push(name.to_string()); + } + for (key, child) in map { + if key != "$defs" { + collect_refs(child, found); + } + } + } + Value::Array(items) => items.iter().for_each(|item| collect_refs(item, found)), + _ => {} + } +} + +/// The text `--schema` prints for the bar configuration type `T`, as `platform` checks it in. +#[cfg(feature = "schemars")] +#[must_use] +pub fn generated_text(platform: SchemaPlatform) -> String { + let union = serde_json::to_value(schemars::schema_for!(T)).expect("a schema is JSON"); + view_text(union, platform) +} diff --git a/komorebi-bar/src/widgets/applications.rs b/crates/komorebi-bar-core/src/widgets/applications.rs similarity index 90% rename from komorebi-bar/src/widgets/applications.rs rename to crates/komorebi-bar-core/src/widgets/applications.rs index 20bc1fe3..2bdcd18a 100644 --- a/komorebi-bar/src/widgets/applications.rs +++ b/crates/komorebi-bar-core/src/widgets/applications.rs @@ -1,4 +1,5 @@ -use super::ImageIcon; +use super::icons::ImageIcon; +use crate::platform::platform; use crate::render::RenderConfig; use crate::selected_frame::SelectableFrame; use crate::widgets::widget::BarWidget; @@ -17,17 +18,14 @@ use eframe::egui::StrokeKind; use eframe::egui::Ui; use eframe::egui::Vec2; use eframe::egui::vec2; -use komorebi_client::PathExt; +use komorebi_protocol::pathext::PathExt; use serde::Deserialize; use serde::Serialize; -use std::borrow::Cow; -use std::path::Path; use std::process::Command; use std::sync::Arc; use std::time::Duration; use std::time::Instant; use tracing; -use which::which; /// Minimum interval between consecutive application launches to prevent accidental spamming. const MIN_LAUNCH_INTERVAL: Duration = Duration::from_millis(800); @@ -273,13 +271,7 @@ impl Icon { /// - Returns `None` if the executable path is unavailable or icon extraction fails. #[inline] pub fn try_from_command(command: &UserCommand) -> Option { - let path = command.get_executable()?; - let image_icon = ImageIcon::try_load(path.as_ref(), || { - let path_str = path.to_str()?; - windows_icons::get_icon_by_path(path_str) - .or_else(|| windows_icons_fallback::get_icon_by_path(path_str)) - })?; - Some(Icon::Image(image_icon)) + platform().command_icon(&command.command).map(Icon::Image) } /// Renders the icon in the given [`Ui`] using the provided [`IconConfig`]. @@ -364,28 +356,6 @@ impl UserCommand { } } - /// Attempts to resolve the executable path from the command string. - /// - /// Resolution logic: - /// - Splits the command by ".exe" and checks if the first part is an existing file. - /// - If not, attempts to locate the binary using [`which`] on this name. - /// - If still unresolved, takes the first word (separated by whitespace) and attempts - /// to find it in the system `PATH` using [`which`]. - /// - /// Returns `None` if no executable path can be determined. - #[inline] - pub fn get_executable(&self) -> Option> { - if let Some(binary) = self.command.split(".exe").next().map(Path::new) { - if binary.is_file() { - return Some(Cow::Borrowed(binary)); - } else if let Ok(binary) = which(binary) { - return Some(Cow::Owned(binary)); - } - } - - which(self.command.split(' ').next()?).ok().map(Cow::Owned) - } - /// Attempts to launch the specified command in a separate thread if enough time has passed /// since the last launch. This prevents repeated launches from rapid consecutive clicks. /// diff --git a/crates/komorebi-bar-core/src/widgets/icons.rs b/crates/komorebi-bar-core/src/widgets/icons.rs new file mode 100644 index 00000000..6c6d66ff --- /dev/null +++ b/crates/komorebi-bar-core/src/widgets/icons.rs @@ -0,0 +1,181 @@ +use eframe::egui::ColorImage; +use eframe::egui::Context; +use eframe::egui::TextureHandle; +use eframe::egui::TextureOptions; +use image::RgbaImage; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use std::sync::LazyLock; +use std::sync::RwLock; + +/// Global cache for icon images and their associated GPU textures. +pub static ICONS_CACHE: IconsCache = IconsCache::new(); + +/// In-memory cache for icon images and their associated GPU textures. +/// +/// Stores raw [`ColorImage`]s and [`TextureHandle`]s keyed by [`ImageIconId`]. +/// Texture entries are context-dependent and automatically invalidated when the [`Context`] changes. +#[allow(clippy::type_complexity)] +pub struct IconsCache { + textures: LazyLock, HashMap)>>, + images: LazyLock>>>, +} + +impl IconsCache { + /// Creates a new empty IconsCache instance. + #[inline] + pub const fn new() -> Self { + Self { + textures: LazyLock::new(|| RwLock::new((None, HashMap::new()))), + images: LazyLock::new(|| RwLock::new(HashMap::new())), + } + } + + /// Retrieves or creates a texture handle for the given icon ID and image. + /// + /// If a texture for the given ID already exists for the current [`Context`], it is reused. + /// Otherwise, a new texture is created, inserted into the cache, and returned. + /// The cache is reset if the [`Context`] has changed. + #[inline] + pub fn texture(&self, ctx: &Context, id: &ImageIconId, img: &Arc) -> TextureHandle { + if let Some(texture) = self.get_texture(ctx, id) { + return texture; + } + let texture_handle = ctx.load_texture("icon", img.clone(), TextureOptions::default()); + self.insert_texture(ctx, id.clone(), texture_handle.clone()); + texture_handle + } + + /// Returns the cached texture for the given icon ID if it exists and matches the current [`Context`]. + pub fn get_texture(&self, ctx: &Context, id: &ImageIconId) -> Option { + let textures_lock = self.textures.read().unwrap(); + if textures_lock.0.as_ref() == Some(ctx) { + return textures_lock.1.get(id).cloned(); + } + None + } + + /// Inserts a texture handle, resetting the cache if the [`Context`] has changed. + pub fn insert_texture(&self, ctx: &Context, id: ImageIconId, texture: TextureHandle) { + let mut textures_lock = self.textures.write().unwrap(); + + if textures_lock.0.as_ref() != Some(ctx) { + textures_lock.0 = Some(ctx.clone()); + textures_lock.1.clear(); + } + + textures_lock.1.insert(id, texture); + } + + /// Returns the cached image for the given icon ID, if available. + pub fn get_image(&self, id: &ImageIconId) -> Option> { + self.images.read().unwrap().get(id).cloned() + } + + /// Caches a raw [`ColorImage`] associated with the given icon ID. + pub fn insert_image(&self, id: ImageIconId, image: Arc) { + self.images.write().unwrap().insert(id, image); + } + + /// Removes the cached image and texture for the given icon ID. + pub fn remove(&self, id: &ImageIconId) { + self.images.write().unwrap().remove(id); + self.textures.write().unwrap().1.remove(id); + } +} + +#[inline] +pub fn rgba_to_color_image(rgba_image: &RgbaImage) -> ColorImage { + let size = [rgba_image.width() as usize, rgba_image.height() as usize]; + let pixels = rgba_image.as_flat_samples(); + ColorImage::from_rgba_unmultiplied(size, pixels.as_slice()) +} + +/// Represents an image-based icon with a unique ID and pixel data. +#[derive(Clone, Debug)] +pub struct ImageIcon { + /// Unique identifier for the image icon, used for texture caching. + pub id: ImageIconId, + /// Shared pixel data of the icon in `ColorImage` format. + pub image: Arc, +} + +impl ImageIcon { + /// Creates a new [`ImageIcon`] from the given ID and image data. + #[inline] + pub fn new(id: ImageIconId, image: Arc) -> Self { + Self { id, image } + } + + /// Loads an [`ImageIcon`] from [`ICONS_CACHE`] or calls `loader` if not cached. + /// The loaded image is converted to a [`ColorImage`], cached, and returned. + #[inline] + pub fn try_load(id: impl Into, loader: F) -> Option + where + F: FnOnce() -> Option, + I: Into, + { + let id = id.into(); + let image = ICONS_CACHE.get_image(&id).or_else(|| { + let img = loader()?; + let img = Arc::new(rgba_to_color_image(&img.into())); + ICONS_CACHE.insert_image(id.clone(), img.clone()); + Some(img) + })?; + + Some(ImageIcon::new(id, image)) + } + + /// Returns a texture handle for the icon, using the given [`Context`]. + /// + /// If the texture is already cached in [`ICONS_CACHE`], it is reused. + /// Otherwise, a new texture is created from the [`ColorImage`] and cached. + #[inline] + pub fn texture(&self, ctx: &Context) -> TextureHandle { + ICONS_CACHE.texture(ctx, &self.id, &self.image) + } +} + +/// Unique identifier for an image-based icon. +/// +/// Used to distinguish cached images and textures by either a file path +/// or a Windows window handle. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum ImageIconId { + /// Identifier based on a file system path. + Path(Arc), + /// Windows HWND handle. + Hwnd(isize), + /// System tray icon identifier. + SystrayIcon(String), + /// macOS window ID. + WindowId(u32), +} + +impl From<&Path> for ImageIconId { + #[inline] + fn from(value: &Path) -> Self { + Self::Path(value.into()) + } +} + +impl From for ImageIconId { + #[inline] + fn from(value: isize) -> Self { + Self::Hwnd(value) + } +} + +impl From for ImageIconId { + #[inline] + fn from(value: u32) -> Self { + Self::WindowId(value) + } +} + +impl Default for IconsCache { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/komorebi-bar-core/src/widgets/komorebi_config.rs b/crates/komorebi-bar-core/src/widgets/komorebi_config.rs new file mode 100644 index 00000000..ab75ca74 --- /dev/null +++ b/crates/komorebi-bar-core/src/widgets/komorebi_config.rs @@ -0,0 +1,158 @@ +//! The configuration of the komorebi widgets (workspaces, layout, focused container, ...) and +//! the layout kind the layout widget shows: data only, shared by both bars. + +use crate::config::DisplayFormat; +use crate::config::WorkspacesDisplayFormat; +use color_eyre::eyre; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde::de::Error; +use serde_json::from_str; +use std::collections::BTreeMap; +use std::fmt::Display; +use std::fmt::Formatter; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Komorebi widget configuration +pub struct KomorebiConfig { + /// Configure the Workspaces widget + pub workspaces: Option, + /// Configure the Layout widget + pub layout: Option, + /// Configure the Workspace Layer widget + pub workspace_layer: Option, + /// Configure the Focused Container widget + #[serde(alias = "focused_window")] + pub focused_container: Option, + /// Configure the Locked Container widget + pub locked_container: Option, + /// Configure the Configuration Switcher widget + pub configuration_switcher: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Komorebi widget workspaces configuration +pub struct KomorebiWorkspacesConfig { + /// Enable the Komorebi Workspaces widget + pub enable: bool, + /// Hide workspaces without any windows + pub hide_empty_workspaces: bool, + /// Display format of the workspace + pub display: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Komorebi widget layout configuration +pub struct KomorebiLayoutConfig { + /// Enable the Komorebi Layout widget + pub enable: bool, + /// List of layout options + pub options: Option>, + /// Display format of the current layout + pub display: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Komorebi widget workspace layer configuration +pub struct KomorebiWorkspaceLayerConfig { + /// Enable the Komorebi Workspace Layer widget + pub enable: bool, + /// Display format of the current layer + pub display: Option, + /// Show the widget event if the layer is Tiling + pub show_when_tiling: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Komorebi widget focused container configuration +pub struct KomorebiFocusedContainerConfig { + /// Enable the Komorebi Focused Container widget + pub enable: bool, + /// DEPRECATED: use `display` instead (Show the icon of the currently focused container) + #[deprecated(note = "Use `display` instead")] + pub show_icon: Option, + /// Display format of the currently focused container + pub display: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Komorebi widget locked container configuration +pub struct KomorebiLockedContainerConfig { + /// Enable the Komorebi Locked Container widget + pub enable: bool, + /// Display format of the current locked state + pub display: Option, + /// Show the widget event if the layer is unlocked + pub show_when_unlocked: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Komorebi widget configuration switcher configuration +pub struct KomorebiConfigurationSwitcherConfig { + /// Enable the Komorebi Configurations widget + pub enable: bool, + /// A map of display friendly name => path to configuration.json + pub configurations: BTreeMap, +} + +#[derive(Copy, Clone, Debug, Serialize, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +/// Komorebi layout kind +pub enum KomorebiLayout { + /// Predefined layout + #[cfg_attr(feature = "schemars", schemars(title = "Default"))] + Default(komorebi_layouts::DefaultLayout), + /// Monocle mode + Monocle, + /// Floating layer + Floating, + /// Paused + Paused, + /// Custom layout + Custom, +} + +impl<'de> Deserialize<'de> for KomorebiLayout { + fn deserialize(deserializer: D) -> eyre::Result + where + D: Deserializer<'de>, + { + let s: String = String::deserialize(deserializer)?; + + // Attempt to deserialize the string as a DefaultLayout + if let Ok(default_layout) = from_str::(&format!("\"{s}\"")) + { + return Ok(KomorebiLayout::Default(default_layout)); + } + + // Handle other cases + match s.as_str() { + "Monocle" => Ok(KomorebiLayout::Monocle), + "Floating" => Ok(KomorebiLayout::Floating), + "Paused" => Ok(KomorebiLayout::Paused), + "Custom" => Ok(KomorebiLayout::Custom), + _ => Err(Error::custom(format!("Invalid layout: {s}"))), + } + } +} + +impl Display for KomorebiLayout { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + KomorebiLayout::Default(layout) => write!(f, "{layout}"), + KomorebiLayout::Monocle => write!(f, "Monocle"), + KomorebiLayout::Floating => write!(f, "Floating"), + KomorebiLayout::Paused => write!(f, "Paused"), + KomorebiLayout::Custom => write!(f, "Custom"), + } + } +} diff --git a/komorebi-bar/src/widgets/komorebi_layout.rs b/crates/komorebi-bar-core/src/widgets/komorebi_layout.rs similarity index 74% rename from komorebi-bar/src/widgets/komorebi_layout.rs rename to crates/komorebi-bar-core/src/widgets/komorebi_layout.rs index 5c35246c..8ac4218f 100644 --- a/komorebi-bar/src/widgets/komorebi_layout.rs +++ b/crates/komorebi-bar-core/src/widgets/komorebi_layout.rs @@ -1,8 +1,9 @@ use crate::config::DisplayFormat; use crate::render::RenderConfig; use crate::selected_frame::SelectableFrame; -use crate::widgets::komorebi::KomorebiLayoutConfig; -use color_eyre::eyre; +use crate::platform::platform; +use crate::widgets::komorebi_config::KomorebiLayout; +use crate::widgets::komorebi_config::KomorebiLayoutConfig; use eframe::egui::Context; use eframe::egui::CornerRadius; use eframe::egui::FontId; @@ -14,70 +15,30 @@ use eframe::egui::StrokeKind; use eframe::egui::Ui; use eframe::egui::Vec2; use eframe::egui::vec2; -use komorebi_client::SocketMessage; -use serde::Deserialize; -use serde::Deserializer; -use serde::Serialize; -use serde::de::Error; -use serde_json::from_str; -use std::fmt::Display; -use std::fmt::Formatter; +use komorebi_protocol::message::SocketMessage; -#[derive(Copy, Clone, Debug, Serialize, PartialEq)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(untagged)] -/// Komorebi layout kind -pub enum KomorebiLayout { - /// Predefined layout - #[cfg_attr(feature = "schemars", schemars(title = "Default"))] - Default(komorebi_client::DefaultLayout), - /// Monocle mode - Monocle, - /// Floating layer - Floating, - /// Paused - Paused, - /// Custom layout - Custom, +/// What the layout widget does with a layout kind (the kind itself lives in the core). +pub trait KomorebiLayoutExt { + fn is_default(&mut self) -> bool; + fn on_click( + &mut self, + show_options: &bool, + monitor_idx: usize, + workspace_idx: Option, + ) -> bool; + fn on_click_option(&mut self, monitor_idx: usize, workspace_idx: Option); + fn show_icon(&mut self, is_selected: bool, font_id: FontId, ctx: &Context, ui: &mut Ui); + fn show( + &mut self, + ctx: &Context, + ui: &mut Ui, + render_config: &mut RenderConfig, + layout_config: &KomorebiLayoutConfig, + workspace_idx: Option, + ); } -impl<'de> Deserialize<'de> for KomorebiLayout { - fn deserialize(deserializer: D) -> eyre::Result - where - D: Deserializer<'de>, - { - let s: String = String::deserialize(deserializer)?; - - // Attempt to deserialize the string as a DefaultLayout - if let Ok(default_layout) = from_str::(&format!("\"{s}\"")) - { - return Ok(KomorebiLayout::Default(default_layout)); - } - - // Handle other cases - match s.as_str() { - "Monocle" => Ok(KomorebiLayout::Monocle), - "Floating" => Ok(KomorebiLayout::Floating), - "Paused" => Ok(KomorebiLayout::Paused), - "Custom" => Ok(KomorebiLayout::Custom), - _ => Err(Error::custom(format!("Invalid layout: {s}"))), - } - } -} - -impl Display for KomorebiLayout { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - KomorebiLayout::Default(layout) => write!(f, "{layout}"), - KomorebiLayout::Monocle => write!(f, "Monocle"), - KomorebiLayout::Floating => write!(f, "Floating"), - KomorebiLayout::Paused => write!(f, "Paused"), - KomorebiLayout::Custom => write!(f, "Custom"), - } - } -} - -impl KomorebiLayout { +impl KomorebiLayoutExt for KomorebiLayout { fn is_default(&mut self) -> bool { matches!(self, KomorebiLayout::Default(_)) } @@ -100,7 +61,7 @@ impl KomorebiLayout { match self { KomorebiLayout::Default(option) => { if let Some(ws_idx) = workspace_idx - && komorebi_client::send_message(&SocketMessage::WorkspaceLayout( + && platform().send_komorebi_message(&SocketMessage::WorkspaceLayout( monitor_idx, ws_idx, *option, @@ -111,7 +72,7 @@ impl KomorebiLayout { } } KomorebiLayout::Monocle => { - if komorebi_client::send_batch([ + if platform().send_komorebi_batch(vec![ SocketMessage::FocusMonitorAtCursor, SocketMessage::ToggleMonocle, ]) @@ -121,7 +82,7 @@ impl KomorebiLayout { } } KomorebiLayout::Floating => { - if komorebi_client::send_batch([ + if platform().send_komorebi_batch(vec![ SocketMessage::FocusMonitorAtCursor, SocketMessage::ToggleTiling, ]) @@ -131,7 +92,7 @@ impl KomorebiLayout { } } KomorebiLayout::Paused => { - if komorebi_client::send_message(&SocketMessage::TogglePause).is_err() { + if platform().send_komorebi_message(&SocketMessage::TogglePause).is_err() { tracing::error!("could not send message to komorebi: TogglePause"); } } @@ -158,44 +119,44 @@ impl KomorebiLayout { match self { KomorebiLayout::Default(layout) => match layout { - komorebi_client::DefaultLayout::BSP => { + komorebi_layouts::DefaultLayout::BSP => { painter.line_segment([c - vec2(0.0, r), c + vec2(0.0, r)], stroke); painter.line_segment([c, c + vec2(r, 0.0)], stroke); painter.line_segment([c + vec2(r / 2.0, 0.0), c + vec2(r / 2.0, r)], stroke); } - komorebi_client::DefaultLayout::Columns => { + komorebi_layouts::DefaultLayout::Columns => { painter.line_segment([c - vec2(r / 2.0, r), c + vec2(-r / 2.0, r)], stroke); painter.line_segment([c - vec2(0.0, r), c + vec2(0.0, r)], stroke); painter.line_segment([c - vec2(-r / 2.0, r), c + vec2(r / 2.0, r)], stroke); } - komorebi_client::DefaultLayout::Rows => { + komorebi_layouts::DefaultLayout::Rows => { painter.line_segment([c - vec2(r, r / 2.0), c + vec2(r, -r / 2.0)], stroke); painter.line_segment([c - vec2(r, 0.0), c + vec2(r, 0.0)], stroke); painter.line_segment([c - vec2(r, -r / 2.0), c + vec2(r, r / 2.0)], stroke); } - komorebi_client::DefaultLayout::VerticalStack => { + komorebi_layouts::DefaultLayout::VerticalStack => { painter.line_segment([c - vec2(0.0, r), c + vec2(0.0, r)], stroke); painter.line_segment([c, c + vec2(r, 0.0)], stroke); } - komorebi_client::DefaultLayout::RightMainVerticalStack => { + komorebi_layouts::DefaultLayout::RightMainVerticalStack => { painter.line_segment([c - vec2(0.0, r), c + vec2(0.0, r)], stroke); painter.line_segment([c - vec2(r, 0.0), c], stroke); } - komorebi_client::DefaultLayout::HorizontalStack => { + komorebi_layouts::DefaultLayout::HorizontalStack => { painter.line_segment([c - vec2(r, 0.0), c + vec2(r, 0.0)], stroke); painter.line_segment([c, c + vec2(0.0, r)], stroke); } - komorebi_client::DefaultLayout::UltrawideVerticalStack => { + komorebi_layouts::DefaultLayout::UltrawideVerticalStack => { painter.line_segment([c - vec2(r / 2.0, r), c + vec2(-r / 2.0, r)], stroke); painter.line_segment([c + vec2(r / 2.0, 0.0), c + vec2(r, 0.0)], stroke); painter.line_segment([c - vec2(-r / 2.0, r), c + vec2(r / 2.0, r)], stroke); } - komorebi_client::DefaultLayout::Grid => { + komorebi_layouts::DefaultLayout::Grid => { painter.line_segment([c - vec2(r, 0.0), c + vec2(r, 0.0)], stroke); painter.line_segment([c - vec2(0.0, r), c + vec2(0.0, r)], stroke); } // TODO: @CtByte can you think of a nice icon to draw here? - komorebi_client::DefaultLayout::Scrolling => { + komorebi_layouts::DefaultLayout::Scrolling => { painter.line_segment([c - vec2(r / 2.0, r), c + vec2(-r / 2.0, r)], stroke); painter.line_segment([c - vec2(0.0, r), c + vec2(0.0, r)], stroke); painter.line_segment([c - vec2(-r / 2.0, r), c + vec2(r / 2.0, r)], stroke); @@ -242,7 +203,7 @@ impl KomorebiLayout { } } - pub fn show( + fn show( &mut self, ctx: &Context, ui: &mut Ui, @@ -284,18 +245,18 @@ impl KomorebiLayout { ); let mut layout_options = layout_config.options.clone().unwrap_or(vec![ - KomorebiLayout::Default(komorebi_client::DefaultLayout::BSP), - KomorebiLayout::Default(komorebi_client::DefaultLayout::Columns), - KomorebiLayout::Default(komorebi_client::DefaultLayout::Rows), - KomorebiLayout::Default(komorebi_client::DefaultLayout::VerticalStack), + KomorebiLayout::Default(komorebi_layouts::DefaultLayout::BSP), + KomorebiLayout::Default(komorebi_layouts::DefaultLayout::Columns), + KomorebiLayout::Default(komorebi_layouts::DefaultLayout::Rows), + KomorebiLayout::Default(komorebi_layouts::DefaultLayout::VerticalStack), KomorebiLayout::Default( - komorebi_client::DefaultLayout::RightMainVerticalStack, + komorebi_layouts::DefaultLayout::RightMainVerticalStack, ), - KomorebiLayout::Default(komorebi_client::DefaultLayout::HorizontalStack), + KomorebiLayout::Default(komorebi_layouts::DefaultLayout::HorizontalStack), KomorebiLayout::Default( - komorebi_client::DefaultLayout::UltrawideVerticalStack, + komorebi_layouts::DefaultLayout::UltrawideVerticalStack, ), - KomorebiLayout::Default(komorebi_client::DefaultLayout::Grid), + KomorebiLayout::Default(komorebi_layouts::DefaultLayout::Grid), //KomorebiLayout::Custom, KomorebiLayout::Monocle, KomorebiLayout::Floating, diff --git a/crates/komorebi-bar-core/src/widgets/mod.rs b/crates/komorebi-bar-core/src/widgets/mod.rs index 42d16a57..a07b36fe 100644 --- a/crates/komorebi-bar-core/src/widgets/mod.rs +++ b/crates/komorebi-bar-core/src/widgets/mod.rs @@ -1,6 +1,14 @@ +pub mod applications; pub mod battery; pub mod cpu; pub mod date; +pub mod icons; +pub mod komorebi_config; +pub mod komorebi_layout; pub mod memory; +pub mod network_config; +pub mod platform_config; +pub mod storage; +pub mod time; pub mod update; pub mod widget; diff --git a/crates/komorebi-bar-core/src/widgets/network_config.rs b/crates/komorebi-bar-core/src/widgets/network_config.rs new file mode 100644 index 00000000..d958b453 --- /dev/null +++ b/crates/komorebi-bar-core/src/widgets/network_config.rs @@ -0,0 +1,66 @@ +//! The configuration of the network widget: data only, shared by both bars. The Windows bar +//! reads `default_interface_options`; the macOS bar accepts the key and ignores it. + +use crate::config::LabelPrefix; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Network widget configuration +pub struct NetworkConfig { + /// Enable the Network widget + pub enable: bool, + /// Show total received and transmitted activity + #[serde(alias = "show_total_data_transmitted")] + pub show_total_activity: bool, + /// Show received and transmitted activity + #[serde(alias = "show_network_activity")] + pub show_activity: bool, + /// Show default interface + pub show_default_interface: Option, + /// Default interface options: whether to show the friendly name, IPv4 or IPv6 address + pub default_interface_options: Option, + /// Characters to reserve for received and transmitted activity + #[serde(alias = "network_activity_fill_characters")] + pub activity_left_padding: Option, + /// Data refresh interval in seconds + #[cfg_attr(feature = "schemars", schemars(extend("default" = 10)))] + pub data_refresh_interval: Option, + /// Display label prefix + pub label_prefix: Option, + /// Select when the value is over a limit (1MiB is 1048576 bytes (1024*1024)) + pub auto_select: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Network select configuration +pub struct NetworkSelectConfig { + /// Select the total received data when it's over this value + pub total_received_over: Option, + /// Select the total transmitted data when it's over this value + pub total_transmitted_over: Option, + /// Select the received data when it's over this value + pub received_over: Option, + /// Select the transmitted data when it's over this value + pub transmitted_over: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Which IP address to show for the default interface +pub enum InterfaceDisplayType { + #[default] + FriendlyName, + Ipv4, + Ipv6, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Default interface configuration +pub struct DefaultInterfaceConfig { + #[serde(default)] + pub display_type: InterfaceDisplayType, +} diff --git a/crates/komorebi-bar-core/src/widgets/platform_config.rs b/crates/komorebi-bar-core/src/widgets/platform_config.rs new file mode 100644 index 00000000..746fc7db --- /dev/null +++ b/crates/komorebi-bar-core/src/widgets/platform_config.rs @@ -0,0 +1,131 @@ +//! The configuration types of the widgets only the Windows bar runs (keyboard, media, system +//! tray). They are data without any platform code, so that `WidgetConfig` can name every widget +//! and the schema is one union that each platform's view prunes (see `schema`). + +use crate::config::LabelPrefix; +use crate::config::MediaDisplayFormat; +use komorebi_config::matching::MatchingStrategy; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Keyboard widget configuration +pub struct KeyboardConfig { + /// Enable the Input widget + pub enable: bool, + /// Data refresh interval + #[cfg_attr(feature = "schemars", schemars(extend("default" = 10)))] + pub data_refresh_interval: Option, + /// Display label prefix + pub label_prefix: Option, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Media widget configuration +pub struct MediaConfig { + /// Enable the Media widget + pub enable: bool, + /// Display format of the media widget (defaults to IconAndText) + pub display: Option, +} + +#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Position of the overflow toggle button +pub enum OverflowTogglePosition { + /// Toggle button appears on the left side (before visible icons) + Left, + /// Toggle button appears on the right side (after visible icons) + #[default] + Right, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Where to place a systray button (refresh, info, shortcuts, etc.) +pub enum ButtonPosition { + /// Show in the main visible area + Visible, + /// Show in the overflow/hidden section + Overflow, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +/// A field value with an optional matching strategy. +/// +/// A plain string uses exact (case-insensitive) matching. +/// An object with `value` and `matching_strategy` uses the specified strategy. +pub enum FieldMatch { + /// Exact case-insensitive match + Exact(String), + /// Match using a specific strategy + WithStrategy { + /// The value to match against + value: String, + /// How to match (Equals, StartsWith, EndsWith, Contains, Regex, etc.) + matching_strategy: MatchingStrategy, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +/// Rule for matching a systray icon to hide +/// +/// A plain string matches the exe name (backward compatible). +/// An object with optional `exe`, `tooltip`, and/or `guid` fields +/// uses AND logic: all specified fields must match. +/// Each field can be a plain string (exact match) or an object with +/// `value` and `matching_strategy` for advanced matching. +pub enum HiddenIconRule { + /// Match by exe name (case-insensitive, exact) + Exe(String), + /// Match by one or more properties (all specified fields must match) + Match { + /// Exe name to match + exe: Option, + /// Tooltip text to match + tooltip: Option, + /// Icon GUID to match (most stable identifier across restarts) + guid: Option, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// System tray widget configuration +pub struct SystrayConfig { + /// Enable the System Tray widget + pub enable: bool, + /// A list of rules for icons to hide from the system tray + /// + /// Each entry can be a plain string (matches exe name, case-insensitive) + /// or an object with optional `exe`, `tooltip`, and/or `guid` fields + /// (all specified fields must match, AND logic, case-insensitive). + /// + /// Run komorebi-bar with RUST_LOG=info to see the properties of all + /// systray icons in the log output. + pub hidden_icons: Option>, + /// Position of the overflow toggle button (default: Right) + pub overflow_toggle_position: Option, + /// Show an info button that opens a floating panel listing all systray icons + /// with their exe, tooltip, and GUID. Set to "Visible" to show it in the main + /// area, or "Overflow" to show it in the hidden/overflow section. + pub info_button: Option, + /// Interval in seconds to automatically check for and remove stale icons + /// whose owning process has exited. Clamped between 30 and 600 seconds. + /// Defaults to 60. Set to 0 to disable. + pub stale_icons_check_interval: Option, + /// Show a refresh button that manually triggers stale icon cleanup. + /// Set to "Visible" to show it in the main area, or "Overflow" to + /// show it in the hidden/overflow section. + pub refresh_button: Option, + /// Show a button that toggles komorebi-shortcuts (kills the process if + /// running, starts it otherwise). Set to "Visible" to show it in the main + /// area, or "Overflow" to show it in the hidden/overflow section. + pub shortcuts_button: Option, +} diff --git a/komorebi-bar/src/widgets/storage.rs b/crates/komorebi-bar-core/src/widgets/storage.rs similarity index 94% rename from komorebi-bar/src/widgets/storage.rs rename to crates/komorebi-bar-core/src/widgets/storage.rs index 12d3c440..70bfe5ef 100644 --- a/komorebi-bar/src/widgets/storage.rs +++ b/crates/komorebi-bar-core/src/widgets/storage.rs @@ -1,4 +1,4 @@ -use crate::bar::Alignment; +use crate::alignment::Alignment; use crate::config::LabelPrefix; use crate::render::RenderConfig; use crate::selected_frame::SelectableFrame; @@ -11,7 +11,8 @@ use eframe::egui::Ui; use eframe::egui::text::LayoutJob; use serde::Deserialize; use serde::Serialize; -use std::process::Command; +use crate::platform::SystemTool; +use crate::platform::platform; use std::time::Duration; use std::time::Instant; use sysinfo::Disks; @@ -190,13 +191,8 @@ impl BarWidget for Storage { if SelectableFrame::new_auto(output.selected, auto_focus_fill) .show(ui, |ui| ui.add(Label::new(layout_job).selectable(false))) .clicked() - && let Err(error) = Command::new("cmd.exe") - .args([ - "/C", - "explorer.exe", - output.label.split(' ').collect::>()[0], - ]) - .spawn() + && let Err(error) = platform() + .open_system_tool(SystemTool::StorageLocation(output.label.clone())) { eprintln!("{error}") } diff --git a/komorebi-bar/src/widgets/time.rs b/crates/komorebi-bar-core/src/widgets/time.rs similarity index 99% rename from komorebi-bar/src/widgets/time.rs rename to crates/komorebi-bar-core/src/widgets/time.rs index 0dd75299..044de33c 100644 --- a/komorebi-bar/src/widgets/time.rs +++ b/crates/komorebi-bar-core/src/widgets/time.rs @@ -1,4 +1,4 @@ -use crate::bar::Alignment; +use crate::alignment::Alignment; use crate::config::LabelPrefix; use crate::render::RenderConfig; use crate::selected_frame::SelectableFrame; diff --git a/crates/komorebi-bar-core/src/widgets/widget.rs b/crates/komorebi-bar-core/src/widgets/widget.rs index f992a972..5e2ef7d9 100644 --- a/crates/komorebi-bar-core/src/widgets/widget.rs +++ b/crates/komorebi-bar-core/src/widgets/widget.rs @@ -1,7 +1,134 @@ use crate::render::RenderConfig; +use crate::widgets::applications::ApplicationsConfig; +use crate::widgets::battery::BatteryConfig; +use crate::widgets::cpu::CpuConfig; +use crate::widgets::date::DateConfig; +use crate::widgets::komorebi_config::KomorebiConfig; +use crate::widgets::memory::MemoryConfig; +use crate::widgets::network_config::NetworkConfig; +use crate::widgets::platform_config::KeyboardConfig; +use crate::widgets::platform_config::MediaConfig; +use crate::widgets::platform_config::SystrayConfig; +use crate::widgets::storage::StorageConfig; +use crate::widgets::time::TimeConfig; +use crate::widgets::update::UpdateConfig; use eframe::egui::Context; use eframe::egui::Ui; +use serde::Deserialize; +use serde::Serialize; pub trait BarWidget { fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig); } + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Widget configuration +pub enum WidgetConfig { + /// Applications widget configuration + #[cfg_attr(feature = "schemars", schemars(title = "Applications"))] + Applications(ApplicationsConfig), + /// Battery widget configuration + #[cfg_attr(feature = "schemars", schemars(title = "Battery"))] + Battery(BatteryConfig), + /// CPU widget configuration + #[cfg_attr(feature = "schemars", schemars(title = "Cpu"))] + Cpu(CpuConfig), + /// Date widget configuration + #[cfg_attr(feature = "schemars", schemars(title = "Date"))] + Date(DateConfig), + /// Keyboard widget configuration + #[cfg_attr(feature = "schemars", schemars(title = "Keyboard"))] + Keyboard(KeyboardConfig), + /// Komorebi widget configuration + #[cfg_attr(feature = "schemars", schemars(title = "Komorebi"))] + Komorebi(KomorebiConfig), + /// Media widget configuration + #[cfg_attr(feature = "schemars", schemars(title = "Media"))] + Media(MediaConfig), + /// Memory widget configuration + #[cfg_attr(feature = "schemars", schemars(title = "Memory"))] + Memory(MemoryConfig), + /// Network widget configuration + #[cfg_attr(feature = "schemars", schemars(title = "Network"))] + Network(NetworkConfig), + /// Storage widget configuration + #[cfg_attr(feature = "schemars", schemars(title = "Storage"))] + Storage(StorageConfig), + /// System Tray widget configuration (Windows only) + #[cfg_attr(feature = "schemars", schemars(title = "Systray"))] + Systray(SystrayConfig), + /// Time widget configuration + #[cfg_attr(feature = "schemars", schemars(title = "Time"))] + Time(TimeConfig), + /// Update widget configuration + #[cfg_attr(feature = "schemars", schemars(title = "Update"))] + Update(UpdateConfig), +} + +impl WidgetConfig { + /// The widget a configuration key names, if the platform this bar runs on has it. The + /// keyboard, media and system tray widgets exist on Windows only: elsewhere the key is + /// accepted and skipped (see [`WidgetConfig::unsupported`]). + #[must_use] + pub const fn supported(&self) -> bool { + match self { + WidgetConfig::Keyboard(_) | WidgetConfig::Media(_) | WidgetConfig::Systray(_) => { + cfg!(target_os = "windows") + } + _ => true, + } + } + + /// The name of this widget when the platform cannot run it. + #[must_use] + pub const fn unsupported(&self) -> Option<&'static str> { + if self.supported() { + return None; + } + + match self { + WidgetConfig::Keyboard(_) => Some("Keyboard"), + WidgetConfig::Media(_) => Some("Media"), + WidgetConfig::Systray(_) => Some("Systray"), + _ => None, + } + } + + /// Whether the widget is enabled and can run on this platform. + #[must_use] + pub fn enabled(&self) -> bool { + self.supported() + && match self { + WidgetConfig::Applications(config) => config.enable, + WidgetConfig::Battery(config) => config.enable, + WidgetConfig::Cpu(config) => config.enable, + WidgetConfig::Date(config) => config.enable, + WidgetConfig::Keyboard(config) => config.enable, + WidgetConfig::Komorebi(config) => { + config.workspaces.as_ref().is_some_and(|w| w.enable) + || config.layout.as_ref().is_some_and(|w| w.enable) + || config.focused_container.as_ref().is_some_and(|w| w.enable) + || config + .configuration_switcher + .as_ref() + .is_some_and(|w| w.enable) + } + WidgetConfig::Media(config) => config.enable, + WidgetConfig::Memory(config) => config.enable, + WidgetConfig::Network(config) => config.enable, + WidgetConfig::Storage(config) => config.enable, + WidgetConfig::Systray(config) => config.enable, + WidgetConfig::Time(config) => config.enable, + WidgetConfig::Update(config) => config.enable, + } + } +} + +/// Logs a warning for every widget in `widgets` that this platform cannot run; they are accepted +/// in the configuration and skipped. +pub fn warn_unsupported<'a>(widgets: impl IntoIterator) { + for name in widgets.into_iter().filter_map(WidgetConfig::unsupported) { + tracing::warn!("the {name} widget is not supported on this platform and is skipped"); + } +} diff --git a/crates/komorebi-bar-core/tests/schema_oracle.rs b/crates/komorebi-bar-core/tests/schema_oracle.rs index 510057e8..59054bea 100644 --- a/crates/komorebi-bar-core/tests/schema_oracle.rs +++ b/crates/komorebi-bar-core/tests/schema_oracle.rs @@ -76,3 +76,73 @@ fn the_checked_in_bar_schemas_are_json_schemas_of_komobar_config() { assert!(text.ends_with("}\n"), "{}", pin.file); } } + +mod views { + use komorebi_bar_core::schema::SchemaPlatform; + use komorebi_bar_core::schema::view_text; + + fn text(file: &str) -> String { + String::from_utf8(super::read(file)).expect("utf-8") + } + + /// The Windows file is the union; its Windows view is itself. + #[test] + fn the_windows_view_of_the_union_is_the_windows_file() { + let union = serde_json::from_str(&text("schema.bar.json")).expect("json"); + assert!(view_text(union, SchemaPlatform::Windows) == text("schema.bar.json")); + } + + /// The macOS view of the union is the macOS file, byte for byte. + #[test] + fn the_macos_view_of_the_union_is_the_macos_file() { + let union = serde_json::from_str(&text("schema.bar.json")).expect("json"); + let view = view_text(union, SchemaPlatform::MacOs); + let file = text("komorebi-for-mac/schema.bar.json"); + if view != file { + let first = view.lines().zip(file.lines()).position(|(a, b)| a != b); + panic!( + "the views differ, first at line {first:?} (view {} lines, file {})", + view.lines().count(), + file.lines().count() + ); + } + } + + /// A view applied to its own output changes nothing, so a bar can print its generated + /// schema through it. + #[test] + fn a_view_is_idempotent() { + let union = serde_json::from_str(&text("schema.bar.json")).expect("json"); + let once = view_text(union, SchemaPlatform::MacOs); + let again = view_text( + serde_json::from_str(&once).expect("json"), + SchemaPlatform::MacOs, + ); + assert!(once == again); + } +} + +mod generated { + use komorebi_bar_core::config::KomobarConfig; + use komorebi_bar_core::schema::SchemaPlatform; + use komorebi_bar_core::schema::generated_text; + + /// The schema the core generates from its own types, through the macOS view, is the macOS + /// file. (Not tested for Windows: three descriptions in komorebi-protocol and the themes + /// crate read differently when compiled for Windows, which a Linux test host cannot do; the + /// out-of-tree harness covers it.) + #[test] + fn the_generated_macos_view_is_the_macos_file() { + let file = + String::from_utf8(super::read("komorebi-for-mac/schema.bar.json")).expect("utf-8"); + let text = generated_text::(SchemaPlatform::MacOs); + if text != file { + let first = text.lines().zip(file.lines()).position(|(a, b)| a != b); + panic!( + "differs, first at line {first:?} ({} lines, file {})", + text.lines().count(), + file.lines().count() + ); + } + } +} diff --git a/crates/komorebi-settings/Cargo.toml b/crates/komorebi-settings/Cargo.toml new file mode 100644 index 00000000..b807819d --- /dev/null +++ b/crates/komorebi-settings/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "komorebi-settings" +version = "0.1.42" +edition = "2024" +description = "The settings every komorebi daemon reads while it runs: one immutable snapshot, replaced as a whole, instead of process-wide statics" + +[dependencies] +komorebi-config = { path = "../komorebi-config", default-features = false } +komorebi-layouts = { path = "../../komorebi-layouts" } +komorebi-protocol = { path = "../komorebi-protocol", default-features = false } +komorebi-themes = { path = "../../komorebi-themes", default-features = false } + +parking_lot = "0.12" diff --git a/crates/komorebi-settings/src/handle.rs b/crates/komorebi-settings/src/handle.rs new file mode 100644 index 00000000..b09f2163 --- /dev/null +++ b/crates/komorebi-settings/src/handle.rs @@ -0,0 +1,263 @@ +use std::sync::Arc; + +use parking_lot::Mutex; +use parking_lot::RwLock; + +use crate::Settings; + +/// The current [`Settings`] of a daemon. +/// +/// Reading costs one uncontended read lock and an `Arc` clone; the lock is held for the clone +/// only, never while the caller uses the snapshot. The representation (an `RwLock>`) is +/// private: it can become an `ArcSwap` later without touching a caller. +pub struct Handle

{ + current: RwLock>>, + // Serialises writers. Readers never take it. + writers: Mutex<()>, +} + +impl

Handle

{ + pub fn new(settings: Settings

) -> Self { + Self { + current: RwLock::new(Arc::new(settings)), + writers: Mutex::new(()), + } + } + + /// The settings as they are now. Take it once per unit of work (one border render, one + /// layout pass, one rule check) and use it for all of it. + pub fn snapshot(&self) -> Arc> { + Arc::clone(&self.current.read()) + } + + /// Publish a whole new value. Readers that already hold a snapshot keep the old one. + pub fn replace(&self, settings: Settings

) { + let _writer = self.writers.lock(); + *self.current.write() = Arc::new(settings); + } +} + +impl Handle

{ + /// Copy the current value, let `edit` change the copy and publish it once. + /// + /// Writers are serialised for the whole call, so an `update` never overwrites a change made by + /// another `update` that ran at the same time. If `edit` panics nothing is published. + pub fn update(&self, edit: impl FnOnce(&mut Settings

) -> R) -> R { + let _writer = self.writers.lock(); + let mut copy = (**self.current.read()).clone(); + let result = edit(&mut copy); + *self.current.write() = Arc::new(copy); + result + } +} + +impl Handle

{ + /// Set one field now and put its previous value back when the guard is dropped. + /// + /// The previous value is restored only if the field still holds the value this call set. If a + /// reload or a command changed the field meanwhile, that change wins. (The statics did the + /// opposite: the saved value overwrote whatever had been written in between.) + pub fn scoped(&self, field: fn(&mut Settings

) -> &mut T, value: T) -> Scoped<'_, P, T> + where + T: Clone + PartialEq, + { + let previous = self.update(|settings| std::mem::replace(field(settings), value.clone())); + + Scoped { + handle: self, + field, + set: value, + previous: Some(previous), + } + } +} + +/// The guard returned by [`Handle::scoped`]. +#[must_use = "the previous value is put back when the guard is dropped"] +pub struct Scoped<'a, P: Clone + 'static, T: Clone + PartialEq> { + handle: &'a Handle

, + field: fn(&mut Settings

) -> &mut T, + set: T, + previous: Option, +} + +impl Drop for Scoped<'_, P, T> { + fn drop(&mut self) { + if let Some(previous) = self.previous.take() { + let field = self.field; + let set = &self.set; + self.handle.update(|settings| { + let slot = field(settings); + if slot == set { + *slot = previous; + } + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shared::PlatformDefaults; + + #[derive(Clone, Debug, PartialEq, Default)] + struct Platform { + hiding: u8, + flag: bool, + } + + fn handle() -> Handle { + Handle::new(Settings::new( + crate::Shared::defaults_for(PlatformDefaults::KDE), + Platform::default(), + )) + } + + #[test] + fn a_snapshot_does_not_change_when_the_handle_does() { + let handle = handle(); + let before = handle.snapshot(); + + handle.update(|s| s.shared.border.width = 99); + + assert_eq!(before.shared.border.width, 10); + assert_eq!(handle.snapshot().shared.border.width, 99); + } + + #[test] + fn replace_publishes_the_whole_value() { + let handle = handle(); + let mut next = (*handle.snapshot()).clone(); + next.platform.hiding = 3; + next.shared.border.enabled = false; + + handle.replace(next); + + let now = handle.snapshot(); + assert_eq!(now.platform.hiding, 3); + assert!(!now.shared.border.enabled); + } + + #[test] + fn a_panicking_edit_publishes_nothing() { + let handle = handle(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + handle.update(|s| { + s.shared.border.width = 77; + panic!("edit failed"); + }); + })); + + assert!(result.is_err()); + assert_eq!(handle.snapshot().shared.border.width, 10); + // and the writer lock is free again + handle.update(|s| s.shared.border.width = 5); + assert_eq!(handle.snapshot().shared.border.width, 5); + } + + #[test] + fn concurrent_updates_lose_nothing() { + let handle = Arc::new(handle()); + let threads: Vec<_> = (0..8) + .map(|_| { + let handle = Arc::clone(&handle); + std::thread::spawn(move || { + for _ in 0..500 { + handle.update(|s| s.shared.border.width += 1); + } + }) + }) + .collect(); + for thread in threads { + thread.join().unwrap(); + } + + assert_eq!(handle.snapshot().shared.border.width, 10 + 8 * 500); + } + + #[test] + fn readers_never_see_half_an_update() { + // The writer keeps width and offset equal; a torn read would show them different. + let handle = Arc::new(handle()); + handle.update(|s| { + s.shared.border.width = 0; + s.shared.border.offset = 0; + }); + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let writer = { + let (handle, stop) = (Arc::clone(&handle), Arc::clone(&stop)); + std::thread::spawn(move || { + let mut n = 0; + while !stop.load(std::sync::atomic::Ordering::Relaxed) { + n += 1; + handle.update(|s| { + s.shared.border.width = n; + s.shared.border.offset = n; + }); + } + }) + }; + + for _ in 0..50_000 { + let seen = handle.snapshot(); + assert_eq!(seen.shared.border.width, seen.shared.border.offset); + } + stop.store(true, std::sync::atomic::Ordering::Relaxed); + writer.join().unwrap(); + } + + #[test] + fn scoped_puts_the_value_back() { + let handle = handle(); + + { + let _guard = handle.scoped(|s| &mut s.platform.hiding, 9); + assert_eq!(handle.snapshot().platform.hiding, 9); + } + + assert_eq!(handle.snapshot().platform.hiding, 0); + } + + #[test] + fn scoped_leaves_a_change_made_meanwhile() { + let handle = handle(); + + { + let _guard = handle.scoped(|s| &mut s.platform.hiding, 9); + // a reload writes the field while the override is held + handle.update(|s| s.platform.hiding = 4); + } + + assert_eq!(handle.snapshot().platform.hiding, 4); + } + + #[test] + fn scoped_touches_only_its_own_field() { + let handle = handle(); + + { + let _guard = handle.scoped(|s| &mut s.platform.flag, true); + handle.update(|s| s.shared.border.width = 31); + } + + let now = handle.snapshot(); + assert!(!now.platform.flag); + assert_eq!(now.shared.border.width, 31); + } + + #[test] + fn nested_scopes_unwind_in_order() { + let handle = handle(); + + let outer = handle.scoped(|s| &mut s.platform.hiding, 1); + let inner = handle.scoped(|s| &mut s.platform.hiding, 2); + assert_eq!(handle.snapshot().platform.hiding, 2); + drop(inner); + assert_eq!(handle.snapshot().platform.hiding, 1); + drop(outer); + assert_eq!(handle.snapshot().platform.hiding, 0); + } +} diff --git a/crates/komorebi-settings/src/lib.rs b/crates/komorebi-settings/src/lib.rs new file mode 100644 index 00000000..c7c0854e --- /dev/null +++ b/crates/komorebi-settings/src/lib.rs @@ -0,0 +1,41 @@ +//! The settings a daemon reads while it runs. +//! +//! Today each daemon keeps them in dozens of process-wide statics that `apply_globals` and the +//! `SocketMessage` handlers write one at a time while the border, animator and reaper threads +//! read them (see `docs/dev/settings.md`). This crate holds the replacement and nothing else: no +//! daemon uses it yet. +//! +//! * [`Settings`] is a value: a [`Shared`] part that is the same struct on every platform and a +//! platform part `P` that each daemon defines for its own subsystems. +//! * [`Handle`] holds the current value. A reader takes a [`Handle::snapshot`] once per unit of +//! work and sees one consistent configuration for all of it. A writer uses [`Handle::update`], +//! which serialises writers, so two writers never lose each other's change, and publishes the +//! result once. +//! * [`Handle::scoped`] replaces the "store a value, do work, store the old value back" pattern +//! without clobbering a change made in between. +#![warn(clippy::all)] +#![allow(clippy::missing_errors_doc, clippy::use_self, clippy::doc_markdown)] + +mod handle; +pub mod shared; + +pub use handle::Handle; +pub use handle::Scoped; +pub use shared::Shared; + +/// Every setting of one running daemon. +/// +/// `P` is the platform part: a plain type the daemon defines (the Windows stackbar and hiding +/// behaviour, the macOS tabbed-application list, ...). Shared code never looks inside it, so it is +/// a type parameter and not a trait; it needs to be `Clone` so that an update can copy the value. +#[derive(Clone, Debug, PartialEq)] +pub struct Settings

{ + pub shared: Shared, + pub platform: P, +} + +impl

Settings

{ + pub fn new(shared: Shared, platform: P) -> Self { + Self { shared, platform } + } +} diff --git a/crates/komorebi-settings/src/shared.rs b/crates/komorebi-settings/src/shared.rs new file mode 100644 index 00000000..aebbd069 --- /dev/null +++ b/crates/komorebi-settings/src/shared.rs @@ -0,0 +1,648 @@ +//! The part of the settings that is the same struct on every platform. +//! +//! Every field here has a counterpart static in each daemon (`docs/dev/settings.md`, section 2.1), +//! and [`SharedDelta::apply`] reproduces what `StaticConfig::apply_globals` does with it, including +//! which absent keys keep the old value and which are reset. + +use std::collections::HashMap; + +use komorebi_config::animation::PerAnimationPrefixConfig; +use komorebi_config::aspect_ratio::AspectRatio; +use komorebi_config::aspect_ratio::PredefinedAspectRatio; +use komorebi_layouts::DefaultLayout; +use komorebi_layouts::LayoutDefaultEntry; +use komorebi_protocol::animation::AnimationStyle; +use komorebi_protocol::animation::prefix::AnimationPrefix; +use komorebi_themes::colour::Colour; +use komorebi_themes::colour::Rgb; + +/// What `ANIMATION_FPS` becomes when `animation` is configured without `fps` +/// (`DEFAULT_ANIMATION_FPS` in each daemon). +pub const DEFAULT_ANIMATION_FPS: u64 = 60; +/// `DEFAULT_ANIMATION_ENABLED`, `DEFAULT_ANIMATION_STYLE` and `DEFAULT_ANIMATION_DURATION`. +pub const DEFAULT_ANIMATION_ENABLED: bool = false; +pub const DEFAULT_ANIMATION_STYLE: AnimationStyle = AnimationStyle::Linear; +pub const DEFAULT_ANIMATION_DURATION: u64 = 250; +/// `DEFAULT_RESIZE_DELTA` and `DEFAULT_MOUSE_FOLLOWS_FOCUS` (equal on all three platforms). +pub const DEFAULT_RESIZE_DELTA: i32 = 50; +pub const DEFAULT_MOUSE_FOLLOWS_FOCUS: bool = true; + +/// The defaults a daemon starts with. They differ per platform today (border width 8 / 10 / 10, +/// padding 10 / 5 / 5, ...) and this step does not change any of them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PlatformDefaults { + pub border_width: i32, + pub border_offset: i32, + pub border_radius: i32, + pub container_padding: i32, + pub workspace_padding: i32, +} + +impl PlatformDefaults { + /// `komorebi/src/border_manager/mod.rs` and `komorebi/src/lib.rs`. Windows has no border + /// radius setting; the unused value is the one the other platforms use. + pub const WINDOWS: Self = Self { + border_width: 8, + border_offset: -1, + border_radius: 10, + container_padding: 10, + workspace_padding: 10, + }; + /// `komorebi-for-kde/komorebi/src/border_manager/mod.rs` and `.../lib.rs` + pub const KDE: Self = Self { + border_width: 10, + border_offset: 0, + border_radius: 10, + container_padding: 5, + workspace_padding: 5, + }; + /// `komorebi-for-mac/komorebi/src/border_manager/mod.rs` and `.../lib.rs` + pub const MACOS: Self = Self { + border_width: 10, + border_offset: 0, + border_radius: 10, + container_padding: 5, + workspace_padding: 5, + }; +} + +/// Colours are `u32` exactly as the border statics hold them (`u32::from(Colour)`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BorderColours { + pub single: u32, + pub stack: u32, + pub monocle: u32, + pub floating: u32, + pub unfocused: u32, + pub unfocused_locked: u32, +} + +/// Whether and how the borders are drawn; the colours are the same on all three platforms. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Border { + pub enabled: bool, + pub width: i32, + pub offset: i32, + /// Used by KDE and macOS; Windows keeps it but never reads it. + pub radius: i32, + pub colours: BorderColours, +} + +/// The animation settings: a global value for each of enabled, style and duration, and a map of +/// per-prefix values that wins over the global one. +#[derive(Clone, Debug, PartialEq)] +pub struct Animation { + pub enabled: bool, + pub style: AnimationStyle, + pub duration: u64, + pub fps: u64, + pub enabled_per_animation: HashMap, + pub style_per_animation: HashMap, + pub duration_per_animation: HashMap, +} + +/// How a window is moved for one kind of animation: the three values `motion_for` reads today as +/// three separate statics. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Motion { + pub duration_ms: u64, + pub style: AnimationStyle, +} + +impl Animation { + /// `None` if `prefix` is not animated, else how. The same rule as `motion_for` (without the + /// suspension counter, which is runtime state and stays with the animation module), now read + /// from one snapshot. + pub fn motion_for(&self, prefix: AnimationPrefix) -> Option { + let enabled = self + .enabled_per_animation + .get(&prefix) + .copied() + .unwrap_or(self.enabled); + + enabled.then(|| Motion { + duration_ms: self + .duration_per_animation + .get(&prefix) + .copied() + .unwrap_or(self.duration), + style: self + .style_per_animation + .get(&prefix) + .copied() + .unwrap_or(self.style), + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Defaults { + pub container_padding: i32, + pub workspace_padding: i32, + pub resize_delta: i32, + pub mouse_follows_focus: bool, +} + +/// `transparency` and `transparency_alpha` (Windows and KDE; macOS never reads them). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Transparency { + pub enabled: bool, + pub alpha: u8, +} + +/// Every setting that is not tied to one platform. +#[derive(Clone, Debug, PartialEq)] +pub struct Shared { + pub border: Border, + pub animation: Animation, + pub defaults: Defaults, + pub transparency: Transparency, + pub floating_window_aspect_ratio: AspectRatio, + pub layout_defaults: HashMap, + pub display_index_preferences: HashMap, +} + +impl Shared { + /// The values the statics hold before any configuration is read. + pub fn defaults_for(defaults: PlatformDefaults) -> Self { + Self { + border: Border { + enabled: true, + width: defaults.border_width, + offset: defaults.border_offset, + radius: defaults.border_radius, + colours: BorderColours { + single: rgb(66, 165, 245), + stack: rgb(0, 165, 66), + monocle: rgb(255, 51, 153), + floating: rgb(245, 245, 165), + unfocused: rgb(128, 128, 128), + unfocused_locked: rgb(158, 8, 8), + }, + }, + animation: Animation { + enabled: DEFAULT_ANIMATION_ENABLED, + style: DEFAULT_ANIMATION_STYLE, + duration: DEFAULT_ANIMATION_DURATION, + fps: DEFAULT_ANIMATION_FPS, + enabled_per_animation: HashMap::new(), + style_per_animation: HashMap::new(), + duration_per_animation: HashMap::new(), + }, + defaults: Defaults { + container_padding: defaults.container_padding, + workspace_padding: defaults.workspace_padding, + resize_delta: DEFAULT_RESIZE_DELTA, + mouse_follows_focus: DEFAULT_MOUSE_FOLLOWS_FOCUS, + }, + transparency: Transparency { + enabled: false, + alpha: 200, + }, + // The static starts at Widescreen; a load without the key writes Standard + // (`unwrap_or`), see `SharedDelta::floating_window_aspect_ratio`. + floating_window_aspect_ratio: AspectRatio::Predefined( + PredefinedAspectRatio::Widescreen, + ), + layout_defaults: HashMap::new(), + display_index_preferences: HashMap::new(), + } + } +} + +fn rgb(r: u32, g: u32, b: u32) -> u32 { + u32::from(Colour::Rgb(Rgb::new(r, g, b))) +} + +/// The colours a configuration file or theme names; a missing one keeps the current value. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct BorderColoursDelta { + pub single: Option, + pub stack: Option, + pub monocle: Option, + pub floating: Option, + pub unfocused: Option, + pub unfocused_locked: Option, +} + +/// The animation block of a configuration file (`enabled` is required in the file, the rest is +/// optional). +#[derive(Clone, Debug, PartialEq)] +pub struct AnimationDelta { + pub enabled: PerAnimationPrefixConfig, + pub style: Option>, + pub duration: Option>, + pub fps: Option, +} + +/// What one configuration load says about the [`Shared`] settings. `None` means "the key is not in +/// the file", and [`apply`](Self::apply) treats each `None` exactly as +/// `StaticConfig::apply_globals` does. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SharedDelta { + pub border: Option, + pub border_width: Option, + pub border_offset: Option, + pub border_radius: Option, + pub border_colours: Option, + pub animation: Option, + pub default_container_padding: Option, + pub default_workspace_padding: Option, + pub transparency: Option, + pub transparency_alpha: Option, + pub floating_window_aspect_ratio: Option, + pub layout_defaults: Option>, + pub display_index_preferences: Option>, +} + +impl SharedDelta { + /// Merge this load into `shared`. + /// + /// * Keeps the old value when the key is absent: the border (enabled, width, offset, radius, + /// each colour), both paddings, transparency and its alpha, the display index preferences, + /// and the animation block as a whole. + /// * Resets when the key is absent: `floating_window_aspect_ratio` (to Standard, not to the + /// initial Widescreen), `layout_defaults` (cleared), and, inside a present animation block, + /// `fps` (to 60). + /// * A global animation value clears that setting's per-prefix map; a per-prefix map replaces + /// the whole map and leaves the global value alone. + pub fn apply(&self, shared: &mut Shared) { + shared.floating_window_aspect_ratio = self + .floating_window_aspect_ratio + .unwrap_or(AspectRatio::Predefined(PredefinedAspectRatio::Standard)); + + if let Some(preferences) = &self.display_index_preferences { + shared.display_index_preferences.clone_from(preferences); + } + + if let Some(animation) = &self.animation { + let target = &mut shared.animation; + + match &animation.enabled { + PerAnimationPrefixConfig::Prefix(enabled) => { + target.enabled_per_animation.clone_from(enabled); + } + PerAnimationPrefixConfig::Global(enabled) => { + target.enabled = *enabled; + target.enabled_per_animation.clear(); + } + } + + match &animation.style { + Some(PerAnimationPrefixConfig::Prefix(style)) => { + target.style_per_animation.clone_from(style); + } + Some(PerAnimationPrefixConfig::Global(style)) => { + target.style = *style; + target.style_per_animation.clear(); + } + None => {} + } + + match &animation.duration { + Some(PerAnimationPrefixConfig::Prefix(duration)) => { + target.duration_per_animation.clone_from(duration); + } + Some(PerAnimationPrefixConfig::Global(duration)) => { + target.duration = *duration; + target.duration_per_animation.clear(); + } + None => {} + } + + target.fps = animation.fps.unwrap_or(DEFAULT_ANIMATION_FPS); + } + + if let Some(container) = self.default_container_padding { + shared.defaults.container_padding = container; + } + + if let Some(workspace) = self.default_workspace_padding { + shared.defaults.workspace_padding = workspace; + } + + match &self.layout_defaults { + Some(defaults) => shared.layout_defaults.clone_from(defaults), + None => shared.layout_defaults.clear(), + } + + if let Some(width) = self.border_width { + shared.border.width = width; + } + + if let Some(offset) = self.border_offset { + shared.border.offset = offset; + } + + if let Some(radius) = self.border_radius { + shared.border.radius = radius; + } + + if let Some(enabled) = self.border { + shared.border.enabled = enabled; + } + + if let Some(colours) = &self.border_colours { + let target = &mut shared.border.colours; + if let Some(colour) = colours.single { + target.single = colour; + } + if let Some(colour) = colours.stack { + target.stack = colour; + } + if let Some(colour) = colours.monocle { + target.monocle = colour; + } + if let Some(colour) = colours.floating { + target.floating = colour; + } + if let Some(colour) = colours.unfocused { + target.unfocused = colour; + } + if let Some(colour) = colours.unfocused_locked { + target.unfocused_locked = colour; + } + } + + if let Some(enabled) = self.transparency { + shared.transparency.enabled = enabled; + } + + if let Some(alpha) = self.transparency_alpha { + shared.transparency.alpha = alpha; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn shared() -> Shared { + Shared::defaults_for(PlatformDefaults::KDE) + } + + #[test] + fn the_platform_defaults_are_the_ones_the_statics_start_with() { + let windows = Shared::defaults_for(PlatformDefaults::WINDOWS); + assert_eq!( + (windows.border.width, windows.border.offset), + (8, -1), + "komorebi/src/border_manager/mod.rs" + ); + assert_eq!(windows.defaults.container_padding, 10); + assert_eq!(windows.defaults.workspace_padding, 10); + + for platform in [PlatformDefaults::KDE, PlatformDefaults::MACOS] { + let other = Shared::defaults_for(platform); + assert_eq!((other.border.width, other.border.offset), (10, 0)); + assert_eq!(other.defaults.container_padding, 5); + assert_eq!(other.defaults.workspace_padding, 5); + } + } + + #[test] + fn the_default_colours_are_the_theme_conversion_of_the_rgb_values() { + // Rgb packs as r | g << 8 | b << 16 (komorebi-themes), e.g. FOCUSED = (66, 165, 245) + let colours = shared().border.colours; + assert_eq!(colours.single, 66 | (165 << 8) | (245 << 16)); + assert_eq!(colours.unfocused_locked, 158 | (8 << 8) | (8 << 16)); + } + + #[test] + fn an_empty_load_keeps_everything_but_the_two_reset_keys() { + let mut shared = shared(); + shared.border.width = 33; + shared.defaults.container_padding = 7; + shared.transparency.alpha = 12; + shared.animation.fps = 144; + shared + .display_index_preferences + .insert(0, String::from("serial")); + shared.layout_defaults.insert( + DefaultLayout::BSP, + LayoutDefaultEntry { + layout_options: None, + layout_options_rules: None, + }, + ); + let before = shared.clone(); + + SharedDelta::default().apply(&mut shared); + + // kept + assert_eq!(shared.border, before.border); + assert_eq!(shared.defaults, before.defaults); + assert_eq!(shared.transparency, before.transparency); + assert_eq!(shared.animation, before.animation); + assert_eq!( + shared.display_index_preferences, + before.display_index_preferences + ); + // reset + assert!(shared.layout_defaults.is_empty()); + assert_eq!( + shared.floating_window_aspect_ratio, + AspectRatio::Predefined(PredefinedAspectRatio::Standard), + "absent key writes Standard, not the initial Widescreen" + ); + } + + #[test] + fn present_keys_replace_and_absent_colours_stay() { + let mut shared = shared(); + let before = shared.border.colours; + + SharedDelta { + border: Some(false), + border_width: Some(3), + border_offset: Some(-2), + border_radius: Some(0), + border_colours: Some(BorderColoursDelta { + single: Some(1), + unfocused_locked: Some(2), + ..BorderColoursDelta::default() + }), + default_workspace_padding: Some(1), + transparency: Some(true), + transparency_alpha: Some(9), + ..SharedDelta::default() + } + .apply(&mut shared); + + assert!(!shared.border.enabled); + assert_eq!( + ( + shared.border.width, + shared.border.offset, + shared.border.radius + ), + (3, -2, 0) + ); + assert_eq!(shared.border.colours.single, 1); + assert_eq!(shared.border.colours.unfocused_locked, 2); + assert_eq!(shared.border.colours.stack, before.stack); + assert_eq!(shared.defaults.workspace_padding, 1); + assert_eq!(shared.defaults.container_padding, 5, "absent: kept"); + assert_eq!( + shared.transparency, + Transparency { + enabled: true, + alpha: 9 + } + ); + } + + fn block( + enabled: PerAnimationPrefixConfig, + style: Option>, + duration: Option>, + fps: Option, + ) -> SharedDelta { + SharedDelta { + animation: Some(AnimationDelta { + enabled, + style, + duration, + fps, + }), + ..SharedDelta::default() + } + } + + #[test] + fn a_global_animation_value_clears_its_per_prefix_map_and_a_map_keeps_the_global() { + let mut shared = shared(); + shared + .animation + .enabled_per_animation + .insert(AnimationPrefix::Movement, true); + shared + .animation + .duration_per_animation + .insert(AnimationPrefix::Workspace, 50); + + // global enabled clears the enabled map; style absent keeps; duration map replaces the + // map and leaves the global duration alone + block( + PerAnimationPrefixConfig::Global(true), + None, + Some(PerAnimationPrefixConfig::Prefix(HashMap::from([( + AnimationPrefix::Movement, + 10, + )]))), + Some(30), + ) + .apply(&mut shared); + + assert!(shared.animation.enabled); + assert!(shared.animation.enabled_per_animation.is_empty()); + assert_eq!(shared.animation.style, DEFAULT_ANIMATION_STYLE); + assert_eq!(shared.animation.duration, DEFAULT_ANIMATION_DURATION); + assert_eq!( + shared.animation.duration_per_animation, + HashMap::from([(AnimationPrefix::Movement, 10)]) + ); + assert_eq!(shared.animation.fps, 30); + } + + #[test] + fn an_animation_block_without_fps_resets_fps_and_no_block_keeps_it() { + let mut shared = shared(); + shared.animation.fps = 144; + + SharedDelta::default().apply(&mut shared); + assert_eq!(shared.animation.fps, 144); + + block(PerAnimationPrefixConfig::Global(false), None, None, None).apply(&mut shared); + assert_eq!(shared.animation.fps, DEFAULT_ANIMATION_FPS); + } + + #[test] + fn motion_for_reads_one_consistent_configuration() { + let mut shared = shared(); + shared.animation.enabled = true; + shared.animation.duration = 250; + shared.animation.style = AnimationStyle::Linear; + shared + .animation + .duration_per_animation + .insert(AnimationPrefix::Workspace, 400); + shared + .animation + .style_per_animation + .insert(AnimationPrefix::Workspace, AnimationStyle::EaseInSine); + shared + .animation + .enabled_per_animation + .insert(AnimationPrefix::Transparency, false); + + assert_eq!( + shared.animation.motion_for(AnimationPrefix::Movement), + Some(Motion { + duration_ms: 250, + style: AnimationStyle::Linear + }) + ); + assert_eq!( + shared.animation.motion_for(AnimationPrefix::Workspace), + Some(Motion { + duration_ms: 400, + style: AnimationStyle::EaseInSine + }) + ); + assert_eq!( + shared.animation.motion_for(AnimationPrefix::Transparency), + None, + "a per-prefix false wins over a global true" + ); + } + + #[test] + fn layout_defaults_are_replaced_when_given() { + let mut shared = shared(); + let entry = LayoutDefaultEntry { + layout_options: None, + layout_options_rules: None, + }; + + SharedDelta { + layout_defaults: Some(HashMap::from([(DefaultLayout::BSP, entry.clone())])), + ..SharedDelta::default() + } + .apply(&mut shared); + assert_eq!( + shared.layout_defaults.get(&DefaultLayout::BSP), + Some(&entry) + ); + + SharedDelta { + layout_defaults: Some(HashMap::new()), + ..SharedDelta::default() + } + .apply(&mut shared); + assert!(shared.layout_defaults.is_empty()); + } + + #[test] + fn display_index_preferences_are_replaced_not_merged() { + let mut shared = shared(); + shared + .display_index_preferences + .insert(0, String::from("a")); + shared + .display_index_preferences + .insert(1, String::from("b")); + + SharedDelta { + display_index_preferences: Some(HashMap::from([(2, String::from("c"))])), + ..SharedDelta::default() + } + .apply(&mut shared); + + assert_eq!( + shared.display_index_preferences, + HashMap::from([(2, String::from("c"))]) + ); + } +} diff --git a/docs/dev/bar-unification.md b/docs/dev/bar-unification.md new file mode 100644 index 00000000..cad5b74d --- /dev/null +++ b/docs/dev/bar-unification.md @@ -0,0 +1,180 @@ +# One `komorebi-bar` for Windows and macOS + +Status at the revision that adds this file. Everything below was measured on this tree with +`diff` (counts are lines that differ, `<`/`>` lines of `diff`, hunks in parentheses). Nothing +here was rendered: the bars cannot run on Linux. Gates that did run are named; the ones that +did not are listed at the end. + +KDE has no bar: `komorebi-for-kde` has no `komorebi-bar` package and no `schema.bar.json`. +The plan below is for two platforms; a KDE bar would add a third shell and a third schema view. + + +## What exists + +- `crates/komorebi-bar-core` (4,592 lines): the code both bars share, one copy each. + `selected_frame`, `ui`, `statics`, `alignment`, `render`, `config` (the leaf types and the whole + `KomobarConfig` with the mouse options), the widgets `applications`, `battery`, `cpu`, `date`, + `memory`, `storage`, `time`, `update`, `komorebi_layout`, the icon cache (`widgets/icons.rs`), + all widget configuration types (`komorebi_config`, `network_config`, `platform_config` for + keyboard, media and systray), `WidgetConfig` with a variant for every widget, the `BarWidget` + trait, `platform` (`BarPlatform`, `SystemTool`, a once-installed global) and `schema` + (per-platform views, see below). +- `BarPlatform` hooks: `open_system_tool`, `latest_release_api_url`, `calendar_icon`, + `command_icon`, `exec_shell_command`, `send_komorebi_message`, `send_komorebi_batch`. +- Two shells, `komorebi-bar` (Windows, root workspace, version 0.1.42) and + `komorebi-for-mac/komorebi-bar` (own workspace, own `Cargo.lock`, own flake, version 0.1.1). + Each keeps `main.rs`, `bar.rs`, `os.rs`, the one-line `config.rs`, and the widgets whose + runtime differs: `komorebi.rs`, `network.rs`, and on Windows `keyboard.rs`, `media.rs`, + `systray.rs`. `widgets/widget.rs` is only `WidgetConfigExt` (config -> widget). +- The schema oracle regenerates the macOS file natively + (`tests/schema_oracle.rs`, module `generated`); the Windows file is still checked by the + out-of-tree harness because three descriptions read differently when compiled for Windows. + +## Why one package is not possible yet (the structural blocker) + +The two trees are two Cargo workspaces with two lockfiles and two flakes. A package belongs to +one workspace, so a single `komorebi-bar` that builds on both platforms needs one workspace +first: the Windows root `Cargo.toml` and `komorebi-for-mac/Cargo.toml` merged, one `Cargo.lock`, +one version (0.1.42 vs 0.1.0), one `komorebi-client` (the two `lib.rs` differ in 150 lines: +Windows re-exports the daemon crate and `State`/`Window`/`Workspace`, macOS has its own +types) and `windows`/`objc2` dependencies under `[target.'cfg(...)'.dependencies]`. That is a +repository-level cutover (the plan's "final naming of the outputs and the unified app/devShell +is a cutover decision"), not a bar change. Until then the bars share the crate and converge, +they do not merge. Everything in this stack is ordered so the merge later is a package move. + +## The schema is solved: views of one union + +`schema.bar.json` differs between the platforms in 40 hunks (5,381 lines of 9,091 / 3,774). +Analysed by definition (`jq` over `$defs`): + +- 42 definitions exist only on Windows: all reachable only through `MouseConfig` (which holds a + `SocketMessage`, hence the closure) and the `Keyboard`/`Media`/`Systray` widgets. Removing the + `mouse` property and the three widget variants and pruning unreachable definitions removes + all 42. +- 14 differences remain in the 55 shared definitions, all text or default: root description + (macOS names its own schema URL), `height` default `50` vs `50.0`, the margin and padding + examples (`10` vs `10.0`, a sentence about defaults, a trailing comma), `show_icon` + (deprecated flag and backticks), `KomorebiLayout` (untagged `anyOf` on Windows, externally + tagged `oneOf` on macOS), `NetworkConfig.default_interface_options` (Windows only), + four storage descriptions (`C:\` vs `/`), `TimeConfig.changing_icon` (`default: false` only + on macOS), `TimeFormat` title of `Custom` (macOS says `Default`, a mislabel), the + `WidgetConfig` variants above. + +`komorebi_bar_core::schema` holds this as data (`schema/changes.rs`, 16 changes) in the same +design as `komorebi-config`: the union is the Windows text, `view_of` applies a platform's +changes and prunes. `tests/schema_oracle.rs` proves that the macOS view of the checked-in Windows +file is byte-identical to `komorebi-for-mac/schema.bar.json`, that the Windows view is the +identity, and that views are idempotent. Both bars print `--schema` through their view +(`schema::generated_text`), so the files and `nix/komorebi-bar-options.nix` do not change when +code is converged onto the Windows spelling. + +The out-of-tree harness (`S/bar2/regen.sh`, real bar sources over the real +`komorebi-protocol`/`komorebi-config`) regenerates both files on Linux. Since `config.rs` moved, the +in-repo oracle regenerates the macOS file itself (`generated` tests); only Windows needs the harness. + +## What is still per platform (measured at this tip) + +| file | win / mac lines | differing | what differs and why it is not moved | +|---|---|---|---| +| `widgets/komorebi.rs` | 940 / 986 | 136 (17) | Two things. (1) Real rendering differences: macOS wraps `show_label` and the "all icons" painter in a `Frame` with a margin and a minimum height (`bar.show_label(...)`, a method, vs an associated function on Windows), `1.0` vs `1.0_f32`. Taking either spelling is a visual change on the other platform: needs a screen. (2) The conversion from the daemon state: Windows uses `komorebi_client::{State, Window, Workspace, Container}` (`hwnd`, `title()`, `process_id()`, windows-icons), macOS its own client types (`details.title`, `details.icon_path`, `icns_to_rgba`, `device`, no `Layout::Custom`). A State-to-snapshot split (`WindowInfo`/`MonitorInfo` of `komorebi-backend` as the carrier) is not clean yet: the macOS and Windows `komorebi_client` `State` are different types, the bar subscribes to the daemons with different notification shapes, and I cannot test either subscription here. Not attempted. | +| `widgets/network.rs` | 598 / 578 | 82 (19) | Windows reads `default_interface_options` (friendly name, IPv4, IPv6), macOS does not (the config type is shared and macOS logs a warning); `std::sync::Mutex` (poisoning handled) vs `parking_lot`; `netdev` 0.46 vs 0.40 and a different interface match key (friendly name vs `name`); `cmd.exe /C ncpa` vs `open x-apple...`. Functional on macOS: BEHAVIOUR, needs a Mac. Known difference, left as is. | +| `bar.rs` | 1,399 / 1,105 | 444 (35) | Windows: PowerShell session (`exec_powershell`, behind the hook), `hwnd`, virtual-desktop restore/minimize, `position_bar` through `komorebi_client::Window`, CJK font fallbacks, `scale_factor`, the Windows mouse handling. macOS: unboxed notification type, window shadow and level. Not behaviour-preserving without a screen. | +| `main.rs` | 402 / 388 | 92 (14) | `process_hwnd`/`enum_window`, DPI awareness, `set_read_timeout`; macOS activation policy and window level (AppKit), config dir `~/.config/komorebi`. Window and viewport setup belongs behind `BarPlatform` hooks (`init_process`, `viewport_builder`, `after_creation`), not written: it cannot be exercised here. | +| `os.rs` | 83 / 60 | 71 (14) | the platform implementations themselves; stays per platform (`cfg(target_os)` modules after the merge). | +| `widgets/widget.rs`, `widgets/mod.rs` | 41 / 49, 22 / 19 | 20, 5 | `WidgetConfigExt` (macOS builds a no-op `Unsupported` for the three Windows-only widgets) and the module lists. | + +Windows-only and not shared: `widgets/keyboard.rs` (167), `media.rs` (322), `systray.rs` (1,205); +their config types are in the core. The macOS copies of keyboard and media were dead and are deleted. + +### Known macOS/Windows differences that were kept (D-coord-1) + +- `network.rs`: see above. The macOS bar ignores `default_interface_options`. +- Windows-only config keys are accepted on macOS and skipped with a logged warning (BEHAVIOUR on + macOS, as for `StaticConfig`): `Keyboard`, `Media` and `Systray` widgets + (`widgets::widget::warn_unsupported`), the `mouse` options and the network + `default_interface_options`. Before, the first group failed to deserialize and rejected the whole + config, the other two were ignored silently. +- macOS commands that spawn programs that do not exist there are preserved, not fixed: + `SystemTool::PowerSettings` (battery click) spawns `cmd.exe /C start ms-settings:...`, + `SystemTool::Url` (update click) spawns `explorer.exe`, and `UserCommand::launch_if_ready` of the + applications widget spawns `cmd /C ` in both bars. `update.rs` still has a second GitHub + API URL for the Windows repository. The macOS spawns live in + `komorebi-for-mac/komorebi-bar/src/os.rs`; the storage click is `SystemTool::StorageLocation` and + each shell spawns what it spawned before. +- `KomorebiLayout` is `#[serde(untagged)]` in both bars now (the macOS source converged; the macOS + schema view restores the macOS text). Its `Deserialize` is hand-written and nothing in the bar + serializes it, so this changes only the derived schema and `Serialize`. The coordinator said to + leave it alone; it is a separate revision (`mac-bar: converge komorebi.rs config text and + komorebi_layout.rs`) and can be dropped, at the price of keeping `KomorebiLayout` and its + widget in the shells. + +## The merged package layout (for the coordinator's workspace merge) + +Not done and not prepared in the tree (no workspace or lockfile structure was changed). When the +root workspace and `komorebi-for-mac` are merged the bar should look like this: + +- Package `komorebi-bar` (one, in `komorebi-bar/`) plus `crates/komorebi-bar-core` as today. + Version: the workspace version. The macOS tree's `komorebi-bar` 0.1.1 and the `v0.1.0` text in + its schema description are a view concern (`schema/changes.rs`), not a package one. +- `src/main.rs` (shared: the argument parsing, config read, `--schema`, the event loop shell) + and `src/bar.rs` split into `src/bar/mod.rs` (shared `Komobar`) with + `#[cfg(target_os = "windows")] mod windows;` and `#[cfg(target_os = "macos")] mod macos;` + for the window handle, DPI, activation policy, window level, position and the PowerShell + session. `src/os.rs` becomes `src/os/{windows,macos}.rs` (the `BarPlatform` implementations, + selected by `cfg(target_os)` and installed first thing in `main`). Windows-only widgets + (`keyboard`, `media`, `systray`) are `#[cfg(target_os = "windows")]` modules; `komorebi.rs` and + `network.rs` stay two files (`komorebi/{windows,macos}.rs` behind one `widgets::komorebi` + facade) until the snapshot split. +- `Cargo.toml`: `windows`, `windows-core`, `windows-icons*`, `systray-util`, `font-loader` under + `[target.'cfg(windows)'.dependencies]`; `objc2`, `objc2-app-kit`, `raw-window-handle`, `icns` + under `[target.'cfg(target_os = "macos")'.dependencies]`; `netdev` has two versions (0.46, + 0.40): pick one when `network.rs` converges, until then a target-specific rename + (`netdev-win`, `netdev-mac`). `komorebi-client`: one crate (the two differ in 150 lines). + Features: `schemars` as now (it forwards to the core). No new feature per platform: the + platform is `cfg(target_os)`. +- Schema: the checked-in files stay (`schema.bar.json`, `komorebi-for-mac/schema.bar.json`, or one + directory for both after the merge). `--schema` prints the view of the running platform + (`SchemaPlatform` from `cfg(target_os)`); a `--schema-for ` flag would let one host + regenerate both, as the Windows union is the Windows text. Windows' three + platform-dependent descriptions (protocol `PathBuf`, themes) need the Windows target to + regenerate, so the Windows file is only verifiable on Windows or with the harness. +- Lock files: the macOS `Cargo.lock` folds into the root one; the lines this stack added to both + (`image`, `komorebi-config`, `komorebi-layouts`, `komorebi-protocol`, `komorebi-themes`, + `tracing`, `lazy_static`, `color-eyre` for bar-core) are the same crates in both, so the merge + adds no new versions beyond `netdev` and `starship-battery`/`sysinfo` (already unified here). +- Nix: `komorebi-for-mac/nix/komorebi-bar-options.nix` is generated from the macOS file and does + not change; the two flakes' `extractBinary "komorebi-bar"` become one derivation per system. + +## The plan, in order + +1. Done: schema views and oracle; storage, time, applications, komorebi layout widget, icon + cache, all widget config types, `WidgetConfig`, `config.rs` moved; hooks for platform + spawns, the shell session and the komorebi client. +2. Not done, needs a screen or a Mac: `bar.rs`/`main.rs` window code behind `BarPlatform` + (`init_process`, `viewport_builder`, `after_creation`, position/DPI/activation), then + `Komobar

` and the shared `app` body into the core. +3. Not done, needs the snapshot split: `komorebi.rs`. It needs `State`-to-snapshot conversion + per platform and one renderer; the frame/margin difference must be decided by looking at both. +4. Decision for `network.rs`: converge on the Windows widget (macOS gains friendly-name, + IPv4/IPv6 and the `netdev` bump) or keep two. +5. Workspace merge (above), then the two shells collapse into one package. +6. The oracle then compares generated text with the files for both platforms. + +## What to check by hand + +Windows (coordinator, screenshot e2e): the bar starts; the storage, time, applications (icons +from executables), komorebi layout (click opens the options, choosing one changes the layout, +Monocle/Floating toggles, paused) and komorebi workspaces widgets render; a click on storage +opens Explorer on the drive; a config with `mouse` options and a `Systray`/`Keyboard`/`Media` +widget still works (this is the Windows path through the moved `config.rs`, `WidgetConfig` and +the `exec_shell_command` hook: a `Command` mouse message must still reach the PowerShell session); +`komorebi-bar --schema` equals `schema.bar.json`. + +macOS (also in the section for `docs/dev/macos-handcheck.md`): the bar starts with your real +config; the time widget renders (format variants, `changing_icon`); clicking a storage widget +opens the mounted volume in Finder (`open `); the komorebi layout widget click works as +before; a config that contains a `Keyboard`, `Media` or `Systray` widget, a `mouse` block, or +`default_interface_options` under `Network` starts the bar and logs one warning per item (before: +the widget case failed to load the config); `komorebi-bar --schema` equals +`komorebi-for-mac/schema.bar.json`; the battery and update clicks still fail the way they did +(preserved, one-function fix in `os.rs`). diff --git a/docs/dev/settings.md b/docs/dev/settings.md new file mode 100644 index 00000000..6ca75bb5 --- /dev/null +++ b/docs/dev/settings.md @@ -0,0 +1,320 @@ +# Settings: replacing the process-wide statics + +Status: design (plan step 10, phase 1). Measured on `wip-main` 633e7453 (the Windows tree +`komorebi/src`, KDE `komorebi-for-kde/komorebi/src`, macOS `komorebi-for-mac/komorebi/src`). Nothing +here was run: counts come from `grep` and reading, and macOS cannot run on this host. The shared type +is landed as the crate `crates/komorebi-settings` (phase 2) and **no daemon uses it yet**. + +Abbreviations: `W/`, `K/`, `M/` are the three `src` trees. `apply_globals` is +`StaticConfig::apply_globals` (`W/static_config.rs:396`, `K/static_config.rs:414`, +`M/static_config.rs:392`). + +## 1. What the statics are + +Declarations of `static`, `static ref` and `lazy_static` (thread locals and per-call counters left out): +**Windows 126, KDE 53, macOS 74.** Most are not settings. Four kinds: + +| kind | examples | goes into `Settings`? | +|---|---|---| +| **config**: written by `komorebi.json` load and/or a `SocketMessage`, read by other threads | `BORDER_*`, `ANIMATION_*`, paddings, rule lists, stackbar | yes | +| **session state**: changes while running, is not configuration | `SESSION_FLOATING_APPLICATIONS`, `UNMANAGED_WINDOW_IDS`, `HIDDEN_HWNDS`, `WORKSPACE_MATCHING_RULES`, `INITIAL_CONFIGURATION_LOADED`, `CUSTOM_FFM` (command line) | no; stays where it is (core step 9) | +| **plumbing**: channels, `OnceLock`, caches of OS handles, per-window tables | `CHANNEL`, `BORDER_STATE`, `MONITOR_CACHE`, `HWNDS_CACHE`, `PARKED_WINDOWS`, `live_resize.rs` flags | no | +| **environment**: computed once | `HOME_DIR`, `DATA_DIR`, `AHK_EXE`, `WINDOWS_11` | no | + +Config-class statics by my count: **Windows 61, KDE 35, macOS 36** (these include the session-state +rule lists that sit next to them; see 2.3). What `apply_globals` itself writes (distinct statics, +counted on the function body): **Windows 51, KDE 29, macOS 28.** The plan's "19-27 written per +platform" counted scalar settings only; the 11 rule lists, the 7 per-animation/global animation +statics and the stackbar block make the totals larger. + +## 2. The inventory (config class) + +"Writers": `load` is `apply_globals` (runs on start-up through `preload`, and again on every +`ReloadConfiguration` through `reload`), `cmd` is a `SocketMessage` arm of `process_command.rs`, +`theme` is `theme_manager`, `thr` is another thread. "Readers" lists only threads that are not the +window-manager thread; "wm" means code that runs under the window-manager lock. Match is the name +on the other platforms (`=` same name and type, `~` same name, different default or type, `-` +absent). + +### 2.1 Scalars and small values + +| static | type | writers | readers | W / K / M | shared? | note | +|---|---|---|---|---|---|---| +| `BORDER_ENABLED` | AtomicBool | load, cmd `Border` | border thread (all 3), wm | = = = | shared | K reads it to decide whether borders take room in the layout (`BORDER_DRAWN` is its derived twin, K only) | +| `BORDER_WIDTH` | AtomicI32 | load, cmd | border thread, wm (offsets), stackbar (W) | ~ default 8 / 10 / 10 | shared | | +| `BORDER_OFFSET` | AtomicI32 | load, cmd | border thread, wm | ~ default -1 / 0 / 0 | shared | | +| `BORDER_RADIUS` | AtomicI32 | load | K effect, M `ns_window` | - K M | K+M | Windows draws square or via DWM (`STYLE`) | +| `BORDER_OFFSET_ADJUSTMENT` | `static i32` = 7 | nobody | M border | - - M | platform constant | stays a constant | +| `FOCUSED UNFOCUSED UNFOCUSED_LOCKED MONOCLE STACK FLOATING` | 6 x AtomicU32 | load, cmd `BorderColour`, theme | border thread | = = = | shared | `UNFOCUSED_LOCKED` is written by theme only (load never writes it); one theme change writes 6 border and (W) 3 stackbar values one by one | +| `STYLE` | AtomicCell | load, cmd | border thread, wm | W only | W platform | load resets it to default when the file has none | +| `IMPLEMENTATION` | AtomicCell | load, cmd | border thread | W only | W platform | | +| `ANIMATION_ENABLED_GLOBAL` | Arc | load, cmd, **scoped override**, shutdown | W animation engine, M animator and lane threads, wm | = = = | shared | see 4 (override) | +| `ANIMATION_STYLE_GLOBAL` | Arc> | load, cmd | same | = = = | shared | | +| `ANIMATION_DURATION_GLOBAL` | Arc | load, cmd | same | = = = | shared | | +| `ANIMATION_{ENABLED,STYLE,DURATION}_PER_ANIMATION` | Arc>> x3 | load, cmd (2 sites for style), shutdown (enabled) | same | = = = | shared | `load` clears the map when a global value is given, replaces it when a prefix map is given | +| `ANIMATION_FPS` | AtomicU64 | load, cmd | W engine, M animator (`frame_period`) | = = = | shared | load writes the default when `animation` is present but `fps` is not | +| `GHOST_MOVEMENT_ENABLED` | AtomicBool | load | W ghost dispatcher, live resize | W only | W platform | | +| `TRANSPARENCY_ALPHA` | AtomicU8 | load, cmd | W/K transparency manager | = = - | shared (W, K) | default 200 | +| `TRANSPARENCY_ENABLED` | AtomicBool | load (`transparency`), cmd (set, toggle) | transparency manager, W process_event | = = - | shared (W, K) | a command toggles what the file set; a reload overwrites it only when the key is present | +| `DEFAULT_WORKSPACE_PADDING`, `DEFAULT_CONTAINER_PADDING` | AtomicI32 | load | wm (every layout), W stackbar | ~ default 10 / 5 / 5 | shared | load keeps the old value when the key is absent | +| `DEFAULT_RESIZE_DELTA`, `DEFAULT_MOUSE_FOLLOWS_FOCUS` | `static i32 / bool` | nobody | wm | = = = | shared constants | become part of the platform defaults | +| `FLOATING_WINDOW_TOGGLE_ASPECT_RATIO` | Arc> | load (2 sites) | wm | = = = | shared | load writes a default when absent | +| `LAYOUT_DEFAULTS` | Arc>> | load (replace or clear) | wm | = = = | shared | | +| `DISPLAY_INDEX_PREFERENCES` | Arc>> | load, cmd | monitor reconciliator thread, wm | = = = | shared | | +| `MONITOR_INDEX_PREFERENCES` | Arc>> | load, cmd | W windows_api, reconciliator | W only | W platform | commented out of macOS and KDE load | +| `DUPLICATE_MONITOR_SERIAL_IDS` | Arc>> | reconciliator, windows_api | windows_api | W only | **not config** | derived from the monitor list | +| `HIDING_BEHAVIOUR` | Arc> | load, cmd, (state loader) | **reaper thread** (every 20 ms), wm | W only | W platform | reaper reads it without the wm lock | +| `WINDOW_HANDLING_BEHAVIOUR` | AtomicCell | load, **scoped override** in `border.rs:831-851` | W windows_api (every position call), ghost dispatcher, scrolling | W only | W platform | see 4 | +| `MINIMUM_WIDTH`, `MINIMUM_HEIGHT` | AtomicI32 x2 | load | wm | W only | W platform | commented out of the macOS load | +| `SLOW_APPLICATION_COMPENSATION_TIME` | AtomicU64 | nobody after init | W window | W only | W platform | effectively constant 20 | +| `REMOVE_TITLEBARS` | AtomicBool | cmd | wm, workspace | W only | W platform | runtime toggle, not in `apply_globals` | +| `STACKBAR_*` (9: font size/family, focused/unfocused text, tab background, height, width, label, mode) | AtomicI32/U32/Cell, `Mutex>` | load, cmd, theme (3 colours) | stackbar threads, wm | W only | W platform | load sets font size and family unconditionally (0 / None) when `stackbar.tabs` is present | +| `STACKBAR_TEMPORARILY_DISABLED` | AtomicBool | ghost dispatcher | stackbar manager | W only | session state | | +| `CURRENT_THEME` | AtomicCell> | theme thread | theme thread only | = = = | not config | read nowhere else | +| `TABBED_APPLICATIONS`, `TITLELESS_APPLICATIONS`, `DO_NOT_OBSERVE_APPLICATIONS` | Arc>> | load (first two) | M window, process_event | M only | M platform | | + +### 2.2 Rule lists (the lock-ordered set) + +| static | writers | readers | W / K / M | +|---|---|---|---| +| `FLOATING_APPLICATIONS`, `IGNORE_IDENTIFIERS`, `MANAGE_IDENTIFIERS` | load (all three held at once) | wm; W also `winevent` and `window_manager_event` threads through `window_is_eligible` | = = = | +| `TRANSPARENCY_BLACKLIST` | load | transparency manager | = = - (K: pub) | +| `LAYERED_WHITELIST`, `TRAY_AND_MULTI_WINDOW_IDENTIFIERS`, `OBJECT_NAME_CHANGE_ON_LAUNCH`, `OBJECT_NAME_CHANGE_TITLE_IGNORE_LIST`, `SLOW_APPLICATION_IDENTIFIERS`, `NO_TITLEBAR` | load | wm, event threads | W only | +| `REGEX_IDENTIFIERS` | load (clears), every rule check (compiles on demand) | every rule check | = = = | +| `PERMAIGNORE_CLASSES` (W also `WSL2_UI_PROCESSES`) | nobody after init | window eligibility | = = = (W: +1) | +| `SESSION_FLOATING_APPLICATIONS` | cmd (add/remove at run time) | window eligibility | = = = | +| `WORKSPACE_MATCHING_RULES` | cmd, **reload (clears, then refills after `apply_globals`)** | wm, reconciliator, M process_event | = = = | + +### 2.3 Lock-order relations + +The documented order (comment above the statics in `komorebi/src/lib.rs` on `wip-u-kfw`; it is the +only one: KDE and macOS declare no order and take the same lists in whatever order the code does): + +``` +FLOATING_APPLICATIONS, SESSION_FLOATING_APPLICATIONS, TRANSPARENCY_BLACKLIST, IGNORE_IDENTIFIERS, +MANAGE_IDENTIFIERS, LAYERED_WHITELIST, TRAY_AND_MULTI_WINDOW_IDENTIFIERS, OBJECT_NAME_CHANGE_ON_LAUNCH, +OBJECT_NAME_CHANGE_TITLE_IGNORE_LIST, SLOW_APPLICATION_IDENTIFIERS, NO_TITLEBAR, +WORKSPACE_MATCHING_RULES, REGEX_IDENTIFIERS +``` + +`REGEX_IDENTIFIERS` is last (every rule check holds a list and then the regexes); `PERMAIGNORE_CLASSES` +and `WSL2_UI_PROCESSES` are leaves. The window-manager lock is above all of them: `apply_globals` runs +with it held (the `reload` signature takes `&mut WindowManager`), but the event threads of Windows +(`winevent_listener` to `window_manager_event`) and the transparency manager also lock single lists +without it. + +Two findings: + +1. `apply_globals` on Windows takes **eleven** list locks in a fixed order and keeps them for the whole + function (`W/static_config.rs:562-572`), so the rules change atomically with respect to each other, + but not with respect to the scalars (written before and after). +2. `reload` clears `WORKSPACE_MATCHING_RULES` and refills it per monitor afterwards (`M/static_config.rs` + after `apply_globals`, same on W and K): for the duration of the loop a reader that does not hold the + wm lock sees an **empty** list. This is a live partial-update window today. + +With `Settings` the immutable lists are fields of one `Arc` snapshot: readers take no lock, there is no +order to follow, and the lock order shrinks to: wm lock, then the two session mutexes +(`SESSION_FLOATING_APPLICATIONS`, the regex cache), never nested the other way round. + +## 3. The design + +### 3.1 Crates + +New crate `crates/komorebi-settings` (std plus `parking_lot`; types from `komorebi-config`, +`komorebi-protocol` and `komorebi-layouts`; no daemon dependency, so it tests natively): + +```rust +pub struct Settings

{ + pub shared: Shared, // the same struct on all three platforms + pub platform: P, // Windows: border style, stackbar, hiding/handling behaviour, ... +} // KDE: nothing yet (the compositor owns look); macOS: tabbed/titleless apps + +pub struct Shared { + pub border: Border, // enabled, width, offset, six colours + pub animation: Animation, // global + per-prefix enabled/style/duration, fps; motion_for(prefix) + pub defaults: Defaults, // container/workspace padding, resize delta, mouse-follows-focus + pub transparency: Transparency, // enabled, alpha (Windows and KDE; macOS keeps the default) + pub floating_window_aspect_ratio: AspectRatio, + pub layout_defaults: HashMap, + pub display_index_preferences: HashMap, +} + +pub struct Handle

{ /* RwLock>> + a writer mutex */ } +impl Handle

{ + pub fn snapshot(&self) -> Arc>; // readers + pub fn replace(&self, s: Settings

); // whole new value + pub fn update(&self, f: impl FnOnce(&mut Settings

) -> R) -> R; // copy, edit, swap; writers serialised + pub fn scoped(&self, field: fn(&mut Settings

) -> &mut T, value: T) -> Scoped<'_, P, T>; +} +``` + +The **platform part** is a plain type parameter `P` (not a trait): each daemon names its struct +(`WindowsSettings`, `MacosSettings`, `KdeSettings`) and the handle is `static SETTINGS: LazyLock>` +behind a `settings()` function of the daemon crate. `P` needs only `Clone + Debug`. A trait would add +nothing, because the shared code never calls into `P`: only the platform's own subsystems read it. When +the daemons become one crate this is `#[cfg(target_os)] type Platform = ...`. + +The lists of rules and the stackbar block move into the platform parts first (they are Windows only or +differ); the rule lists join `Shared` in step G7 below once the lock order is retired. + +### 3.2 How readers get a consistent snapshot + +* A reader calls `settings().snapshot()` **once per unit of work** (one render pass of a border, one + `motion_for` call, one layout pass, one window eligibility check) and uses that `Arc` for the whole + unit. Cost: one uncontended `RwLock` read and an `Arc` clone; no atomics are needed on the hot + paths I found (border thread loops once per notification, animator once per frame, rule checks once per + event). If a measurement shows one needs it, add an `AtomicBool` *mirror* of that single field written by + the same `update`; do not read the mirror and the snapshot for the same decision. +* `ArcSwap` was the first idea. It is **not in any of the three lockfiles** (checked), so it would add a + dependency to three lockfiles and the flake hashes for no gain at this read rate. `Handle` hides the + representation: swapping `RwLock>` for `ArcSwap<_>` later changes one file. +* Writers go through `update`, which holds a mutex while it clones, edits and publishes, so two writers + (a `SocketMessage` and a config reload, a theme change and a command) never lose each other's field. The + statics lose such updates today only when both write the same field, but a theme change writes nine + separate atomics and a command can interleave. +* A config load builds the new value and publishes it **once**; a theme change is one `update`. + +### 3.3 Semantics that must survive: reload merges, it does not replace + +`apply_globals` is a field-by-field merge: `if let Some(x) = self.border_width { store }`. A key absent +from a reloaded `komorebi.json` **keeps the previous value** (and a command-set value); a few fields are +instead reset (listed in section 4, "reset on reload"). A "build a fresh Settings from the file and swap" +would reset every absent key to its default. So the migration keeps the merge: `Settings::apply(delta)` +is written as the same sequence of `if let Some` over a copy of the **current** snapshot, published once. +Replacing instead of merging is a separate, optional BEHAVIOUR change, last in the list. + +### 3.4 The scoped override + +Two places write a setting, do work and put it back: Windows `border.rs:831-851` +(`WINDOW_HANDLING_BEHAVIOUR`: load, store `handling`, ..., store `previous`) and KDE +`process_command.rs:607-609` (`ANIMATION_ENABLED_GLOBAL.swap(false)`, `add_window_to_container`, +`store(restore)`). Both clobber a concurrent change (a reload or a command in between is overwritten with the old value). macOS +already has the right pattern: `animation::suspend()` returns a guard that counts and `motion_for` returns +`None` while it is held (`M/animation/mod.rs:36-71`). `Handle::scoped` gives the same for any field: +it sets the value and, on drop, puts the previous value back **only if the field still holds the +override value**, so a change made meanwhile wins. For animations KDE and Windows should adopt +macOS's `suspend` counter instead (a field of `Shared::animation`, not a scoped value). + +## 4. Statics whose behaviour would change + +Everything here is a **BEHAVIOUR** change if it is not made to keep today's semantics. Flagged items +say what the migration does about each. + +**Partial updates visible mid-reload (today).** `apply_globals` publishes about 28 to 51 values +one at a time while the border, animator, reaper and transparency threads keep reading. A border +thread can draw once with the new width and the old colours; `motion_for` (macOS) reads enabled, +duration and style as three separate lock/atomic reads and can mix two configurations. With `Handle` all +readers see the old or the new `Settings`, never a mix: **BEHAVIOUR (intended, can only remove +tears)**, on every static of the table. The e2e that would show it is `config_reload`. + +**Reset on reload (must be kept as written):** `LAYOUT_DEFAULTS` (cleared when absent), +`FLOATING_WINDOW_TOGGLE_ASPECT_RATIO` (default when absent), `ANIMATION_FPS` (default when `animation` is present +without `fps`), `GHOST_MOVEMENT_ENABLED` (W), `STYLE` and `IMPLEMENTATION` (W: default when absent), +`STACKBAR_FONT_SIZE` / `STACKBAR_FONT_FAMILY` (W: 0 / None when `stackbar.tabs` is present), +`ANIMATION_*_PER_ANIMATION` (cleared when the global form is used) and the rule lists (`apply_globals` +clears each only when the new file gives that list; check per list in G7). + +**Keeps the old value when absent (must be kept):** borders (width, offset, radius, enabled, colours), +paddings, transparency alpha, hiding and handling behaviour, minimum size, stackbar scalars, display index +preferences. + +**Scoped override (3.4):** KDE `ANIMATION_ENABLED_GLOBAL.swap/store` pair and Windows `WINDOW_HANDLING_BEHAVIOUR` +pair become guards. A concurrent reload no longer gets clobbered: BEHAVIOUR. + +**Shutdown writes** (`main.rs:367-368`, `window_manager.rs:1445-1446` on W; the Stop handler on K and M): they +disable animation permanently before restoring windows. These are plain `update`s, not scoped. + +**`WORKSPACE_MATCHING_RULES` empty window** (2.3): stays in the core until step 9 (session state); not part +of `Settings`, so not changed here. + +**Defaults differ per platform** (border width 8/10/10, offset -1/0/0, padding 10/5/5, KDE border radius): +`Shared::defaults_for(PlatformDefaults::{WINDOWS, KDE, MACOS})`. Changing any default is user visible and is +not part of this step. + +**Threads read the statics without the wm lock:** border thread (all three), W reaper +(`HIDING_BEHAVIOUR`), W winevent and window_manager_event threads (rule lists), W transparency manager, +macOS animator thread and the per-pid lane threads, macOS border thread. These are the readers whose behaviour a snapshot +changes (from "latest value on each read" to "value of the snapshot taken at the start of the unit"); the +gap is at most one unit of work. + +## 5. Migration order (least to most risky) + +Each group is one revision per platform tree (or per platform file set), moving the writers and **all** +readers of that group together, never dual-writing a static and a field. P = pure refactor (same +observable behaviour); B = BEHAVIOUR (marked in the revision body, not landed until its gate ran). + +| step | group | kind | gate | +|---|---|---|---| +| S0 | the crate and its tests (landed, phase 2) | P | native `cargo test -p komorebi-settings` | +| S1 | `DEFAULT_RESIZE_DELTA`, `DEFAULT_MOUSE_FOLLOWS_FOCUS`, `BORDER_OFFSET_ADJUSTMENT`: constants into `PlatformDefaults` | P | KDE `cargo test -p komorebi --lib`, Windows clippy, macOS fake-CC check | +| S2 | install `settings()` in each daemon with the platform defaults (no reader yet) | P | same | +| S3 | paddings, aspect ratio, layout defaults, display index preferences (wm-lock readers and the reconciliator) | P for the readers; the load writes the new place once: B (tear) | KDE lib tests; Windows `config_reload`, `layouts`, `workspaces` e2e; macOS: reload with changed padding | +| S4 | transparency enabled and alpha (W, K) | P | Windows `config_reload` plus the transparency scenario if it exists; KDE lib tests | +| S5 | animation (global, per-prefix, fps): `motion_for`, engine, animator; KDE/Windows adopt `suspend()` guards | **B** (tear removed, override semantics) | KDE VM suites that stack windows (`stacks`); Windows animation e2e; **macOS hand-check**: animation on, switch workspace, change `animation.duration` by `komorebic` mid-animation, stack a window | +| S6 | border scalars and colours, theme colours as one `update` | **B** (the 6 colour atomics change together) | Windows border e2e (screenshot) and `config_reload`; KDE VM `borders`/look suites; **macOS hand-check**: `border-colour`, `border-width`, theme change, reload with changed border | +| S7 | Windows platform part: `HIDING_BEHAVIOUR`, `WINDOW_HANDLING_BEHAVIOUR` (with `scoped`), min size, stackbar block, border style/implementation, ghost movement | **B** (reaper reads a snapshot; override guard) | Windows: `commands`, `config_reload`, `lifecycle`, `lifecycle_hidden`, stackbars, border e2e | +| S8 | rule lists into `Shared` (immutable `Arc`), session floating and regex cache stay mutexes; retire the lock-order comment | **B** (hot path of every window event; deadlock class removed) | Windows `config_reload` (lock order), `commands`, `focus`, `lifecycle`; KDE lib tests plus VM suites; macOS: manage/ignore rules by hand | +| S9 | `apply_globals` as one `update` (single publish), and one decision on merge versus replace | **B** | Windows `config_reload`; macOS reload by hand | + +Risk grows with the number of threads that read without the wm lock and with how hot the read is: +S3 has none, S5 and S6 have the macOS threads the plan names, S7 has the reaper, S8 is every event. +Do S8 only after the Windows lock-order e2e from the `wip-u-kfw` stack has run once on the current +tree. + +## 6. What is not in `Settings` + +`SESSION_FLOATING_APPLICATIONS`, `UNMANAGED_WINDOW_IDS`, `HIDDEN_HWNDS`/`HIDDEN_WINDOWS`, +`WORKSPACE_MATCHING_RULES`, `REMOVE_TITLEBARS`, `CUSTOM_FFM`, +`INITIAL_CONFIGURATION_LOADED`, subscriptions, `*_CHANNEL`s, caches, `BORDER_STATE`: these are session or +plumbing state and belong to the model (step 9) or the subsystem. `Settings` is only what the user +configures. + +## 7. What landed (phase 2) and findings from writing it + +`crates/komorebi-settings` (native tests, 18 passed): `Settings

`, `Handle

` (`snapshot`, `replace`, +`update`, `scoped`), `Shared` (border, animation with `motion_for`, paddings and constants, transparency, +aspect ratio, layout defaults, display index preferences), `PlatformDefaults::{WINDOWS, KDE, MACOS}` and +`SharedDelta::apply`, which is `apply_globals` for the shared keys written as a function of the current +value. Not yet in the crate: rule lists, theme application as one update, anything platform specific. + +Findings that only showed up while encoding the semantics: + +* `FLOATING_WINDOW_TOGGLE_ASPECT_RATIO` starts at **Widescreen** on all three platforms, but a load + whose file has no `floating_window_aspect_ratio` writes **Standard** (`unwrap_or`). So the first + reload of a file without the key changes the ratio. `SharedDelta::apply` keeps this; fixing it is a + separate user-visible change. +* `TRANSPARENCY_ENABLED` is written by the load (`transparency`) and by the toggle command, so it is a + setting, not session state (corrected from the first draft of this document's table). +* `komorebi-config` warns about an unused import (`crate::defaults`, `static_config.rs:39`) when it is + built with `default-features = false`; the settings crate builds it that way. Not touched. +* Windows `reload` runs `apply_globals` twice per reload: in `preload` of the new window manager + (while the old one is paused) and again in `reload`; both writes become visible to the threads that + read without the wm lock. One `update` per load keeps the two visible publishes. + +## 8. S1 and S2 as landed for KDE and macOS, and the Windows patch (not applied) + +KDE and macOS (`wip-u-settings`): S1 (the `DEFAULT_*` statics take their values from +`komorebi_settings::shared`, paddings from `PlatformDefaults::{KDE, MACOS}`; the statics keep their +names and types) and S2 (`src/settings.rs` with `KdeSettings` / `MacosSettings` and `settings()`; +a test pins that the starting settings equal the starting values of the border and default +statics). The new crate is a path dependency of both workspaces (manifest, package manifest, +`Cargo.lock` +1 package). S3 was **not** started: it moves the writers (`apply_globals`) and the +readers in `monitor.rs`, `workspace.rs`, `window.rs`, `process_command.rs`, which the KDE and macOS +stacks are editing, and the load would publish once instead of storing key by key, which is the tear +removal marked BEHAVIOUR in section 5. It needs the rebase onto the merged stacks and its gate. + +Windows S1/S2, to apply when told (all in the files kfw is changing, so not done): + +1. `komorebi/Cargo.toml`: `komorebi-settings = { path = "../crates/komorebi-settings" }` (the crate is + already a root workspace member; `Cargo.lock` gets the dependency edge, no new package). +2. `komorebi/src/lib.rs`: `DEFAULT_WORKSPACE_PADDING` / `DEFAULT_CONTAINER_PADDING` start from + `PlatformDefaults::WINDOWS.workspace_padding` / `.container_padding` (10, 10); + `DEFAULT_RESIZE_DELTA` and `DEFAULT_MOUSE_FOLLOWS_FOCUS` are `komorebi_settings::shared::...` + (50, true); add `pub mod settings;`. +3. New `komorebi/src/settings.rs`, a copy of the KDE one with `WindowsSettings` (empty) and + `PlatformDefaults::WINDOWS`; its pin test compares the paddings, resize delta, mouse-follows-focus, + `BORDER_ENABLED`, `BORDER_WIDTH` (8), `BORDER_OFFSET` (-1) and the six colours, and leaves out + `border.radius` (Windows has no `BORDER_RADIUS`). +4. Gate: Windows clippy (`-D warnings`) and `cargo test -p komorebi --lib`; `tools/e2e-needed` will + say YES (the crate is in the closure of `komorebi.exe`) so one e2e run is owed, but behaviour is + unchanged. diff --git a/komorebi-bar/src/bar.rs b/komorebi-bar/src/bar.rs index f113e3e2..c54125db 100644 --- a/komorebi-bar/src/bar.rs +++ b/komorebi-bar/src/bar.rs @@ -23,6 +23,7 @@ use crate::widgets::komorebi::Komorebi; use crate::widgets::komorebi::MonitorInfo; use crate::widgets::widget::BarWidget; use crate::widgets::widget::WidgetConfig; +use crate::widgets::widget::WidgetConfigExt; use color_eyre::eyre; use crossbeam_channel::Receiver; use crossbeam_channel::TryRecvError; diff --git a/komorebi-bar/src/config.rs b/komorebi-bar/src/config.rs index 637f6292..0702a6d7 100644 --- a/komorebi-bar/src/config.rs +++ b/komorebi-bar/src/config.rs @@ -1,590 +1 @@ -use crate::AUTO_SELECT_FILL_COLOUR; -use crate::AUTO_SELECT_TEXT_COLOUR; -use crate::DEFAULT_PADDING; -use crate::bar::exec_powershell; -use crate::render::Grouping; -use crate::render::RenderConfig; -use crate::render::RenderExt; -use crate::widgets::widget::WidgetConfig; -use eframe::egui::Color32; -use eframe::egui::Context; -use eframe::egui::FontId; -use eframe::egui::TextBuffer; -use eframe::egui::TextStyle; -use komorebi_client::PathExt; -use komorebi_client::Rect; -use komorebi_client::SocketMessage; -use komorebi_themes::colour::Colour; -use komorebi_themes::colour::Rgb; -use serde::Deserialize; -use serde::Serialize; -use std::collections::HashMap; -use std::num::NonZeroU32; -use std::path::PathBuf; -use std::sync::atomic::Ordering; - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// The `komorebi.bar.json` configuration file reference for `v0.1.42` -pub struct KomobarConfig { - /// Bar height - #[cfg_attr(feature = "schemars", schemars(extend("default" = 50)))] - pub height: Option, - /// Bar padding. Use one value for all sides or use a grouped padding for horizontal and/or - /// vertical definition which can each take a single value for a symmetric padding or two - /// values for each side, i.e.: - /// ```json - /// "padding": { - /// "horizontal": 10 - /// } - /// ``` - /// or: - /// ```json - /// "padding": { - /// "horizontal": [left, right] - /// } - /// ``` - /// You can also set individual padding on each side like this: - /// ```json - /// "padding": { - /// "top": 10, - /// "bottom": 10, - /// "left": 10, - /// "right": 10, - /// } - /// ``` - /// By default, padding is set to 10 on all sides. - pub padding: Option, - /// Bar margin. Use one value for all sides or use a grouped margin for horizontal and/or - /// vertical definition which can each take a single value for a symmetric margin or two - /// values for each side, i.e.: - /// ```json - /// "margin": { - /// "horizontal": 10 - /// } - /// ``` - /// or: - /// ```json - /// "margin": { - /// "vertical": [top, bottom] - /// } - /// ``` - /// You can also set individual margin on each side like this: - /// ```json - /// "margin": { - /// "top": 10, - /// "bottom": 10, - /// "left": 10, - /// "right": 10, - /// } - /// ``` - /// By default, margin is set to 0 on all sides. - pub margin: Option, - /// Bar positioning options - #[serde(alias = "viewport")] - pub position: Option, - /// Frame options (see: https://docs.rs/egui/latest/egui/containers/frame/struct.Frame.html) - pub frame: Option, - /// The monitor index or the full monitor options - #[cfg_attr(feature = "schemars", schemars(extend("default" = MonitorConfigOrIndex::Index(0))))] - pub monitor: Option, - /// Font family - pub font_family: Option, - /// Font size - #[cfg_attr(feature = "schemars", schemars(extend("default" = 12.5)))] - pub font_size: Option, - /// Scale of the icons relative to the font_size [[1.0-2.0]] - #[cfg_attr(feature = "schemars", schemars(extend("default" = 1.4)))] - pub icon_scale: Option, - /// Max label width before text truncation - #[cfg_attr(feature = "schemars", schemars(extend("default" = 400.0)))] - pub max_label_width: Option, - /// Theme - pub theme: Option, - /// Alpha value for the color transparency [[0-255]] - #[cfg_attr(feature = "schemars", schemars(extend("default" = 200)))] - pub transparency_alpha: Option, - /// Spacing between widgets - #[cfg_attr(feature = "schemars", schemars(extend("default" = 10.0)))] - pub widget_spacing: Option, - /// Visual grouping for widgets - pub grouping: Option, - /// Options for mouse interaction on the bar - pub mouse: Option, - /// Left side widgets (ordered left-to-right) - pub left_widgets: Vec, - /// Center widgets (ordered left-to-right) - pub center_widgets: Option>, - /// Right side widgets (ordered left-to-right) - pub right_widgets: Vec, -} - -impl KomobarConfig { - pub fn aliases(raw: &str) { - let mut map = HashMap::new(); - map.insert("position", ["viewport"]); - map.insert("end", ["inner_frame"]); - - let mut display = false; - - for aliases in map.values() { - for a in aliases { - if raw.contains(a) { - display = true; - } - } - } - - if display { - println!( - "\nYour bar configuration file contains some options that have been renamed or deprecated:\n" - ); - for (canonical, aliases) in map { - for alias in aliases { - if raw.contains(alias) { - println!(r#""{alias}" is now "{canonical}""#); - } - } - } - } - } - - pub fn show_all_icons_on_komorebi_workspace(widgets: &[WidgetConfig]) -> bool { - widgets - .iter() - .any(|w| matches!(w, WidgetConfig::Komorebi(config) if config.workspaces.is_some_and(|w| w.enable && w.display.is_some_and(|s| matches!(s, - WorkspacesDisplayFormat::AllIcons - | WorkspacesDisplayFormat::AllIconsAndText - | WorkspacesDisplayFormat::AllIconsAndTextOnSelected))))) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Position configuration -pub struct PositionConfig { - /// The desired starting position of the bar (0,0 = top left of the screen) - #[serde(alias = "position")] - pub start: Option, - /// The desired size of the bar from the starting position (usually monitor width x desired height) - #[serde(alias = "inner_size")] - pub end: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Frame configuration -pub struct FrameConfig { - /// Margin inside the painted frame - pub inner_margin: Position, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(untagged)] -/// Monitor configuration or monitor index -pub enum MonitorConfigOrIndex { - /// The monitor index where you want the bar to show - Index(usize), - /// The full monitor options with the index and an optional work_area_offset - MonitorConfig(MonitorConfig), -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Monitor configuration -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, -} - -pub type Padding = SpacingKind; -pub type Margin = SpacingKind; - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(untagged)] -// WARNING: To any developer messing with this code in the future: The order here matters! -// `Grouped` needs to come last, otherwise serde might mistaken an `IndividualSpacingConfig` for a -// `GroupedSpacingConfig` with both `vertical` and `horizontal` set to `None` ignoring the -// individual values. -/// Spacing kind -pub enum SpacingKind { - /// Spacing applied to all sides - All(f32), - /// Individual spacing applied to each side - Individual(IndividualSpacingConfig), - /// Grouped vertical and horizontal spacing - Grouped(GroupedSpacingConfig), -} - -impl SpacingKind { - pub fn to_individual(&self, default: f32) -> IndividualSpacingConfig { - match self { - SpacingKind::All(m) => IndividualSpacingConfig::all(*m), - SpacingKind::Grouped(grouped_spacing_config) => { - let vm = grouped_spacing_config.vertical.as_ref().map_or( - IndividualSpacingConfig::vertical(default), - |vm| match vm { - GroupedSpacingOptions::Symmetrical(m) => { - IndividualSpacingConfig::vertical(*m) - } - GroupedSpacingOptions::Split(tm, bm) => { - IndividualSpacingConfig::vertical(*tm).bottom(*bm) - } - }, - ); - let hm = grouped_spacing_config.horizontal.as_ref().map_or( - IndividualSpacingConfig::horizontal(default), - |hm| match hm { - GroupedSpacingOptions::Symmetrical(m) => { - IndividualSpacingConfig::horizontal(*m) - } - GroupedSpacingOptions::Split(lm, rm) => { - IndividualSpacingConfig::horizontal(*lm).right(*rm) - } - }, - ); - IndividualSpacingConfig { - top: vm.top, - bottom: vm.bottom, - left: hm.left, - right: hm.right, - } - } - SpacingKind::Individual(m) => *m, - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Grouped vertical and horizontal spacing -pub struct GroupedSpacingConfig { - /// Vertical grouped spacing - pub vertical: Option, - /// Horizontal grouped spacing - pub horizontal: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(untagged)] -/// Grouped spacing options -pub enum GroupedSpacingOptions { - /// Symmetrical grouped spacing - Symmetrical(f32), - /// Split grouped spacing - Split(f32, f32), -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Individual spacing configuration -pub struct IndividualSpacingConfig { - /// Spacing for the top - pub top: f32, - /// Spacing for the bottom - pub bottom: f32, - /// Spacing for the left - pub left: f32, - /// Spacing for the right - pub right: f32, -} - -#[allow(dead_code)] -impl IndividualSpacingConfig { - pub const ZERO: Self = IndividualSpacingConfig { - top: 0.0, - bottom: 0.0, - left: 0.0, - right: 0.0, - }; - - pub fn all(value: f32) -> Self { - IndividualSpacingConfig { - top: value, - bottom: value, - left: value, - right: value, - } - } - - pub fn horizontal(value: f32) -> Self { - IndividualSpacingConfig { - top: 0.0, - bottom: 0.0, - left: value, - right: value, - } - } - - pub fn vertical(value: f32) -> Self { - IndividualSpacingConfig { - top: value, - bottom: value, - left: 0.0, - right: 0.0, - } - } - - pub fn top(self, value: f32) -> Self { - IndividualSpacingConfig { top: value, ..self } - } - - pub fn bottom(self, value: f32) -> Self { - IndividualSpacingConfig { - bottom: value, - ..self - } - } - - pub fn left(self, value: f32) -> Self { - IndividualSpacingConfig { - left: value, - ..self - } - } - - pub fn right(self, value: f32) -> Self { - IndividualSpacingConfig { - right: value, - ..self - } - } -} - -pub fn get_individual_spacing( - default: f32, - spacing: &Option, -) -> IndividualSpacingConfig { - spacing - .as_ref() - .map_or(IndividualSpacingConfig::all(default), |s| { - s.to_individual(default) - }) -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(untagged)] -/// Mouse message -pub enum MouseMessage { - /// Send a message to the komorebi client. - /// By default, a batch of messages are sent in the following order: - /// FocusMonitorAtCursor => - /// MouseFollowsFocus(false) => - /// {message} => - /// MouseFollowsFocus({original.value}) - /// - /// Example: - /// ```json - /// "on_extra2_click": { - /// "message": { - /// "type": "NewWorkspace" - /// } - /// }, - /// ``` - /// or: - /// ```json - /// "on_middle_click": { - /// "focus_monitor_at_cursor": false, - /// "ignore_mouse_follows_focus": false, - /// "message": { - /// "type": "TogglePause" - /// } - /// } - /// ``` - /// or: - /// ```json - /// "on_scroll_up": { - /// "message": { - /// "type": "CycleFocusWorkspace", - /// "content": "Previous" - /// } - /// } - /// ``` - Komorebi(KomorebiMouseMessage), - /// Execute a custom command. - /// CMD (%variable%), Bash ($variable) and PowerShell ($Env:variable) variables will be resolved. - /// Example: `komorebic toggle-pause` - Command(String), -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Komorebi socket mouse message -pub struct KomorebiMouseMessage { - /// Send the FocusMonitorAtCursor message - #[cfg_attr(feature = "schemars", schemars(extend("default" = true)))] - pub focus_monitor_at_cursor: Option, - /// Wrap the {message} with a MouseFollowsFocus(false) and MouseFollowsFocus({original.value}) message - #[cfg_attr(feature = "schemars", schemars(extend("default" = true)))] - pub ignore_mouse_follows_focus: Option, - /// The message to send to the komorebi client - pub message: komorebi_client::SocketMessage, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Mouse configuration -pub struct MouseConfig { - /// Command to send on primary/left double button click - pub on_primary_double_click: Option, - /// Command to send on secondary/right button click - pub on_secondary_click: Option, - /// Command to send on middle button click - pub on_middle_click: Option, - /// Command to send on extra1/back button click - pub on_extra1_click: Option, - /// Command to send on extra2/forward button click - pub on_extra2_click: Option, - - /// Defines how many points a user needs to scroll vertically to make a "tick" on a mouse/touchpad/touchscreen - #[cfg_attr(feature = "schemars", schemars(extend("default" = 30.0)))] - pub vertical_scroll_threshold: Option, - /// Command to send on scrolling up (every tick) - pub on_scroll_up: Option, - /// Command to send on scrolling down (every tick) - pub on_scroll_down: Option, - - /// Defines how many points a user needs to scroll horizontally to make a "tick" on a mouse/touchpad/touchscreen - #[cfg_attr(feature = "schemars", schemars(extend("default" = 30.0)))] - pub horizontal_scroll_threshold: Option, - /// Command to send on scrolling left (every tick) - pub on_scroll_left: Option, - /// Command to send on scrolling right (every tick) - pub on_scroll_right: Option, -} - -impl MouseConfig { - pub fn has_command(&self) -> bool { - [ - &self.on_primary_double_click, - &self.on_secondary_click, - &self.on_middle_click, - &self.on_extra1_click, - &self.on_extra2_click, - &self.on_scroll_up, - &self.on_scroll_down, - &self.on_scroll_left, - &self.on_scroll_right, - ] - .iter() - .any(|opt| matches!(opt, Some(MouseMessage::Command(_)))) - } -} - -impl MouseMessage { - pub fn execute(&self, mouse_follows_focus: bool) { - match self { - MouseMessage::Komorebi(config) => { - let mut messages = Vec::new(); - - if config.focus_monitor_at_cursor.unwrap_or(true) { - messages.push(SocketMessage::FocusMonitorAtCursor); - } - - if config.ignore_mouse_follows_focus.unwrap_or(true) { - messages.push(SocketMessage::MouseFollowsFocus(false)); - messages.push(config.message.clone()); - messages.push(SocketMessage::MouseFollowsFocus(mouse_follows_focus)); - } else { - messages.push(config.message.clone()); - } - - tracing::debug!("Sending messages: {messages:?}"); - - if komorebi_client::send_batch(messages).is_err() { - tracing::error!("could not send commands"); - } - } - MouseMessage::Command(cmd) => { - tracing::debug!("Executing command: {}", cmd); - - let cmd_no_env = cmd.replace_env(); - - if exec_powershell(cmd_no_env.to_str().expect("Invalid command")).is_err() { - tracing::error!("Failed to execute '{}'", cmd); - } - } - }; - } -} - -impl KomobarConfig { - pub fn read(path: &PathBuf) -> color_eyre::Result { - let content = std::fs::read_to_string(path)?; - let mut value: Self = match path.extension().unwrap().to_string_lossy().as_str() { - "json" => serde_json::from_str(&content)?, - _ => panic!("unsupported format"), - }; - - if value.frame.is_none() { - value.frame = Some(FrameConfig { - inner_margin: Position { - x: DEFAULT_PADDING, - y: DEFAULT_PADDING, - }, - }); - } - - Ok(value) - } -} - -impl RenderExt for &KomobarConfig { - fn new_renderconfig( - &self, - ctx: &Context, - background_color: Color32, - icon_scale: Option, - ) -> RenderConfig { - let text_font_id = ctx - .style() - .text_styles - .get(&TextStyle::Body) - .cloned() - .unwrap_or_else(FontId::default); - - let mut icon_font_id = text_font_id.clone(); - icon_font_id.size *= icon_scale.unwrap_or(1.4).clamp(1.0, 2.0); - - let monitor_idx = match &self.monitor { - Some(MonitorConfigOrIndex::MonitorConfig(monitor_config)) => monitor_config.index, - Some(MonitorConfigOrIndex::Index(idx)) => *idx, - None => 0, - }; - - // check if any of the alignments have a komorebi widget with the workspace set to show all icons - let show_all_icons = - KomobarConfig::show_all_icons_on_komorebi_workspace(&self.left_widgets) - || self - .center_widgets - .as_ref() - .is_some_and(|list| KomobarConfig::show_all_icons_on_komorebi_workspace(list)) - || KomobarConfig::show_all_icons_on_komorebi_workspace(&self.right_widgets); - - RenderConfig { - monitor_idx, - spacing: self.widget_spacing.unwrap_or(10.0), - grouping: self.grouping.unwrap_or(Grouping::None), - background_color, - alignment: None, - more_inner_margin: false, - applied_on_widget: false, - text_font_id, - icon_font_id, - show_all_icons, - auto_select_fill: NonZeroU32::new(AUTO_SELECT_FILL_COLOUR.load(Ordering::SeqCst)) - .map(|c| Colour::Rgb(Rgb::from(c.get())).into()), - auto_select_text: NonZeroU32::new(AUTO_SELECT_TEXT_COLOUR.load(Ordering::SeqCst)) - .map(|c| Colour::Rgb(Rgb::from(c.get())).into()), - } - } -} - -pub use komorebi_bar_core::config::types::*; -pub use komorebi_themes::KomobarTheme; +pub use komorebi_bar_core::config::*; diff --git a/komorebi-bar/src/main.rs b/komorebi-bar/src/main.rs index 89b25c77..b76e22a3 100644 --- a/komorebi-bar/src/main.rs +++ b/komorebi-bar/src/main.rs @@ -106,10 +106,12 @@ fn main() -> color_eyre::Result<()> { #[cfg(feature = "schemars")] if opts.schema { - let bar_config = schemars::schema_for!(KomobarConfig); - let schema = serde_json::to_string_pretty(&bar_config)?; - - println!("{schema}"); + print!( + "{}", + komorebi_bar_core::schema::generated_text::( + komorebi_bar_core::schema::SchemaPlatform::Windows + ) + ); std::process::exit(0); } diff --git a/komorebi-bar/src/os.rs b/komorebi-bar/src/os.rs index 2fca94c4..1dcffa38 100644 --- a/komorebi-bar/src/os.rs +++ b/komorebi-bar/src/os.rs @@ -1,8 +1,13 @@ +use crate::widgets::ImageIcon; use egui_phosphor::regular::CALENDAR_DOTS; use komorebi_bar_core::platform::BarPlatform; use komorebi_bar_core::platform::SystemTool; +use std::borrow::Cow; +use komorebi_client::SocketMessage; use std::io; +use std::path::Path; use std::process::Command; +use which::which; pub struct WindowsBar; @@ -15,6 +20,13 @@ impl BarPlatform for WindowsBar { .spawn(), SystemTool::NetworkSettings => Command::new("cmd.exe").args(["/C", "ncpa"]).spawn(), SystemTool::Url(url) => Command::new("explorer.exe").args([url]).spawn(), + SystemTool::StorageLocation(label) => Command::new("cmd.exe") + .args([ + "/C", + "explorer.exe", + label.split(' ').collect::>()[0], + ]) + .spawn(), } .map(|_| ()) } @@ -26,4 +38,46 @@ impl BarPlatform for WindowsBar { fn calendar_icon(&self) -> &'static str { CALENDAR_DOTS } + + fn exec_shell_command(&self, command: &str) -> color_eyre::Result<()> { + crate::bar::exec_powershell(command) + } + + fn send_komorebi_message(&self, message: &SocketMessage) -> io::Result<()> { + komorebi_client::send_message(message) + } + + fn send_komorebi_batch(&self, messages: Vec) -> io::Result<()> { + komorebi_client::send_batch(messages) + } + + fn command_icon(&self, command: &str) -> Option { + let path = executable_of(command)?; + ImageIcon::try_load(path.as_ref(), || { + let path_str = path.to_str()?; + windows_icons::get_icon_by_path(path_str) + .or_else(|| windows_icons_fallback::get_icon_by_path(path_str)) + }) + } +} + +/// Attempts to resolve the executable path from the command string. +/// +/// Resolution logic: +/// - Splits the command by ".exe" and checks if the first part is an existing file. +/// - If not, attempts to locate the binary using [`which`] on this name. +/// - If still unresolved, takes the first word (separated by whitespace) and attempts +/// to find it in the system `PATH` using [`which`]. +/// +/// Returns `None` if no executable path can be determined. +fn executable_of(command: &str) -> Option> { + if let Some(binary) = command.split(".exe").next().map(Path::new) { + if binary.is_file() { + return Some(Cow::Borrowed(binary)); + } else if let Ok(binary) = which(binary) { + return Some(Cow::Owned(binary)); + } + } + + which(command.split(' ').next()?).ok().map(Cow::Owned) } diff --git a/komorebi-bar/src/widgets/keyboard.rs b/komorebi-bar/src/widgets/keyboard.rs index 5fe6d0a7..ee27da88 100644 --- a/komorebi-bar/src/widgets/keyboard.rs +++ b/komorebi-bar/src/widgets/keyboard.rs @@ -1,4 +1,5 @@ use crate::config::LabelPrefix; +pub use komorebi_bar_core::widgets::platform_config::KeyboardConfig; use crate::render::RenderConfig; use crate::widgets::widget::BarWidget; use color_eyre::eyre; @@ -9,8 +10,6 @@ use eframe::egui::TextFormat; use eframe::egui::Ui; use eframe::egui::WidgetText; use eframe::egui::text::LayoutJob; -use serde::Deserialize; -use serde::Serialize; use std::sync::Arc; use std::time::Duration; use std::time::Instant; @@ -23,19 +22,6 @@ use windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId; const ERROR_TEXT: &str = "Error"; -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Keyboard widget configuration -pub struct KeyboardConfig { - /// Enable the Input widget - pub enable: bool, - /// Data refresh interval - #[cfg_attr(feature = "schemars", schemars(extend("default" = 10)))] - pub data_refresh_interval: Option, - /// Display label prefix - pub label_prefix: Option, -} - impl From for Keyboard { fn from(value: KeyboardConfig) -> Self { let data_refresh_interval = value.data_refresh_interval.unwrap_or(10); diff --git a/komorebi-bar/src/widgets/komorebi.rs b/komorebi-bar/src/widgets/komorebi.rs index a9a3a1f1..1dba3200 100644 --- a/komorebi-bar/src/widgets/komorebi.rs +++ b/komorebi-bar/src/widgets/komorebi.rs @@ -7,7 +7,7 @@ use crate::config::WorkspacesDisplayFormat; use crate::render::RenderConfig; use crate::selected_frame::SelectableFrame; use crate::ui::CustomUi; -use crate::widgets::komorebi_layout::KomorebiLayout; +use crate::widgets::komorebi_layout::KomorebiLayoutExt; use crate::widgets::widget::BarWidget; use eframe::egui::Align; use eframe::egui::Color32; @@ -28,6 +28,14 @@ use eframe::egui::Ui; use eframe::egui::Vec2; use eframe::egui::text::LayoutJob; use eframe::egui::vec2; +pub use komorebi_bar_core::widgets::komorebi_config::KomorebiConfig; +pub use komorebi_bar_core::widgets::komorebi_config::KomorebiConfigurationSwitcherConfig; +pub use komorebi_bar_core::widgets::komorebi_config::KomorebiFocusedContainerConfig; +pub use komorebi_bar_core::widgets::komorebi_config::KomorebiLayout; +pub use komorebi_bar_core::widgets::komorebi_config::KomorebiLayoutConfig; +pub use komorebi_bar_core::widgets::komorebi_config::KomorebiLockedContainerConfig; +pub use komorebi_bar_core::widgets::komorebi_config::KomorebiWorkspaceLayerConfig; +pub use komorebi_bar_core::widgets::komorebi_config::KomorebiWorkspacesConfig; use komorebi_client::Container; use komorebi_client::PathExt; use komorebi_client::Rect; @@ -37,106 +45,13 @@ use komorebi_client::State; use komorebi_client::Window; use komorebi_client::Workspace; use komorebi_client::WorkspaceLayer; -use serde::Deserialize; -use serde::Serialize; use std::cell::RefCell; -use std::collections::BTreeMap; use std::collections::HashMap; use std::io::Result as IoResult; use std::path::Path; use std::rc::Rc; use std::sync::atomic::Ordering; -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Komorebi widget configuration -pub struct KomorebiConfig { - /// Configure the Workspaces widget - pub workspaces: Option, - /// Configure the Layout widget - pub layout: Option, - /// Configure the Workspace Layer widget - pub workspace_layer: Option, - /// Configure the Focused Container widget - #[serde(alias = "focused_window")] - pub focused_container: Option, - /// Configure the Locked Container widget - pub locked_container: Option, - /// Configure the Configuration Switcher widget - pub configuration_switcher: Option, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Komorebi widget workspaces configuration -pub struct KomorebiWorkspacesConfig { - /// Enable the Komorebi Workspaces widget - pub enable: bool, - /// Hide workspaces without any windows - pub hide_empty_workspaces: bool, - /// Display format of the workspace - pub display: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Komorebi widget layout configuration -pub struct KomorebiLayoutConfig { - /// Enable the Komorebi Layout widget - pub enable: bool, - /// List of layout options - pub options: Option>, - /// Display format of the current layout - pub display: Option, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Komorebi widget workspace layer configuration -pub struct KomorebiWorkspaceLayerConfig { - /// Enable the Komorebi Workspace Layer widget - pub enable: bool, - /// Display format of the current layer - pub display: Option, - /// Show the widget event if the layer is Tiling - pub show_when_tiling: Option, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Komorebi widget focused container configuration -pub struct KomorebiFocusedContainerConfig { - /// Enable the Komorebi Focused Container widget - pub enable: bool, - /// DEPRECATED: use `display` instead (Show the icon of the currently focused container) - #[deprecated(note = "Use `display` instead")] - pub show_icon: Option, - /// Display format of the currently focused container - pub display: Option, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Komorebi widget locked container configuration -pub struct KomorebiLockedContainerConfig { - /// Enable the Komorebi Locked Container widget - pub enable: bool, - /// Display format of the current locked state - pub display: Option, - /// Show the widget event if the layer is unlocked - pub show_when_unlocked: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Komorebi widget configuration switcher configuration -pub struct KomorebiConfigurationSwitcherConfig { - /// Enable the Komorebi Configurations widget - pub enable: bool, - /// A map of display friendly name => path to configuration.json - pub configurations: BTreeMap, -} - impl From<&KomorebiConfig> for Komorebi { fn from(cfg: &KomorebiConfig) -> Self { let configuration_switcher = cfg.configuration_switcher.clone().map(|mut cs| { diff --git a/komorebi-bar/src/widgets/media.rs b/komorebi-bar/src/widgets/media.rs index e0155fe0..5c371b61 100644 --- a/komorebi-bar/src/widgets/media.rs +++ b/komorebi-bar/src/widgets/media.rs @@ -1,6 +1,7 @@ use crate::MAX_LABEL_WIDTH; use crate::bar::Alignment; use crate::config::MediaDisplayFormat; +pub use komorebi_bar_core::widgets::platform_config::MediaConfig; use crate::render::RenderConfig; use crate::selected_frame::SelectableFrame; use crate::ui::CustomUi; @@ -12,22 +13,10 @@ use eframe::egui::TextFormat; use eframe::egui::Ui; use eframe::egui::Vec2; use eframe::egui::text::LayoutJob; -use serde::Deserialize; -use serde::Serialize; use std::sync::atomic::Ordering; use windows::Media::Control::GlobalSystemMediaTransportControlsSessionManager; use windows::Media::Control::GlobalSystemMediaTransportControlsSessionPlaybackStatus; -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Media widget configuration -pub struct MediaConfig { - /// Enable the Media widget - pub enable: bool, - /// Display format of the media widget (defaults to IconAndText) - pub display: Option, -} - impl From for Media { fn from(value: MediaConfig) -> Self { Self::new( diff --git a/komorebi-bar/src/widgets/mod.rs b/komorebi-bar/src/widgets/mod.rs index 02250e4f..a8306d3d 100644 --- a/komorebi-bar/src/widgets/mod.rs +++ b/komorebi-bar/src/widgets/mod.rs @@ -1,183 +1,22 @@ -use eframe::egui::ColorImage; -use eframe::egui::Context; -use eframe::egui::TextureHandle; -use eframe::egui::TextureOptions; -use image::RgbaImage; -use std::collections::HashMap; -use std::path::Path; -use std::sync::Arc; -use std::sync::LazyLock; -use std::sync::RwLock; -pub mod applications; +pub use komorebi_bar_core::widgets::applications; pub use komorebi_bar_core::widgets::battery; pub use komorebi_bar_core::widgets::cpu; pub use komorebi_bar_core::widgets::date; pub mod keyboard; pub mod komorebi; -mod komorebi_layout; +pub use komorebi_bar_core::widgets::komorebi_layout; pub mod media; pub use komorebi_bar_core::widgets::memory; pub mod network; -pub mod storage; +pub use komorebi_bar_core::widgets::storage; #[cfg(target_os = "windows")] pub mod systray; -pub mod time; +pub use komorebi_bar_core::widgets::time; pub use komorebi_bar_core::widgets::update; pub mod widget; -/// Global cache for icon images and their associated GPU textures. -pub static ICONS_CACHE: IconsCache = IconsCache::new(); - -/// In-memory cache for icon images and their associated GPU textures. -/// -/// Stores raw [`ColorImage`]s and [`TextureHandle`]s keyed by [`ImageIconId`]. -/// Texture entries are context-dependent and automatically invalidated when the [`Context`] changes. -#[allow(clippy::type_complexity)] -pub struct IconsCache { - textures: LazyLock, HashMap)>>, - images: LazyLock>>>, -} - -impl IconsCache { - /// Creates a new empty IconsCache instance. - #[inline] - pub const fn new() -> Self { - Self { - textures: LazyLock::new(|| RwLock::new((None, HashMap::new()))), - images: LazyLock::new(|| RwLock::new(HashMap::new())), - } - } - - /// Retrieves or creates a texture handle for the given icon ID and image. - /// - /// If a texture for the given ID already exists for the current [`Context`], it is reused. - /// Otherwise, a new texture is created, inserted into the cache, and returned. - /// The cache is reset if the [`Context`] has changed. - #[inline] - pub fn texture(&self, ctx: &Context, id: &ImageIconId, img: &Arc) -> TextureHandle { - if let Some(texture) = self.get_texture(ctx, id) { - return texture; - } - let texture_handle = ctx.load_texture("icon", img.clone(), TextureOptions::default()); - self.insert_texture(ctx, id.clone(), texture_handle.clone()); - texture_handle - } - - /// Returns the cached texture for the given icon ID if it exists and matches the current [`Context`]. - pub fn get_texture(&self, ctx: &Context, id: &ImageIconId) -> Option { - let textures_lock = self.textures.read().unwrap(); - if textures_lock.0.as_ref() == Some(ctx) { - return textures_lock.1.get(id).cloned(); - } - None - } - - /// Inserts a texture handle, resetting the cache if the [`Context`] has changed. - pub fn insert_texture(&self, ctx: &Context, id: ImageIconId, texture: TextureHandle) { - let mut textures_lock = self.textures.write().unwrap(); - - if textures_lock.0.as_ref() != Some(ctx) { - textures_lock.0 = Some(ctx.clone()); - textures_lock.1.clear(); - } - - textures_lock.1.insert(id, texture); - } - - /// Returns the cached image for the given icon ID, if available. - pub fn get_image(&self, id: &ImageIconId) -> Option> { - self.images.read().unwrap().get(id).cloned() - } - - /// Caches a raw [`ColorImage`] associated with the given icon ID. - pub fn insert_image(&self, id: ImageIconId, image: Arc) { - self.images.write().unwrap().insert(id, image); - } - - /// Removes the cached image and texture for the given icon ID. - pub fn remove(&self, id: &ImageIconId) { - self.images.write().unwrap().remove(id); - self.textures.write().unwrap().1.remove(id); - } -} - -#[inline] -pub(crate) fn rgba_to_color_image(rgba_image: &RgbaImage) -> ColorImage { - let size = [rgba_image.width() as usize, rgba_image.height() as usize]; - let pixels = rgba_image.as_flat_samples(); - ColorImage::from_rgba_unmultiplied(size, pixels.as_slice()) -} - -/// Represents an image-based icon with a unique ID and pixel data. -#[derive(Clone, Debug)] -pub struct ImageIcon { - /// Unique identifier for the image icon, used for texture caching. - pub id: ImageIconId, - /// Shared pixel data of the icon in `ColorImage` format. - pub image: Arc, -} - -impl ImageIcon { - /// Creates a new [`ImageIcon`] from the given ID and image data. - #[inline] - pub fn new(id: ImageIconId, image: Arc) -> Self { - Self { id, image } - } - - /// Loads an [`ImageIcon`] from [`ICONS_CACHE`] or calls `loader` if not cached. - /// The loaded image is converted to a [`ColorImage`], cached, and returned. - #[inline] - pub fn try_load(id: impl Into, loader: F) -> Option - where - F: FnOnce() -> Option, - I: Into, - { - let id = id.into(); - let image = ICONS_CACHE.get_image(&id).or_else(|| { - let img = loader()?; - let img = Arc::new(rgba_to_color_image(&img.into())); - ICONS_CACHE.insert_image(id.clone(), img.clone()); - Some(img) - })?; - - Some(ImageIcon::new(id, image)) - } - - /// Returns a texture handle for the icon, using the given [`Context`]. - /// - /// If the texture is already cached in [`ICONS_CACHE`], it is reused. - /// Otherwise, a new texture is created from the [`ColorImage`] and cached. - #[inline] - pub fn texture(&self, ctx: &Context) -> TextureHandle { - ICONS_CACHE.texture(ctx, &self.id, &self.image) - } -} - -/// Unique identifier for an image-based icon. -/// -/// Used to distinguish cached images and textures by either a file path -/// or a Windows window handle. -#[derive(Clone, Debug, Eq, Hash, PartialEq)] -pub enum ImageIconId { - /// Identifier based on a file system path. - Path(Arc), - /// Windows HWND handle. - Hwnd(isize), - /// System tray icon identifier. - SystrayIcon(String), -} - -impl From<&Path> for ImageIconId { - #[inline] - fn from(value: &Path) -> Self { - Self::Path(value.into()) - } -} - -impl From for ImageIconId { - #[inline] - fn from(value: isize) -> Self { - Self::Hwnd(value) - } -} +pub use komorebi_bar_core::widgets::icons::ICONS_CACHE; +pub use komorebi_bar_core::widgets::icons::ImageIcon; +pub use komorebi_bar_core::widgets::icons::ImageIconId; +pub use komorebi_bar_core::widgets::icons::rgba_to_color_image; diff --git a/komorebi-bar/src/widgets/network.rs b/komorebi-bar/src/widgets/network.rs index 740702ca..35dd3022 100644 --- a/komorebi-bar/src/widgets/network.rs +++ b/komorebi-bar/src/widgets/network.rs @@ -1,4 +1,8 @@ use crate::bar::Alignment; +pub use komorebi_bar_core::widgets::network_config::DefaultInterfaceConfig; +pub use komorebi_bar_core::widgets::network_config::InterfaceDisplayType; +pub use komorebi_bar_core::widgets::network_config::NetworkConfig; +pub use komorebi_bar_core::widgets::network_config::NetworkSelectConfig; use crate::config::LabelPrefix; use crate::render::RenderConfig; use crate::selected_frame::SelectableFrame; @@ -11,8 +15,6 @@ use eframe::egui::TextFormat; use eframe::egui::Ui; use eframe::egui::text::LayoutJob; use num_derive::FromPrimitive; -use serde::Deserialize; -use serde::Serialize; use std::fmt; use std::process::Command; use std::sync::Arc; @@ -24,66 +26,6 @@ use std::time::Duration; use std::time::Instant; use sysinfo::Networks; -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Network widget configuration -pub struct NetworkConfig { - /// Enable the Network widget - pub enable: bool, - /// Show total received and transmitted activity - #[serde(alias = "show_total_data_transmitted")] - pub show_total_activity: bool, - /// Show received and transmitted activity - #[serde(alias = "show_network_activity")] - pub show_activity: bool, - /// Show default interface - pub show_default_interface: Option, - /// Default interface options: whether to show the friendly name, IPv4 or IPv6 address - pub default_interface_options: Option, - /// Characters to reserve for received and transmitted activity - #[serde(alias = "network_activity_fill_characters")] - pub activity_left_padding: Option, - /// Data refresh interval in seconds - #[cfg_attr(feature = "schemars", schemars(extend("default" = 10)))] - pub data_refresh_interval: Option, - /// Display label prefix - pub label_prefix: Option, - /// Select when the value is over a limit (1MiB is 1048576 bytes (1024*1024)) - pub auto_select: Option, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Network select configuration -pub struct NetworkSelectConfig { - /// Select the total received data when it's over this value - pub total_received_over: Option, - /// Select the total transmitted data when it's over this value - pub total_transmitted_over: Option, - /// Select the received data when it's over this value - pub received_over: Option, - /// Select the transmitted data when it's over this value - pub transmitted_over: Option, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize, Default)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Which IP address to show for the default interface -pub enum InterfaceDisplayType { - #[default] - FriendlyName, - Ipv4, - Ipv6, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Default interface configuration -pub struct DefaultInterfaceConfig { - #[serde(default)] - pub display_type: InterfaceDisplayType, -} - impl From for Network { fn from(value: NetworkConfig) -> Self { let default_refresh_interval = 10; diff --git a/komorebi-bar/src/widgets/systray.rs b/komorebi-bar/src/widgets/systray.rs index 5d6f6367..230f2a5c 100644 --- a/komorebi-bar/src/widgets/systray.rs +++ b/komorebi-bar/src/widgets/systray.rs @@ -26,10 +26,13 @@ use eframe::egui::ViewportId; use eframe::egui::Window as EguiWindow; use egui_extras::Column; use egui_extras::TableBuilder; +pub use komorebi_bar_core::widgets::platform_config::ButtonPosition; +pub use komorebi_bar_core::widgets::platform_config::FieldMatch; +pub use komorebi_bar_core::widgets::platform_config::HiddenIconRule; +pub use komorebi_bar_core::widgets::platform_config::OverflowTogglePosition; +pub use komorebi_bar_core::widgets::platform_config::SystrayConfig; use komorebi_client::MatchingStrategy; use parking_lot::Mutex; -use serde::Deserialize; -use serde::Serialize; use std::collections::HashMap; use std::sync::Arc; use std::sync::LazyLock; @@ -61,105 +64,6 @@ static SHOW_HIDDEN_ICONS: AtomicBool = AtomicBool::new(false); /// Whether the systray info panel is currently shown static SHOW_SYSTRAY_INFO: AtomicBool = AtomicBool::new(false); -#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Position of the overflow toggle button -pub enum OverflowTogglePosition { - /// Toggle button appears on the left side (before visible icons) - Left, - /// Toggle button appears on the right side (after visible icons) - #[default] - Right, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Where to place a systray button (refresh, info, shortcuts, etc.) -pub enum ButtonPosition { - /// Show in the main visible area - Visible, - /// Show in the overflow/hidden section - Overflow, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(untagged)] -/// A field value with an optional matching strategy. -/// -/// A plain string uses exact (case-insensitive) matching. -/// An object with `value` and `matching_strategy` uses the specified strategy. -pub enum FieldMatch { - /// Exact case-insensitive match - Exact(String), - /// Match using a specific strategy - WithStrategy { - /// The value to match against - value: String, - /// How to match (Equals, StartsWith, EndsWith, Contains, Regex, etc.) - matching_strategy: MatchingStrategy, - }, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(untagged)] -/// Rule for matching a systray icon to hide -/// -/// A plain string matches the exe name (backward compatible). -/// An object with optional `exe`, `tooltip`, and/or `guid` fields -/// uses AND logic: all specified fields must match. -/// Each field can be a plain string (exact match) or an object with -/// `value` and `matching_strategy` for advanced matching. -pub enum HiddenIconRule { - /// Match by exe name (case-insensitive, exact) - Exe(String), - /// Match by one or more properties (all specified fields must match) - Match { - /// Exe name to match - exe: Option, - /// Tooltip text to match - tooltip: Option, - /// Icon GUID to match (most stable identifier across restarts) - guid: Option, - }, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// System tray widget configuration -pub struct SystrayConfig { - /// Enable the System Tray widget - pub enable: bool, - /// A list of rules for icons to hide from the system tray - /// - /// Each entry can be a plain string (matches exe name, case-insensitive) - /// or an object with optional `exe`, `tooltip`, and/or `guid` fields - /// (all specified fields must match, AND logic, case-insensitive). - /// - /// Run komorebi-bar with RUST_LOG=info to see the properties of all - /// systray icons in the log output. - pub hidden_icons: Option>, - /// Position of the overflow toggle button (default: Right) - pub overflow_toggle_position: Option, - /// Show an info button that opens a floating panel listing all systray icons - /// with their exe, tooltip, and GUID. Set to "Visible" to show it in the main - /// area, or "Overflow" to show it in the hidden/overflow section. - pub info_button: Option, - /// Interval in seconds to automatically check for and remove stale icons - /// whose owning process has exited. Clamped between 30 and 600 seconds. - /// Defaults to 60. Set to 0 to disable. - pub stale_icons_check_interval: Option, - /// Show a refresh button that manually triggers stale icon cleanup. - /// Set to "Visible" to show it in the main area, or "Overflow" to - /// show it in the hidden/overflow section. - pub refresh_button: Option, - /// Show a button that toggles komorebi-shortcuts (kills the process if - /// running, starts it otherwise). Set to "Visible" to show it in the main - /// area, or "Overflow" to show it in the hidden/overflow section. - pub shortcuts_button: Option, -} - /// Command sent from UI to background thread #[derive(Debug)] enum SystrayCommand { diff --git a/komorebi-bar/src/widgets/widget.rs b/komorebi-bar/src/widgets/widget.rs index 0b9fa4c6..9f62748f 100644 --- a/komorebi-bar/src/widgets/widget.rs +++ b/komorebi-bar/src/widgets/widget.rs @@ -1,84 +1,27 @@ use crate::widgets::applications::Applications; -use crate::widgets::applications::ApplicationsConfig; use crate::widgets::battery::Battery; -use crate::widgets::battery::BatteryConfig; use crate::widgets::cpu::Cpu; -use crate::widgets::cpu::CpuConfig; use crate::widgets::date::Date; -use crate::widgets::date::DateConfig; use crate::widgets::keyboard::Keyboard; -use crate::widgets::keyboard::KeyboardConfig; use crate::widgets::komorebi::Komorebi; -use crate::widgets::komorebi::KomorebiConfig; use crate::widgets::media::Media; -use crate::widgets::media::MediaConfig; use crate::widgets::memory::Memory; -use crate::widgets::memory::MemoryConfig; use crate::widgets::network::Network; -use crate::widgets::network::NetworkConfig; use crate::widgets::storage::Storage; -use crate::widgets::storage::StorageConfig; -#[cfg(target_os = "windows")] use crate::widgets::systray::Systray; -#[cfg(target_os = "windows")] -use crate::widgets::systray::SystrayConfig; use crate::widgets::time::Time; -use crate::widgets::time::TimeConfig; use crate::widgets::update::Update; -use crate::widgets::update::UpdateConfig; -use serde::Deserialize; -use serde::Serialize; pub use komorebi_bar_core::widgets::widget::BarWidget; +pub use komorebi_bar_core::widgets::widget::WidgetConfig; -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Widget configuration -pub enum WidgetConfig { - /// Applications widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Applications"))] - Applications(ApplicationsConfig), - /// Battery widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Battery"))] - Battery(BatteryConfig), - /// CPU widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Cpu"))] - Cpu(CpuConfig), - /// Date widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Date"))] - Date(DateConfig), - /// Keyboard widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Keyboard"))] - Keyboard(KeyboardConfig), - /// Komorebi widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Komorebi"))] - Komorebi(KomorebiConfig), - /// Media widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Media"))] - Media(MediaConfig), - /// Memory widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Memory"))] - Memory(MemoryConfig), - /// Network widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Network"))] - Network(NetworkConfig), - /// Storage widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Storage"))] - Storage(StorageConfig), - /// System Tray widget configuration (Windows only) - #[cfg(target_os = "windows")] - #[cfg_attr(feature = "schemars", schemars(title = "Systray"))] - Systray(SystrayConfig), - /// Time widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Time"))] - Time(TimeConfig), - /// Update widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Update"))] - Update(UpdateConfig), +/// Turns a widget configuration into the widget that renders it. +pub trait WidgetConfigExt { + fn as_boxed_bar_widget(&self) -> Box; } -impl WidgetConfig { - pub fn as_boxed_bar_widget(&self) -> Box { +impl WidgetConfigExt for WidgetConfig { + fn as_boxed_bar_widget(&self) -> Box { match self { WidgetConfig::Applications(config) => Box::new(Applications::from(config)), WidgetConfig::Battery(config) => Box::new(Battery::from(*config)), @@ -90,37 +33,9 @@ impl WidgetConfig { WidgetConfig::Memory(config) => Box::new(Memory::from(*config)), WidgetConfig::Network(config) => Box::new(Network::from(*config)), WidgetConfig::Storage(config) => Box::new(Storage::from(*config)), - #[cfg(target_os = "windows")] WidgetConfig::Systray(config) => Box::new(Systray::from(config)), WidgetConfig::Time(config) => Box::new(Time::from(config.clone())), WidgetConfig::Update(config) => Box::new(Update::from(*config)), } } - - pub fn enabled(&self) -> bool { - match self { - WidgetConfig::Applications(config) => config.enable, - WidgetConfig::Battery(config) => config.enable, - WidgetConfig::Cpu(config) => config.enable, - WidgetConfig::Date(config) => config.enable, - WidgetConfig::Keyboard(config) => config.enable, - WidgetConfig::Komorebi(config) => { - config.workspaces.as_ref().is_some_and(|w| w.enable) - || config.layout.as_ref().is_some_and(|w| w.enable) - || config.focused_container.as_ref().is_some_and(|w| w.enable) - || config - .configuration_switcher - .as_ref() - .is_some_and(|w| w.enable) - } - WidgetConfig::Media(config) => config.enable, - WidgetConfig::Memory(config) => config.enable, - WidgetConfig::Network(config) => config.enable, - WidgetConfig::Storage(config) => config.enable, - #[cfg(target_os = "windows")] - WidgetConfig::Systray(config) => config.enable, - WidgetConfig::Time(config) => config.enable, - WidgetConfig::Update(config) => config.enable, - } - } } diff --git a/komorebi-for-kde/Cargo.lock b/komorebi-for-kde/Cargo.lock index ff7cf3a5..9e7fb85c 100644 --- a/komorebi-for-kde/Cargo.lock +++ b/komorebi-for-kde/Cargo.lock @@ -2508,6 +2508,7 @@ dependencies = [ "komorebi-layouts", "komorebi-protocol", "komorebi-scrolling", + "komorebi-settings", "komorebi-themes", "komorebi-transport", "lazy_static", @@ -2612,6 +2613,17 @@ dependencies = [ name = "komorebi-scrolling" version = "0.1.42" +[[package]] +name = "komorebi-settings" +version = "0.1.42" +dependencies = [ + "komorebi-config", + "komorebi-layouts", + "komorebi-protocol", + "komorebi-themes", + "parking_lot 0.12.5", +] + [[package]] name = "komorebi-themes" version = "0.1.42" diff --git a/komorebi-for-kde/Cargo.toml b/komorebi-for-kde/Cargo.toml index 164f9ada..e31ce9e8 100644 --- a/komorebi-for-kde/Cargo.toml +++ b/komorebi-for-kde/Cargo.toml @@ -27,6 +27,7 @@ komorebi-layouts = { path = "../komorebi-layouts", features = [ komorebi-scrolling = { path = "../komorebi-scrolling" } # the wire types shared with the other komorebi daemons komorebi-config = { path = "../crates/komorebi-config", default-features = false, features = ["schemars"] } +komorebi-settings = { path = "../crates/komorebi-settings" } komorebi-protocol = { path = "../crates/komorebi-protocol", default-features = false, features = ["schemars"] } komorebi-transport = { path = "../crates/komorebi-transport" } # the themes (and the palettes made from pictures), from the same upstream revision diff --git a/komorebi-for-kde/komorebi/Cargo.toml b/komorebi-for-kde/komorebi/Cargo.toml index da974d14..9217a4d7 100644 --- a/komorebi-for-kde/komorebi/Cargo.toml +++ b/komorebi-for-kde/komorebi/Cargo.toml @@ -12,6 +12,7 @@ komorebi-collections = { workspace = true } komorebi-layouts = { workspace = true } komorebi-scrolling = { workspace = true } komorebi-config = { workspace = true } +komorebi-settings = { workspace = true } komorebi-protocol = { workspace = true } komorebi-transport = { workspace = true } version = { path = "../version" } diff --git a/komorebi-for-kde/komorebi/src/lib.rs b/komorebi-for-kde/komorebi/src/lib.rs index 58da817a..570a2c7d 100644 --- a/komorebi-for-kde/komorebi/src/lib.rs +++ b/komorebi-for-kde/komorebi/src/lib.rs @@ -85,6 +85,7 @@ pub mod process_command; pub mod process_event; pub mod reconcile; pub mod scrolling; +pub mod settings; pub mod state; pub mod static_config; pub mod theme_manager; @@ -208,16 +209,18 @@ lazy_static! { /// The padding, in pixels, between the edge of a monitor's work area and the tiles of its /// workspaces, for a monitor that sets none of its own. The default for this is 5. -pub static DEFAULT_WORKSPACE_PADDING: AtomicI32 = AtomicI32::new(5); +pub static DEFAULT_WORKSPACE_PADDING: AtomicI32 = + AtomicI32::new(komorebi_settings::shared::PlatformDefaults::KDE.workspace_padding); /// The padding, in pixels, around each tile, for a monitor that sets none of its own. The default /// for this is 5. -pub static DEFAULT_CONTAINER_PADDING: AtomicI32 = AtomicI32::new(5); +pub static DEFAULT_CONTAINER_PADDING: AtomicI32 = + AtomicI32::new(komorebi_settings::shared::PlatformDefaults::KDE.container_padding); /// How many pixels `ResizeWindowEdge` and `ResizeWindowAxis` resize by, when `komorebi.json` does /// not say -pub static DEFAULT_RESIZE_DELTA: i32 = 50; +pub static DEFAULT_RESIZE_DELTA: i32 = komorebi_settings::shared::DEFAULT_RESIZE_DELTA; /// Whether the pointer goes to the window that is given the focus, when `komorebi.json` does not /// say -pub static DEFAULT_MOUSE_FOLLOWS_FOCUS: bool = true; +pub static DEFAULT_MOUSE_FOLLOWS_FOCUS: bool = komorebi_settings::shared::DEFAULT_MOUSE_FOLLOWS_FOCUS; /// The event, the message or the change of the monitors a [`Notification`] is about /// diff --git a/komorebi-for-kde/komorebi/src/settings.rs b/komorebi-for-kde/komorebi/src/settings.rs new file mode 100644 index 00000000..b7056427 --- /dev/null +++ b/komorebi-for-kde/komorebi/src/settings.rs @@ -0,0 +1,97 @@ +//! The settings of this daemon, as one snapshot (plan step 10, `docs/dev/settings.md`). +//! +//! Nothing reads this yet: the process-wide statics are still the source of truth, and each +//! migration step moves one group of them (writers and readers together) into +//! [`settings()`]. Until then the value is the starting values of those statics. + +use std::sync::LazyLock; + +use komorebi_settings::Handle; +use komorebi_settings::Shared; +use komorebi_settings::shared::PlatformDefaults; + +/// The part of the settings that only this platform has. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct KdeSettings {} + +pub type Settings = komorebi_settings::Settings; + +static SETTINGS: LazyLock> = LazyLock::new(|| { + Handle::new(Settings::new( + Shared::defaults_for(PlatformDefaults::KDE), + KdeSettings::default(), + )) +}); + +/// The settings of the running daemon: take a [`Handle::snapshot`] once per unit of work. +pub fn settings() -> &'static Handle { + &SETTINGS +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::Ordering; + + use super::*; + use crate::border_manager; + + // The statics are still the source of truth. These pin that the starting values of the + // settings are the starting values of the statics, so that moving a group over changes nothing. + // (They read the statics without any test writing them first: no test here sets them.) + #[test] + fn the_starting_settings_are_the_starting_values_of_the_statics() { + let shared = &settings().snapshot().shared; + + assert_eq!( + shared.defaults.container_padding, + crate::DEFAULT_CONTAINER_PADDING.load(Ordering::SeqCst) + ); + assert_eq!( + shared.defaults.workspace_padding, + crate::DEFAULT_WORKSPACE_PADDING.load(Ordering::SeqCst) + ); + assert_eq!(shared.defaults.resize_delta, crate::DEFAULT_RESIZE_DELTA); + assert_eq!( + shared.defaults.mouse_follows_focus, + crate::DEFAULT_MOUSE_FOLLOWS_FOCUS + ); + assert_eq!( + shared.border.enabled, + border_manager::BORDER_ENABLED.load(Ordering::SeqCst) + ); + assert_eq!( + shared.border.width, + border_manager::BORDER_WIDTH.load(Ordering::SeqCst) + ); + assert_eq!( + shared.border.offset, + border_manager::BORDER_OFFSET.load(Ordering::SeqCst) + ); + assert_eq!( + shared.border.radius, + border_manager::BORDER_RADIUS.load(Ordering::SeqCst) + ); + let colours = shared.border.colours; + assert_eq!( + colours.single, + border_manager::FOCUSED.load(Ordering::SeqCst) + ); + assert_eq!(colours.stack, border_manager::STACK.load(Ordering::SeqCst)); + assert_eq!( + colours.monocle, + border_manager::MONOCLE.load(Ordering::SeqCst) + ); + assert_eq!( + colours.floating, + border_manager::FLOATING.load(Ordering::SeqCst) + ); + assert_eq!( + colours.unfocused, + border_manager::UNFOCUSED.load(Ordering::SeqCst) + ); + assert_eq!( + colours.unfocused_locked, + border_manager::UNFOCUSED_LOCKED.load(Ordering::SeqCst) + ); + } +} diff --git a/komorebi-for-mac/Cargo.lock b/komorebi-for-mac/Cargo.lock index 2fc9e306..6d548be5 100644 --- a/komorebi-for-mac/Cargo.lock +++ b/komorebi-for-mac/Cargo.lock @@ -3170,6 +3170,7 @@ dependencies = [ "komorebi-config", "komorebi-layouts", "komorebi-protocol", + "komorebi-settings", "komorebi-themes", "komorebi-transport", "lazy_static", @@ -3253,14 +3254,22 @@ version = "0.1.42" dependencies = [ "chrono", "chrono-tz", + "color-eyre", "eframe", "egui-phosphor", + "image 0.25.10", + "komorebi-config", + "komorebi-layouts", + "komorebi-protocol", + "komorebi-themes", + "lazy_static", "reqwest", "schemars 1.2.3", "serde", "serde_json_lenient", "starship-battery", "sysinfo 0.39.6", + "tracing", ] [[package]] @@ -3332,6 +3341,17 @@ dependencies = [ name = "komorebi-scrolling" version = "0.1.42" +[[package]] +name = "komorebi-settings" +version = "0.1.42" +dependencies = [ + "komorebi-config", + "komorebi-layouts", + "komorebi-protocol", + "komorebi-themes", + "parking_lot", +] + [[package]] name = "komorebi-themes" version = "0.1.42" diff --git a/komorebi-for-mac/Cargo.toml b/komorebi-for-mac/Cargo.toml index a18ec567..325852c5 100644 --- a/komorebi-for-mac/Cargo.toml +++ b/komorebi-for-mac/Cargo.toml @@ -17,6 +17,7 @@ komorebi-themes = { path = "../komorebi-themes" } komorebi-backend = { path = "../crates/komorebi-backend" } komorebi-collections = { path = "../crates/komorebi-collections" } komorebi-config = { path = "../crates/komorebi-config", default-features = false, features = ["schemars"] } +komorebi-settings = { path = "../crates/komorebi-settings" } komorebi-protocol = { path = "../crates/komorebi-protocol", default-features = false, features = ["schemars"] } komorebi-transport = { path = "../crates/komorebi-transport" } komorebi-layouts = { path = "../komorebi-layouts", features = [ diff --git a/komorebi-for-mac/komorebi-bar/src/bar.rs b/komorebi-for-mac/komorebi-bar/src/bar.rs index 533ac9af..2a47ef88 100644 --- a/komorebi-for-mac/komorebi-bar/src/bar.rs +++ b/komorebi-for-mac/komorebi-bar/src/bar.rs @@ -20,6 +20,7 @@ use crate::widgets::komorebi::Komorebi; use crate::widgets::komorebi::MonitorInfo; use crate::widgets::widget::BarWidget; use crate::widgets::widget::WidgetConfig; +use crate::widgets::widget::WidgetConfigExt; use crossbeam_channel::Receiver; use crossbeam_channel::TryRecvError; use eframe::egui::Align; diff --git a/komorebi-for-mac/komorebi-bar/src/config.rs b/komorebi-for-mac/komorebi-bar/src/config.rs index b913baa5..0702a6d7 100644 --- a/komorebi-for-mac/komorebi-bar/src/config.rs +++ b/komorebi-for-mac/komorebi-bar/src/config.rs @@ -1,582 +1 @@ -use crate::AUTO_SELECT_FILL_COLOUR; -use crate::AUTO_SELECT_TEXT_COLOUR; -use crate::DEFAULT_PADDING; -use crate::render::RenderConfig; -use crate::render::RenderExt; -use eframe::egui::Color32; -use eframe::egui::Context; -use eframe::egui::FontId; -use eframe::egui::TextStyle; -use komorebi_themes::colour::Colour; -use komorebi_themes::colour::Rgb; -use std::num::NonZeroU32; -use std::sync::atomic::Ordering; -// use crate::bar::exec_powershell; -use crate::render::Grouping; -use crate::widgets::widget::WidgetConfig; -use color_eyre::eyre; -use eframe::egui::TextBuffer; -use komorebi_client::Rect; -use komorebi_themes::KomobarTheme; -use serde::Deserialize; -use serde::Serialize; -use std::collections::HashMap; -use std::path::PathBuf; - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// The `komorebi.bar.json` static configuration file reference for komorebi for Mac `v0.1.0` -/// -/// `"$schema": "https://komorebi-for-mac.lgug2z.com/komorebi.bar.0.1.0.schema.json"` -pub struct KomobarConfig { - /// Bar height - #[cfg_attr(feature = "schemars", schemars(extend("default" = 50.0)))] - pub height: Option, - /// Bar padding. Use one value for all sides or use a grouped padding for horizontal and/or - /// vertical definition which can each take a single value for a symmetric padding or two - /// values for each side, i.e.: - /// ```json - /// "padding": { - /// "horizontal": 10.0 - /// } - /// ``` - /// or: - /// ```json - /// "padding": { - /// "horizontal": [left, right] - /// } - /// ``` - /// You can also set individual padding on each side like this: - /// ```json - /// "padding": { - /// "top": 10.0, - /// "bottom": 10.0, - /// "left": 10.0, - /// "right": 10.0 - /// } - /// ``` - pub padding: Option, - /// Bar margin. Use one value for all sides or use a grouped margin for horizontal and/or - /// vertical definition which can each take a single value for a symmetric margin or two - /// values for each side, i.e.: - /// ```json - /// "margin": { - /// "horizontal": 10.0 - /// } - /// ``` - /// or: - /// ```json - /// "margin": { - /// "vertical": [top, bottom] - /// } - /// ``` - /// You can also set individual margin on each side like this: - /// ```json - /// "margin": { - /// "top": 10.0, - /// "bottom": 10.0, - /// "left": 10.0, - /// "right": 10.0 - /// } - /// ``` - pub margin: Option, - /// Bar positioning options - #[serde(alias = "viewport")] - pub position: Option, - /// Frame options (see: https://docs.rs/egui/latest/egui/containers/frame/struct.Frame.html) - pub frame: Option, - /// The monitor index or the full monitor options - #[cfg_attr(feature = "schemars", schemars(extend("default" = MonitorConfigOrIndex::Index(0))))] - pub monitor: Option, - /// Font family - pub font_family: Option, - /// Font size - #[cfg_attr(feature = "schemars", schemars(extend("default" = 12.5)))] - pub font_size: Option, - /// Scale of the icons relative to the font_size [[1.0-2.0]] - #[cfg_attr(feature = "schemars", schemars(extend("default" = 1.4)))] - pub icon_scale: Option, - /// Max label width before text truncation - #[cfg_attr(feature = "schemars", schemars(extend("default" = 400.0)))] - pub max_label_width: Option, - /// Theme - pub theme: Option, - /// Alpha value for the color transparency [[0-255]] - #[cfg_attr(feature = "schemars", schemars(extend("default" = 200)))] - pub transparency_alpha: Option, - /// Spacing between widgets - #[cfg_attr(feature = "schemars", schemars(extend("default" = 10.0)))] - pub widget_spacing: Option, - /// Visual grouping for widgets - pub grouping: Option, - // /// Options for mouse interaction on the bar - // pub mouse: Option, - /// Left side widgets (ordered left-to-right) - pub left_widgets: Vec, - /// Center widgets (ordered left-to-right) - pub center_widgets: Option>, - /// Right side widgets (ordered left-to-right) - pub right_widgets: Vec, -} - -impl KomobarConfig { - pub fn aliases(raw: &str) { - let mut map = HashMap::new(); - map.insert("position", ["viewport"]); - map.insert("end", ["inner_frame"]); - - let mut display = false; - - for aliases in map.values() { - for a in aliases { - if raw.contains(a) { - display = true; - } - } - } - - if display { - println!( - "\nYour bar configuration file contains some options that have been renamed or deprecated:\n" - ); - for (canonical, aliases) in map { - for alias in aliases { - if raw.contains(alias) { - println!(r#""{alias}" is now "{canonical}""#); - } - } - } - } - } - - pub fn show_all_icons_on_komorebi_workspace(widgets: &[WidgetConfig]) -> bool { - widgets - .iter() - .any(|w| matches!(w, WidgetConfig::Komorebi(config) if config.workspaces.is_some_and(|w| w.enable && w.display.is_some_and(|s| matches!(s, - WorkspacesDisplayFormat::AllIcons - | WorkspacesDisplayFormat::AllIconsAndText - | WorkspacesDisplayFormat::AllIconsAndTextOnSelected))))) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Position configuration -pub struct PositionConfig { - /// The desired starting position of the bar (0,0 = top left of the screen) - #[serde(alias = "position")] - pub start: Option, - /// The desired size of the bar from the starting position (usually monitor width x desired height) - #[serde(alias = "inner_size")] - pub end: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Frame configuration -pub struct FrameConfig { - /// Margin inside the painted frame - pub inner_margin: Position, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(untagged)] -/// Monitor configuration or monitor index -pub enum MonitorConfigOrIndex { - /// The monitor index where you want the bar to show - Index(usize), - /// The full monitor options with the index and an optional work_area_offset - MonitorConfig(MonitorConfig), -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Monitor configuration -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, -} - -pub type Padding = SpacingKind; -pub type Margin = SpacingKind; - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(untagged)] -// WARNING: To any developer messing with this code in the future: The order here matters! -// `Grouped` needs to come last, otherwise serde might mistaken an `IndividualSpacingConfig` for a -// `GroupedSpacingConfig` with both `vertical` and `horizontal` set to `None` ignoring the -// individual values. -/// Spacing kind -pub enum SpacingKind { - /// Spacing applied to all sides - All(f32), - /// Individual spacing applied to each side - Individual(IndividualSpacingConfig), - /// Grouped vertical and horizontal spacing - Grouped(GroupedSpacingConfig), -} - -impl SpacingKind { - pub fn to_individual(&self, default: f32) -> IndividualSpacingConfig { - match self { - SpacingKind::All(m) => IndividualSpacingConfig::all(*m), - SpacingKind::Grouped(grouped_spacing_config) => { - let vm = grouped_spacing_config.vertical.as_ref().map_or( - IndividualSpacingConfig::vertical(default), - |vm| match vm { - GroupedSpacingOptions::Symmetrical(m) => { - IndividualSpacingConfig::vertical(*m) - } - GroupedSpacingOptions::Split(tm, bm) => { - IndividualSpacingConfig::vertical(*tm).bottom(*bm) - } - }, - ); - let hm = grouped_spacing_config.horizontal.as_ref().map_or( - IndividualSpacingConfig::horizontal(default), - |hm| match hm { - GroupedSpacingOptions::Symmetrical(m) => { - IndividualSpacingConfig::horizontal(*m) - } - GroupedSpacingOptions::Split(lm, rm) => { - IndividualSpacingConfig::horizontal(*lm).right(*rm) - } - }, - ); - IndividualSpacingConfig { - top: vm.top, - bottom: vm.bottom, - left: hm.left, - right: hm.right, - } - } - SpacingKind::Individual(m) => *m, - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Grouped vertical and horizontal spacing -pub struct GroupedSpacingConfig { - /// Vertical grouped spacing - pub vertical: Option, - /// Horizontal grouped spacing - pub horizontal: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(untagged)] -/// Grouped spacing options -pub enum GroupedSpacingOptions { - /// Symmetrical grouped spacing - Symmetrical(f32), - /// Split grouped spacing - Split(f32, f32), -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Individual spacing configuration -pub struct IndividualSpacingConfig { - /// Spacing for the top - pub top: f32, - /// Spacing for the bottom - pub bottom: f32, - /// Spacing for the left - pub left: f32, - /// Spacing for the right - pub right: f32, -} - -#[allow(dead_code)] -impl IndividualSpacingConfig { - pub const ZERO: Self = IndividualSpacingConfig { - top: 0.0, - bottom: 0.0, - left: 0.0, - right: 0.0, - }; - - pub fn all(value: f32) -> Self { - IndividualSpacingConfig { - top: value, - bottom: value, - left: value, - right: value, - } - } - - pub fn horizontal(value: f32) -> Self { - IndividualSpacingConfig { - top: 0.0, - bottom: 0.0, - left: value, - right: value, - } - } - - pub fn vertical(value: f32) -> Self { - IndividualSpacingConfig { - top: value, - bottom: value, - left: 0.0, - right: 0.0, - } - } - - pub fn top(self, value: f32) -> Self { - IndividualSpacingConfig { top: value, ..self } - } - - pub fn bottom(self, value: f32) -> Self { - IndividualSpacingConfig { - bottom: value, - ..self - } - } - - pub fn left(self, value: f32) -> Self { - IndividualSpacingConfig { - left: value, - ..self - } - } - - pub fn right(self, value: f32) -> Self { - IndividualSpacingConfig { - right: value, - ..self - } - } -} - -pub fn get_individual_spacing( - default: f32, - spacing: &Option, -) -> IndividualSpacingConfig { - spacing - .as_ref() - .map_or(IndividualSpacingConfig::all(default), |s| { - s.to_individual(default) - }) -} - -// #[derive(Clone, Debug, Serialize, Deserialize)] -// #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -// #[serde(untagged)] -// pub enum MouseMessage { -// /// Send a message to the komorebi client. -// /// By default, a batch of messages are sent in the following order: -// /// FocusMonitorAtCursor => -// /// MouseFollowsFocus(false) => -// /// {message} => -// /// MouseFollowsFocus({original.value}) -// /// -// /// Example: -// /// ```json -// /// "on_extra2_click": { -// /// "message": { -// /// "type": "NewWorkspace" -// /// } -// /// }, -// /// ``` -// /// or: -// /// ```json -// /// "on_middle_click": { -// /// "focus_monitor_at_cursor": false, -// /// "ignore_mouse_follows_focus": false, -// /// "message": { -// /// "type": "TogglePause" -// /// } -// /// } -// /// ``` -// /// or: -// /// ```json -// /// "on_scroll_up": { -// /// "message": { -// /// "type": "CycleFocusWorkspace", -// /// "content": "Previous" -// /// } -// /// } -// /// ``` -// Komorebi(KomorebiMouseMessage), -// /// Execute a custom command. -// /// CMD (%variable%), Bash ($variable) and PowerShell ($Env:variable) variables will be resolved. -// /// Example: `komorebic toggle-pause` -// Command(String), -// } -// -// #[derive(Clone, Debug, Serialize, Deserialize)] -// #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -// pub struct KomorebiMouseMessage { -// /// Send the FocusMonitorAtCursor message (default:true) -// pub focus_monitor_at_cursor: Option, -// /// Wrap the {message} with a MouseFollowsFocus(false) and MouseFollowsFocus({original.value}) message (default:true) -// pub ignore_mouse_follows_focus: Option, -// /// The message to send to the komorebi client -// pub message: komorebi_client::SocketMessage, -// } -// -// #[derive(Clone, Debug, Serialize, Deserialize)] -// #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -// pub struct MouseConfig { -// /// Command to send on primary/left double button click -// pub on_primary_double_click: Option, -// /// Command to send on secondary/right button click -// pub on_secondary_click: Option, -// /// Command to send on middle button click -// pub on_middle_click: Option, -// /// Command to send on extra1/back button click -// pub on_extra1_click: Option, -// /// Command to send on extra2/forward button click -// pub on_extra2_click: Option, -// -// /// Defines how many points a user needs to scroll vertically to make a "tick" on a mouse/touchpad/touchscreen (default: 30) -// pub vertical_scroll_threshold: Option, -// /// Command to send on scrolling up (every tick) -// pub on_scroll_up: Option, -// /// Command to send on scrolling down (every tick) -// pub on_scroll_down: Option, -// -// /// Defines how many points a user needs to scroll horizontally to make a "tick" on a mouse/touchpad/touchscreen (default: 30) -// pub horizontal_scroll_threshold: Option, -// /// Command to send on scrolling left (every tick) -// pub on_scroll_left: Option, -// /// Command to send on scrolling right (every tick) -// pub on_scroll_right: Option, -// } -// -// impl MouseConfig { -// pub fn has_command(&self) -> bool { -// [ -// &self.on_primary_double_click, -// &self.on_secondary_click, -// &self.on_middle_click, -// &self.on_extra1_click, -// &self.on_extra2_click, -// &self.on_scroll_up, -// &self.on_scroll_down, -// &self.on_scroll_left, -// &self.on_scroll_right, -// ] -// .iter() -// .any(|opt| matches!(opt, Some(MouseMessage::Command(_)))) -// } -// } -// -// impl MouseMessage { -// pub fn execute(&self, mouse_follows_focus: bool) { -// match self { -// MouseMessage::Komorebi(config) => { -// let mut messages = Vec::new(); -// -// if config.focus_monitor_at_cursor.unwrap_or(true) { -// messages.push(SocketMessage::FocusMonitorAtCursor); -// } -// -// if config.ignore_mouse_follows_focus.unwrap_or(true) { -// messages.push(SocketMessage::MouseFollowsFocus(false)); -// messages.push(config.message.clone()); -// messages.push(SocketMessage::MouseFollowsFocus(mouse_follows_focus)); -// } else { -// messages.push(config.message.clone()); -// } -// -// tracing::debug!("Sending messages: {messages:?}"); -// -// if komorebi_client::send_batch(messages).is_err() { -// tracing::error!("could not send commands"); -// } -// } -// MouseMessage::Command(cmd) => { -// tracing::debug!("Executing command: {}", cmd); -// -// let cmd_no_env = cmd.replace_env(); -// -// if exec_powershell(cmd_no_env.to_str().expect("Invalid command")).is_err() { -// tracing::error!("Failed to execute '{}'", cmd); -// } -// } -// }; -// } -// } - -impl KomobarConfig { - pub fn read(path: &PathBuf) -> eyre::Result { - let content = std::fs::read_to_string(path)?; - let mut value: Self = match path.extension().unwrap().to_string_lossy().as_str() { - "json" => serde_json::from_str(&content)?, - _ => panic!("unsupported format"), - }; - - if value.frame.is_none() { - value.frame = Some(FrameConfig { - inner_margin: Position { - x: DEFAULT_PADDING, - y: DEFAULT_PADDING, - }, - }); - } - - Ok(value) - } -} - -impl RenderExt for &KomobarConfig { - fn new_renderconfig( - &self, - ctx: &Context, - background_color: Color32, - icon_scale: Option, - ) -> RenderConfig { - let text_font_id = ctx - .style() - .text_styles - .get(&TextStyle::Body) - .cloned() - .unwrap_or_else(FontId::default); - - let mut icon_font_id = text_font_id.clone(); - icon_font_id.size *= icon_scale.unwrap_or(1.4).clamp(1.0, 2.0); - - let monitor_idx = match &self.monitor { - Some(MonitorConfigOrIndex::MonitorConfig(monitor_config)) => monitor_config.index, - Some(MonitorConfigOrIndex::Index(idx)) => *idx, - None => 0, - }; - - // check if any of the alignments have a komorebi widget with the workspace set to show all icons - let show_all_icons = - KomobarConfig::show_all_icons_on_komorebi_workspace(&self.left_widgets) - || self - .center_widgets - .as_ref() - .is_some_and(|list| KomobarConfig::show_all_icons_on_komorebi_workspace(list)) - || KomobarConfig::show_all_icons_on_komorebi_workspace(&self.right_widgets); - - RenderConfig { - monitor_idx, - spacing: self.widget_spacing.unwrap_or(10.0), - grouping: self.grouping.unwrap_or(Grouping::None), - background_color, - alignment: None, - more_inner_margin: false, - applied_on_widget: false, - text_font_id, - icon_font_id, - show_all_icons, - auto_select_fill: NonZeroU32::new(AUTO_SELECT_FILL_COLOUR.load(Ordering::SeqCst)) - .map(|c| Colour::Rgb(Rgb::from(c.get())).into()), - auto_select_text: NonZeroU32::new(AUTO_SELECT_TEXT_COLOUR.load(Ordering::SeqCst)) - .map(|c| Colour::Rgb(Rgb::from(c.get())).into()), - } - } -} - -pub use komorebi_bar_core::config::types::*; +pub use komorebi_bar_core::config::*; diff --git a/komorebi-for-mac/komorebi-bar/src/main.rs b/komorebi-for-mac/komorebi-bar/src/main.rs index c08f444c..57e31bea 100644 --- a/komorebi-for-mac/komorebi-bar/src/main.rs +++ b/komorebi-for-mac/komorebi-bar/src/main.rs @@ -71,10 +71,12 @@ fn main() -> eyre::Result<()> { #[cfg(feature = "schemars")] if opts.schema { - let bar_config = schemars::schema_for!(KomobarConfig); - let schema = serde_json::to_string_pretty(&bar_config)?; - - println!("{schema}"); + print!( + "{}", + komorebi_bar_core::schema::generated_text::( + komorebi_bar_core::schema::SchemaPlatform::MacOs + ) + ); std::process::exit(0); } diff --git a/komorebi-for-mac/komorebi-bar/src/os.rs b/komorebi-for-mac/komorebi-bar/src/os.rs index c3df076a..cccfcccd 100644 --- a/komorebi-for-mac/komorebi-bar/src/os.rs +++ b/komorebi-for-mac/komorebi-bar/src/os.rs @@ -1,6 +1,8 @@ +use crate::widgets::ImageIcon; use egui_phosphor::regular::CALENDAR; use komorebi_bar_core::platform::BarPlatform; use komorebi_bar_core::platform::SystemTool; +use komorebi_client::SocketMessage; use std::io; use std::process::Command; @@ -21,6 +23,12 @@ impl BarPlatform for MacBar { .spawn(), // explorer.exe does not exist on macOS: this is what the update widget does today SystemTool::Url(url) => Command::new("explorer.exe").args([url]).spawn(), + SystemTool::StorageLocation(label) => { + let parts: Vec<&str> = label.split(' ').collect(); + let path = parts[..parts.len() - 1].join(" "); + + Command::new("open").args([path]).spawn() + } } .map(|_| ()) } @@ -32,4 +40,21 @@ impl BarPlatform for MacBar { fn calendar_icon(&self) -> &'static str { CALENDAR } + + fn exec_shell_command(&self, _command: &str) -> color_eyre::Result<()> { + // the macOS bar has no shell session: the mouse commands are not supported here + Err(io::Error::new(io::ErrorKind::Unsupported, "no shell session on macOS").into()) + } + + fn send_komorebi_message(&self, message: &SocketMessage) -> io::Result<()> { + komorebi_client::send_message(message) + } + + fn send_komorebi_batch(&self, messages: Vec) -> io::Result<()> { + komorebi_client::send_batch(messages) + } + + fn command_icon(&self, _command: &str) -> Option { + None + } } diff --git a/komorebi-for-mac/komorebi-bar/src/widgets/applications.rs b/komorebi-for-mac/komorebi-bar/src/widgets/applications.rs deleted file mode 100644 index e2d75250..00000000 --- a/komorebi-for-mac/komorebi-bar/src/widgets/applications.rs +++ /dev/null @@ -1,407 +0,0 @@ -use super::ImageIcon; -use crate::render::RenderConfig; -use crate::selected_frame::SelectableFrame; -use crate::widgets::widget::BarWidget; -use eframe::egui::Color32; -use eframe::egui::Context; -use eframe::egui::CornerRadius; -use eframe::egui::FontId; -use eframe::egui::Frame; -use eframe::egui::Image; -use eframe::egui::Label; -use eframe::egui::Margin; -use eframe::egui::RichText; -use eframe::egui::Sense; -use eframe::egui::Stroke; -use eframe::egui::StrokeKind; -use eframe::egui::Ui; -use eframe::egui::Vec2; -use eframe::egui::vec2; -use komorebi_client::PathExt; -use serde::Deserialize; -use serde::Serialize; -use std::process::Command; -use std::sync::Arc; -use std::time::Duration; -use std::time::Instant; -use tracing; - -/// Minimum interval between consecutive application launches to prevent accidental spamming. -const MIN_LAUNCH_INTERVAL: Duration = Duration::from_millis(800); - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Applications widget configuration -pub struct ApplicationsConfig { - /// Enables or disables the applications widget. - pub enable: bool, - /// Whether to show the launch command on hover (optional). - /// Could be overridden per application. Defaults to `false` if not set. - pub show_command_on_hover: Option, - /// Horizontal spacing between application buttons. - pub spacing: Option, - /// Default display format for all applications (optional). - /// Could be overridden per application. Defaults to `Icon`. - pub display: Option, - /// List of configured applications to display. - pub items: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Application button configuration -pub struct AppConfig { - /// Whether to enable this application button (optional). - /// Inherits from the global `Applications` setting if omitted. - pub enable: Option, - /// Whether to show the launch command on hover (optional). - /// Inherits from the global `Applications` setting if omitted. - pub show_command_on_hover: Option, - /// Display name of the application. - pub name: String, - /// Optional icon: a path to an image or a text-based glyph (e.g., from Nerd Fonts). - /// If not set, and if the `command` is a path to an executable, an icon might be extracted from it. - /// Note: glyphs require a compatible `font_family`. - pub icon: Option, - /// Command to execute (e.g. path to the application or shell command). - pub command: String, - /// Display format for this application button (optional). Overrides global format if set. - pub display: Option, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Default)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Applications widget display format -pub enum ApplicationsDisplayFormat { - /// Show only the application icon. - #[default] - Icon, - /// Show only the application name as text. - Text, - /// Show both the application icon and name. - IconAndText, -} - -#[derive(Clone, Debug)] -pub struct Applications { - /// Whether the applications widget is enabled. - pub enable: bool, - /// Horizontal spacing between application buttons. - pub spacing: Option, - /// Applications to be rendered in the UI. - pub items: Vec, -} - -impl BarWidget for Applications { - fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) { - if !self.enable { - return; - } - - let icon_config = IconConfig { - font_id: config.icon_font_id.clone(), - size: config.icon_font_id.size, - color: ctx.style().visuals.selection.stroke.color, - }; - - if let Some(spacing) = self.spacing { - ui.spacing_mut().item_spacing.x = spacing; - } - - config.apply_on_widget(false, ui, |ui| { - for app in &mut self.items { - app.render(ctx, ui, &icon_config); - } - }); - } -} - -impl From<&ApplicationsConfig> for Applications { - fn from(applications_config: &ApplicationsConfig) -> Self { - let items = applications_config - .items - .iter() - .enumerate() - .map(|(index, config)| { - let command = UserCommand::new(&config.command); - - App { - enable: config.enable.unwrap_or(applications_config.enable), - #[allow(clippy::obfuscated_if_else)] - name: config - .name - .is_empty() - .then(|| format!("App {}", index + 1)) - .unwrap_or_else(|| config.name.clone()), - icon: Icon::try_from_path(config.icon.as_deref()) - .or_else(|| Icon::try_from_command(&command)), - command, - display: config - .display - .or(applications_config.display) - .unwrap_or_default(), - show_command_on_hover: config - .show_command_on_hover - .or(applications_config.show_command_on_hover) - .unwrap_or(false), - } - }) - .collect(); - - Self { - enable: applications_config.enable, - items, - spacing: applications_config.spacing, - } - } -} - -/// A single resolved application entry used at runtime. -#[derive(Clone, Debug)] -pub struct App { - /// Whether this application is enabled. - pub enable: bool, - /// Display name of the application. Defaults to "App N" if not set. - pub name: String, - /// Icon to display for this application, if available. - pub icon: Option, - /// Command to execute when the application is launched. - pub command: UserCommand, - /// Display format (icon, text, or both). - pub display: ApplicationsDisplayFormat, - /// Whether to show the launch command on hover. - pub show_command_on_hover: bool, -} - -impl App { - /// Renders the application button in the provided `Ui` context with a given icon size. - #[inline] - pub fn render(&mut self, ctx: &Context, ui: &mut Ui, icon_config: &IconConfig) { - if self.enable - && SelectableFrame::new(false) - .show(ui, |ui| { - ui.spacing_mut().item_spacing = Vec2::splat(4.0); - - match self.display { - ApplicationsDisplayFormat::Icon => self.draw_icon(ctx, ui, icon_config), - ApplicationsDisplayFormat::Text => self.draw_name(ui), - ApplicationsDisplayFormat::IconAndText => { - self.draw_icon(ctx, ui, icon_config); - self.draw_name(ui); - } - } - - // Add hover text with command information - let response = ui.response(); - if self.show_command_on_hover { - response.on_hover_text(format!("Launch: {}", self.command.as_ref())); - } - }) - .clicked() - { - // Launch the application when clicked - self.command.launch_if_ready(); - } - } - - /// Draws the application's icon within the UI if available, - /// or falls back to a default placeholder icon. - #[inline] - fn draw_icon(&self, ctx: &Context, ui: &mut Ui, icon_config: &IconConfig) { - if let Some(icon) = &self.icon { - icon.draw(ctx, ui, icon_config); - } else { - Icon::draw_fallback(ui, Vec2::splat(icon_config.size)); - } - } - - /// Displays the application's name as a non-selectable label within the UI. - #[inline] - fn draw_name(&self, ui: &mut Ui) { - ui.add(Label::new(&self.name).selectable(false)); - } -} - -/// Holds image/text data to be used as an icon in the UI. -/// This represents source icon data before rendering. -#[derive(Clone, Debug)] -pub enum Icon { - /// RGBA image used for rendering the icon. - Image(ImageIcon), - /// Text-based icon, e.g. from a font like Nerd Fonts. - Text(String), -} - -impl Icon { - /// Attempts to create an [`Icon`] from a string path or text glyph/glyphs. - /// - /// - Environment variables in the path are resolved using [`PathExt::replace_env`]. - /// - Uses [`ImageIcon::try_load`] to load and cache the icon image based on the resolved path. - /// - If the path is invalid but the string is non-empty, it is interpreted as a text-based icon and - /// returned as [`Icon::Text`]. - /// - Returns `None` if the input is empty, `None`, or image loading fails. - #[inline] - pub fn try_from_path(icon: Option<&str>) -> Option { - let icon = icon.map(str::trim)?; - if icon.is_empty() { - return None; - } - - let path = icon.replace_env(); - if !path.is_file() { - return Some(Icon::Text(icon.to_owned())); - } - - let image_icon = ImageIcon::try_load(path.as_ref(), || match image::open(&path) { - Ok(img) => Some(img), - Err(err) => { - tracing::error!("Failed to load icon from {:?}, error: {}", path, err); - None - } - })?; - - Some(Icon::Image(image_icon)) - } - - /// Attempts to create an [`Icon`] by extracting an image from the executable path of a [`UserCommand`]. - /// - /// - Uses [`ImageIcon::try_load`] to load and cache the icon image based on the resolved executable path. - /// - Returns [`Icon::Image`] if an icon is successfully extracted. - /// - Returns `None` if the executable path is unavailable or icon extraction fails. - #[inline] - pub fn try_from_command(_command: &UserCommand) -> Option { - // let path = command.get_executable()?; - // let image_icon = ImageIcon::try_load(path.as_ref(), || { - // let path_str = path.to_str()?; - // windows_icons::get_icon_by_path(path_str) - // .or_else(|| windows_icons_fallback::get_icon_by_path(path_str)) - // })?; - // Some(Icon::Image(image_icon)) - None - } - - /// Renders the icon in the given [`Ui`] using the provided [`IconConfig`]. - #[inline] - pub fn draw(&self, ctx: &Context, ui: &mut Ui, icon_config: &IconConfig) { - match self { - Icon::Image(image_icon) => { - Frame::NONE - .inner_margin(Margin::same(ui.style().spacing.button_padding.y as i8)) - .show(ui, |ui| { - ui.add( - Image::from_texture(&image_icon.texture(ctx)) - .maintain_aspect_ratio(true) - .fit_to_exact_size(Vec2::splat(icon_config.size)), - ); - }); - } - Icon::Text(icon) => { - let rich_text = RichText::new(icon) - .font(icon_config.font_id.clone()) - .size(icon_config.size) - .color(icon_config.color); - ui.add(Label::new(rich_text).selectable(false)); - } - } - } - - /// Draws a fallback icon when the specified icon cannot be loaded. - /// Displays a simple crossed-out rectangle as a placeholder. - #[inline] - pub fn draw_fallback(ui: &mut Ui, icon_size: Vec2) { - let (response, painter) = ui.allocate_painter(icon_size, Sense::hover()); - let stroke = Stroke::new(1.0, ui.style().visuals.text_color()); - let mut rect = response.rect; - let rounding = CornerRadius::same((rect.width() * 0.1) as u8); - rect = rect.shrink(stroke.width); - let c = rect.center(); - let r = rect.width() / 2.0; - painter.rect_stroke(rect, rounding, stroke, StrokeKind::Outside); - painter.line_segment([c - vec2(r, r), c + vec2(r, r)], stroke); - } -} - -/// Configuration structure for icon rendering -#[derive(Clone, Debug)] -pub struct IconConfig { - /// Font used for text-based icons - pub font_id: FontId, - /// Size of the icon - pub size: f32, - /// Color of the icon used for text-based icons - pub color: Color32, -} - -/// A structure to manage command execution with cooldown prevention. -#[derive(Clone, Debug)] -pub struct UserCommand { - /// The command string to execute - pub command: Arc, - /// Last time this command was executed (used for cooldown control) - pub last_launch: Instant, -} - -impl AsRef for UserCommand { - #[inline] - fn as_ref(&self) -> &str { - &self.command - } -} - -impl UserCommand { - /// Creates a new [`UserCommand`] with environment variables in the command path - /// resolved using [`PathExt::replace_env`]. - #[inline] - pub fn new(command: &str) -> Self { - // Allow immediate launch by initializing last_launch in the past - let last_launch = Instant::now() - 2 * MIN_LAUNCH_INTERVAL; - - Self { - command: Arc::from(command.replace_env().to_str().unwrap_or_default()), - last_launch, - } - } - - // /// Attempts to resolve the executable path from the command string. - // /// - // /// Resolution logic: - // /// - Splits the command by ".exe" and checks if the first part is an existing file. - // /// - If not, attempts to locate the binary using [`which`] on this name. - // /// - If still unresolved, takes the first word (separated by whitespace) and attempts - // /// to find it in the system `PATH` using [`which`]. - // /// - // /// Returns `None` if no executable path can be determined. - // #[inline] - // pub fn get_executable(&self) -> Option> { - // if let Some(binary) = self.command.split(".exe").next().map(Path::new) { - // if binary.is_file() { - // return Some(Cow::Borrowed(binary)); - // } else if let Ok(binary) = which(binary) { - // return Some(Cow::Owned(binary)); - // } - // } - // - // which(self.command.split(' ').next()?).ok().map(Cow::Owned) - // } - - /// Attempts to launch the specified command in a separate thread if enough time has passed - /// since the last launch. This prevents repeated launches from rapid consecutive clicks. - /// - /// Errors during launch are logged using the `tracing` crate. - pub fn launch_if_ready(&mut self) { - let now = Instant::now(); - // Check if enough time has passed since the last launch - if now.duration_since(self.last_launch) < MIN_LAUNCH_INTERVAL { - return; - } - - self.last_launch = now; - let command_string = self.command.clone(); - // Launch the application in a separate thread to avoid blocking the UI - std::thread::spawn(move || { - if let Err(e) = Command::new("cmd").args(["/C", &command_string]).spawn() { - tracing::error!("Failed to launch command '{}': {}", command_string, e); - } - }); - } -} diff --git a/komorebi-for-mac/komorebi-bar/src/widgets/komorebi.rs b/komorebi-for-mac/komorebi-bar/src/widgets/komorebi.rs index 0b9cbaf5..6ad709ae 100644 --- a/komorebi-for-mac/komorebi-bar/src/widgets/komorebi.rs +++ b/komorebi-for-mac/komorebi-bar/src/widgets/komorebi.rs @@ -7,7 +7,15 @@ use crate::config::WorkspacesDisplayFormat; use crate::render::RenderConfig; use crate::selected_frame::SelectableFrame; use crate::ui::CustomUi; -use crate::widgets::komorebi_layout::KomorebiLayout; +pub use komorebi_bar_core::widgets::komorebi_config::KomorebiConfig; +pub use komorebi_bar_core::widgets::komorebi_config::KomorebiConfigurationSwitcherConfig; +pub use komorebi_bar_core::widgets::komorebi_config::KomorebiFocusedContainerConfig; +pub use komorebi_bar_core::widgets::komorebi_config::KomorebiLayoutConfig; +pub use komorebi_bar_core::widgets::komorebi_config::KomorebiLockedContainerConfig; +pub use komorebi_bar_core::widgets::komorebi_config::KomorebiWorkspaceLayerConfig; +pub use komorebi_bar_core::widgets::komorebi_config::KomorebiWorkspacesConfig; +pub use komorebi_bar_core::widgets::komorebi_config::KomorebiLayout; +use crate::widgets::komorebi_layout::KomorebiLayoutExt; use crate::widgets::widget::BarWidget; use eframe::egui::Align; use eframe::egui::Color32; @@ -40,10 +48,7 @@ use komorebi_client::State; use komorebi_client::Window; use komorebi_client::Workspace; use komorebi_client::WorkspaceLayer; -use serde::Deserialize; -use serde::Serialize; use std::cell::RefCell; -use std::collections::BTreeMap; use std::collections::HashMap; use std::fs::File; use std::io::BufReader; @@ -52,95 +57,6 @@ use std::path::Path; use std::rc::Rc; use std::sync::atomic::Ordering; -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Komorebi widget configuration -pub struct KomorebiConfig { - /// Configure the Workspaces widget - pub workspaces: Option, - /// Configure the Layout widget - pub layout: Option, - /// Configure the Workspace Layer widget - pub workspace_layer: Option, - /// Configure the Focused Container widget - #[serde(alias = "focused_window")] - pub focused_container: Option, - /// Configure the Locked Container widget - pub locked_container: Option, - /// Configure the Configuration Switcher widget - pub configuration_switcher: Option, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Komorebi widget workspaces configuration -pub struct KomorebiWorkspacesConfig { - /// Enable the Komorebi Workspaces widget - pub enable: bool, - /// Hide workspaces without any windows - pub hide_empty_workspaces: bool, - /// Display format of the workspace - pub display: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Komorebi widget layout configuration -pub struct KomorebiLayoutConfig { - /// Enable the Komorebi Layout widget - pub enable: bool, - /// List of layout options - pub options: Option>, - /// Display format of the current layout - pub display: Option, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Komorebi widget workspace layer configuration -pub struct KomorebiWorkspaceLayerConfig { - /// Enable the Komorebi Workspace Layer widget - pub enable: bool, - /// Display format of the current layer - pub display: Option, - /// Show the widget event if the layer is Tiling - pub show_when_tiling: Option, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Komorebi widget focused container configuration -pub struct KomorebiFocusedContainerConfig { - /// Enable the Komorebi Focused Container widget - pub enable: bool, - /// DEPRECATED: use 'display' instead (Show the icon of the currently focused container) - pub show_icon: Option, - /// Display format of the currently focused container - pub display: Option, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Komorebi widget locked container configuration -pub struct KomorebiLockedContainerConfig { - /// Enable the Komorebi Locked Container widget - pub enable: bool, - /// Display format of the current locked state - pub display: Option, - /// Show the widget event if the layer is unlocked - pub show_when_unlocked: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Komorebi widget configuration switcher configuration -pub struct KomorebiConfigurationSwitcherConfig { - /// Enable the Komorebi Configurations widget - pub enable: bool, - /// A map of display friendly name => path to configuration.json - pub configurations: BTreeMap, -} - impl From<&KomorebiConfig> for Komorebi { fn from(cfg: &KomorebiConfig) -> Self { let configuration_switcher = cfg.configuration_switcher.clone().map(|mut cs| { @@ -530,7 +446,9 @@ impl FocusedContainerBar { if !value.enable { return None; } + // Handle legacy setting - convert show_icon to display format + #[allow(deprecated)] let format = value .display .unwrap_or(if value.show_icon.unwrap_or(false) { diff --git a/komorebi-for-mac/komorebi-bar/src/widgets/komorebi_layout.rs b/komorebi-for-mac/komorebi-bar/src/widgets/komorebi_layout.rs deleted file mode 100644 index c6b1c6a8..00000000 --- a/komorebi-for-mac/komorebi-bar/src/widgets/komorebi_layout.rs +++ /dev/null @@ -1,329 +0,0 @@ -use crate::config::DisplayFormat; -use crate::render::RenderConfig; -use crate::selected_frame::SelectableFrame; -use crate::widgets::komorebi::KomorebiLayoutConfig; -use eframe::egui::Context; -use eframe::egui::CornerRadius; -use eframe::egui::FontId; -use eframe::egui::Frame; -use eframe::egui::Label; -use eframe::egui::Sense; -use eframe::egui::Stroke; -use eframe::egui::StrokeKind; -use eframe::egui::Ui; -use eframe::egui::Vec2; -use eframe::egui::vec2; -use komorebi_client::SocketMessage; -use serde::Deserialize; -use serde::Deserializer; -use serde::Serialize; -use serde::de::Error; -use serde_json::from_str; -use std::fmt::Display; -use std::fmt::Formatter; - -#[derive(Copy, Clone, Debug, Serialize, PartialEq)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Komorebi layout kind -pub enum KomorebiLayout { - /// Predefined layout - #[cfg_attr(feature = "schemars", schemars(title = "Default"))] - Default(komorebi_client::DefaultLayout), - /// Monocle mode - Monocle, - /// Floating layer - Floating, - /// Paused - Paused, - /// Custom layout - Custom, -} - -impl<'de> Deserialize<'de> for KomorebiLayout { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let s: String = String::deserialize(deserializer)?; - - // Attempt to deserialize the string as a DefaultLayout - if let Ok(default_layout) = from_str::(&format!("\"{s}\"")) - { - return Ok(KomorebiLayout::Default(default_layout)); - } - - // Handle other cases - match s.as_str() { - "Monocle" => Ok(KomorebiLayout::Monocle), - "Floating" => Ok(KomorebiLayout::Floating), - "Paused" => Ok(KomorebiLayout::Paused), - "Custom" => Ok(KomorebiLayout::Custom), - _ => Err(Error::custom(format!("Invalid layout: {s}"))), - } - } -} - -impl Display for KomorebiLayout { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - KomorebiLayout::Default(layout) => write!(f, "{layout}"), - KomorebiLayout::Monocle => write!(f, "Monocle"), - KomorebiLayout::Floating => write!(f, "Floating"), - KomorebiLayout::Paused => write!(f, "Paused"), - KomorebiLayout::Custom => write!(f, "Custom"), - } - } -} - -impl KomorebiLayout { - fn is_default(&mut self) -> bool { - matches!(self, KomorebiLayout::Default(_)) - } - - fn on_click( - &mut self, - show_options: &bool, - monitor_idx: usize, - workspace_idx: Option, - ) -> bool { - if self.is_default() { - !show_options - } else { - self.on_click_option(monitor_idx, workspace_idx); - false - } - } - - fn on_click_option(&mut self, monitor_idx: usize, workspace_idx: Option) { - match self { - KomorebiLayout::Default(option) => { - if let Some(ws_idx) = workspace_idx - && komorebi_client::send_message(&SocketMessage::WorkspaceLayout( - monitor_idx, - ws_idx, - *option, - )) - .is_err() - { - tracing::error!("could not send message to komorebi: WorkspaceLayout"); - } - } - KomorebiLayout::Monocle => { - if komorebi_client::send_batch([ - SocketMessage::FocusMonitorAtCursor, - SocketMessage::ToggleMonocle, - ]) - .is_err() - { - tracing::error!("could not send message to komorebi: ToggleMonocle"); - } - } - KomorebiLayout::Floating => { - if komorebi_client::send_batch([ - SocketMessage::FocusMonitorAtCursor, - SocketMessage::ToggleTiling, - ]) - .is_err() - { - tracing::error!("could not send message to komorebi: ToggleTiling"); - } - } - KomorebiLayout::Paused => { - if komorebi_client::send_message(&SocketMessage::TogglePause).is_err() { - tracing::error!("could not send message to komorebi: TogglePause"); - } - } - KomorebiLayout::Custom => {} - } - } - - fn show_icon(&mut self, is_selected: bool, font_id: FontId, ctx: &Context, ui: &mut Ui) { - // paint custom icons for the layout - let size = Vec2::splat(font_id.size); - let (response, painter) = ui.allocate_painter(size, Sense::hover()); - let color = if is_selected { - ctx.style().visuals.selection.stroke.color - } else { - ui.style().visuals.text_color() - }; - let stroke = Stroke::new(1.0, color); - let mut rect = response.rect; - let rounding = CornerRadius::same((rect.width() * 0.1) as u8); - rect = rect.shrink(stroke.width); - let c = rect.center(); - let r = rect.width() / 2.0; - painter.rect_stroke(rect, rounding, stroke, StrokeKind::Outside); - - match self { - KomorebiLayout::Default(layout) => match layout { - komorebi_client::DefaultLayout::BSP => { - painter.line_segment([c - vec2(0.0, r), c + vec2(0.0, r)], stroke); - painter.line_segment([c, c + vec2(r, 0.0)], stroke); - painter.line_segment([c + vec2(r / 2.0, 0.0), c + vec2(r / 2.0, r)], stroke); - } - komorebi_client::DefaultLayout::Columns => { - painter.line_segment([c - vec2(r / 2.0, r), c + vec2(-r / 2.0, r)], stroke); - painter.line_segment([c - vec2(0.0, r), c + vec2(0.0, r)], stroke); - painter.line_segment([c - vec2(-r / 2.0, r), c + vec2(r / 2.0, r)], stroke); - } - komorebi_client::DefaultLayout::Rows => { - painter.line_segment([c - vec2(r, r / 2.0), c + vec2(r, -r / 2.0)], stroke); - painter.line_segment([c - vec2(r, 0.0), c + vec2(r, 0.0)], stroke); - painter.line_segment([c - vec2(r, -r / 2.0), c + vec2(r, r / 2.0)], stroke); - } - komorebi_client::DefaultLayout::VerticalStack => { - painter.line_segment([c - vec2(0.0, r), c + vec2(0.0, r)], stroke); - painter.line_segment([c, c + vec2(r, 0.0)], stroke); - } - komorebi_client::DefaultLayout::RightMainVerticalStack => { - painter.line_segment([c - vec2(0.0, r), c + vec2(0.0, r)], stroke); - painter.line_segment([c - vec2(r, 0.0), c], stroke); - } - komorebi_client::DefaultLayout::HorizontalStack => { - painter.line_segment([c - vec2(r, 0.0), c + vec2(r, 0.0)], stroke); - painter.line_segment([c, c + vec2(0.0, r)], stroke); - } - komorebi_client::DefaultLayout::UltrawideVerticalStack => { - painter.line_segment([c - vec2(r / 2.0, r), c + vec2(-r / 2.0, r)], stroke); - painter.line_segment([c + vec2(r / 2.0, 0.0), c + vec2(r, 0.0)], stroke); - painter.line_segment([c - vec2(-r / 2.0, r), c + vec2(r / 2.0, r)], stroke); - } - komorebi_client::DefaultLayout::Grid => { - painter.line_segment([c - vec2(r, 0.0), c + vec2(r, 0.0)], stroke); - painter.line_segment([c - vec2(0.0, r), c + vec2(0.0, r)], stroke); - } - // TODO: @CtByte can you think of a nice icon to draw here? - komorebi_client::DefaultLayout::Scrolling => { - painter.line_segment([c - vec2(r / 2.0, r), c + vec2(-r / 2.0, r)], stroke); - painter.line_segment([c - vec2(0.0, r), c + vec2(0.0, r)], stroke); - painter.line_segment([c - vec2(-r / 2.0, r), c + vec2(r / 2.0, r)], stroke); - } - }, - KomorebiLayout::Monocle => {} - KomorebiLayout::Floating => { - let mut rect_left = response.rect; - rect_left.set_width(rect.width() * 0.5); - rect_left.set_height(rect.height() * 0.5); - let mut rect_right = rect_left; - rect_left = rect_left.translate(Vec2::new( - rect.width() * 0.1 + stroke.width, - rect.width() * 0.1 + stroke.width, - )); - rect_right = rect_right.translate(Vec2::new( - rect.width() * 0.35 + stroke.width, - rect.width() * 0.35 + stroke.width, - )); - painter.rect_filled(rect_left, rounding, color); - painter.rect_stroke(rect_right, rounding, stroke, StrokeKind::Outside); - } - KomorebiLayout::Paused => { - let mut rect_left = response.rect; - rect_left.set_width(rect.width() * 0.25); - rect_left.set_height(rect.height() * 0.8); - let mut rect_right = rect_left; - rect_left = rect_left.translate(Vec2::new( - rect.width() * 0.2 + stroke.width, - rect.width() * 0.1 + stroke.width, - )); - rect_right = rect_right.translate(Vec2::new( - rect.width() * 0.55 + stroke.width, - rect.width() * 0.1 + stroke.width, - )); - painter.rect_filled(rect_left, rounding, color); - painter.rect_filled(rect_right, rounding, color); - } - KomorebiLayout::Custom => { - painter.line_segment([c - vec2(0.0, r), c + vec2(0.0, r)], stroke); - painter.line_segment([c + vec2(0.0, r / 2.0), c + vec2(r, r / 2.0)], stroke); - painter.line_segment([c - vec2(0.0, r / 3.0), c - vec2(r, r / 3.0)], stroke); - } - } - } - - pub fn show( - &mut self, - ctx: &Context, - ui: &mut Ui, - render_config: &mut RenderConfig, - layout_config: &KomorebiLayoutConfig, - workspace_idx: Option, - ) { - let monitor_idx = render_config.monitor_idx; - let font_id = render_config.icon_font_id.clone(); - let mut show_options = RenderConfig::load_show_komorebi_layout_options(); - let format = layout_config.display.unwrap_or(DisplayFormat::IconAndText); - - if !self.is_default() { - show_options = false; - } - - render_config.apply_on_widget(false, ui, |ui| { - let layout_frame = SelectableFrame::new(false) - .show(ui, |ui| { - if let DisplayFormat::Icon | DisplayFormat::IconAndText = format { - self.show_icon(true, font_id.clone(), ctx, ui); - } - - if let DisplayFormat::Text | DisplayFormat::IconAndText = format { - ui.add(Label::new(self.to_string()).selectable(false)); - } - }) - .on_hover_text(self.to_string()); - - if layout_frame.clicked() { - show_options = self.on_click(&show_options, monitor_idx, workspace_idx); - } - - if show_options && let Some(workspace_idx) = workspace_idx { - Frame::NONE.show(ui, |ui| { - ui.add( - Label::new(egui_phosphor::regular::ARROW_FAT_LINES_RIGHT.to_string()) - .selectable(false), - ); - - let mut layout_options = layout_config.options.clone().unwrap_or(vec![ - KomorebiLayout::Default(komorebi_client::DefaultLayout::BSP), - KomorebiLayout::Default(komorebi_client::DefaultLayout::Columns), - KomorebiLayout::Default(komorebi_client::DefaultLayout::Rows), - KomorebiLayout::Default(komorebi_client::DefaultLayout::VerticalStack), - KomorebiLayout::Default( - komorebi_client::DefaultLayout::RightMainVerticalStack, - ), - KomorebiLayout::Default(komorebi_client::DefaultLayout::HorizontalStack), - KomorebiLayout::Default( - komorebi_client::DefaultLayout::UltrawideVerticalStack, - ), - KomorebiLayout::Default(komorebi_client::DefaultLayout::Grid), - //KomorebiLayout::Custom, - KomorebiLayout::Monocle, - KomorebiLayout::Floating, - KomorebiLayout::Paused, - ]); - - for layout_option in &mut layout_options { - let is_selected = self == layout_option; - - if SelectableFrame::new(is_selected) - .show(ui, |ui| { - layout_option.show_icon(is_selected, font_id.clone(), ctx, ui) - }) - .on_hover_text(match layout_option { - KomorebiLayout::Default(layout) => layout.to_string(), - KomorebiLayout::Monocle => "Toggle monocle".to_string(), - KomorebiLayout::Floating => "Toggle tiling".to_string(), - KomorebiLayout::Paused => "Toggle pause".to_string(), - KomorebiLayout::Custom => "Custom".to_string(), - }) - .clicked() - { - layout_option.on_click_option(monitor_idx, Some(workspace_idx)); - show_options = false; - }; - } - }); - } - }); - - RenderConfig::store_show_komorebi_layout_options(show_options); - } -} diff --git a/komorebi-for-mac/komorebi-bar/src/widgets/mod.rs b/komorebi-for-mac/komorebi-bar/src/widgets/mod.rs index 34bbb3a8..e34ad182 100644 --- a/komorebi-for-mac/komorebi-bar/src/widgets/mod.rs +++ b/komorebi-for-mac/komorebi-bar/src/widgets/mod.rs @@ -1,171 +1,19 @@ -use eframe::egui::ColorImage; -use eframe::egui::Context; -use eframe::egui::TextureHandle; -use eframe::egui::TextureOptions; -use image::RgbaImage; -use std::collections::HashMap; -use std::path::Path; -use std::sync::Arc; -use std::sync::LazyLock; -use std::sync::RwLock; -pub mod applications; +pub use komorebi_bar_core::widgets::applications; pub use komorebi_bar_core::widgets::battery; pub use komorebi_bar_core::widgets::cpu; pub use komorebi_bar_core::widgets::date; pub mod komorebi; -mod komorebi_layout; +pub use komorebi_bar_core::widgets::komorebi_layout; pub use komorebi_bar_core::widgets::memory; pub mod network; -pub mod storage; -pub mod time; +pub use komorebi_bar_core::widgets::storage; +pub use komorebi_bar_core::widgets::time; pub use komorebi_bar_core::widgets::update; pub mod widget; -/// Global cache for icon images and their associated GPU textures. -pub static ICONS_CACHE: IconsCache = IconsCache::new(); - -/// In-memory cache for icon images and their associated GPU textures. -/// -/// Stores raw [`ColorImage`]s and [`TextureHandle`]s keyed by [`ImageIconId`]. -/// Texture entries are context-dependent and automatically invalidated when the [`Context`] changes. -#[allow(clippy::type_complexity)] -pub struct IconsCache { - textures: LazyLock, HashMap)>>, - images: LazyLock>>>, -} - -impl IconsCache { - /// Creates a new empty IconsCache instance. - #[inline] - pub const fn new() -> Self { - Self { - textures: LazyLock::new(|| RwLock::new((None, HashMap::new()))), - images: LazyLock::new(|| RwLock::new(HashMap::new())), - } - } - - /// Retrieves or creates a texture handle for the given icon ID and image. - /// - /// If a texture for the given ID already exists for the current [`Context`], it is reused. - /// Otherwise, a new texture is created, inserted into the cache, and returned. - /// The cache is reset if the [`Context`] has changed. - #[inline] - pub fn texture(&self, ctx: &Context, id: &ImageIconId, img: &Arc) -> TextureHandle { - if let Some(texture) = self.get_texture(ctx, id) { - return texture; - } - let texture_handle = ctx.load_texture("icon", img.clone(), TextureOptions::default()); - self.insert_texture(ctx, id.clone(), texture_handle.clone()); - texture_handle - } - - /// Returns the cached texture for the given icon ID if it exists and matches the current [`Context`]. - pub fn get_texture(&self, ctx: &Context, id: &ImageIconId) -> Option { - let textures_lock = self.textures.read().unwrap(); - if textures_lock.0.as_ref() == Some(ctx) { - return textures_lock.1.get(id).cloned(); - } - None - } - - /// Inserts a texture handle, resetting the cache if the [`Context`] has changed. - pub fn insert_texture(&self, ctx: &Context, id: ImageIconId, texture: TextureHandle) { - let mut textures_lock = self.textures.write().unwrap(); - - if textures_lock.0.as_ref() != Some(ctx) { - textures_lock.0 = Some(ctx.clone()); - textures_lock.1.clear(); - } - - textures_lock.1.insert(id, texture); - } - - /// Returns the cached image for the given icon ID, if available. - pub fn get_image(&self, id: &ImageIconId) -> Option> { - self.images.read().unwrap().get(id).cloned() - } - - /// Caches a raw [`ColorImage`] associated with the given icon ID. - pub fn insert_image(&self, id: ImageIconId, image: Arc) { - self.images.write().unwrap().insert(id, image); - } -} - -#[inline] -fn rgba_to_color_image(rgba_image: &RgbaImage) -> ColorImage { - let size = [rgba_image.width() as usize, rgba_image.height() as usize]; - let pixels = rgba_image.as_flat_samples(); - ColorImage::from_rgba_unmultiplied(size, pixels.as_slice()) -} - -/// Represents an image-based icon with a unique ID and pixel data. -#[derive(Clone, Debug)] -pub struct ImageIcon { - /// Unique identifier for the image icon, used for texture caching. - pub id: ImageIconId, - /// Shared pixel data of the icon in `ColorImage` format. - pub image: Arc, -} - -impl ImageIcon { - /// Creates a new [`ImageIcon`] from the given ID and image data. - #[inline] - pub fn new(id: ImageIconId, image: Arc) -> Self { - Self { id, image } - } - - /// Loads an [`ImageIcon`] from [`ICONS_CACHE`] or calls `loader` if not cached. - /// The loaded image is converted to a [`ColorImage`], cached, and returned. - #[inline] - pub fn try_load(id: impl Into, loader: F) -> Option - where - F: FnOnce() -> Option, - I: Into, - { - let id = id.into(); - let image = ICONS_CACHE.get_image(&id).or_else(|| { - let img = loader()?; - let img = Arc::new(rgba_to_color_image(&img.into())); - ICONS_CACHE.insert_image(id.clone(), img.clone()); - Some(img) - })?; - - Some(ImageIcon::new(id, image)) - } - - /// Returns a texture handle for the icon, using the given [`Context`]. - /// - /// If the texture is already cached in [`ICONS_CACHE`], it is reused. - /// Otherwise, a new texture is created from the [`ColorImage`] and cached. - #[inline] - pub fn texture(&self, ctx: &Context) -> TextureHandle { - ICONS_CACHE.texture(ctx, &self.id, &self.image) - } -} - -/// Unique identifier for an image-based icon. -/// -/// Used to distinguish cached images and textures by either a file path -/// or a Windows window handle. -#[derive(Clone, Debug, Eq, Hash, PartialEq)] -pub enum ImageIconId { - /// Identifier based on a file system path. - Path(Arc), - /// Window ID - WindowId(u32), -} - -impl From<&Path> for ImageIconId { - #[inline] - fn from(value: &Path) -> Self { - Self::Path(value.into()) - } -} - -impl From for ImageIconId { - #[inline] - fn from(value: u32) -> Self { - Self::WindowId(value) - } -} +pub use komorebi_bar_core::widgets::icons::ICONS_CACHE; +pub use komorebi_bar_core::widgets::icons::ImageIcon; +pub use komorebi_bar_core::widgets::icons::ImageIconId; +#[allow(unused_imports)] +pub use komorebi_bar_core::widgets::icons::rgba_to_color_image; diff --git a/komorebi-for-mac/komorebi-bar/src/widgets/network.rs b/komorebi-for-mac/komorebi-bar/src/widgets/network.rs index 806c7968..bd6f2476 100644 --- a/komorebi-for-mac/komorebi-bar/src/widgets/network.rs +++ b/komorebi-for-mac/komorebi-bar/src/widgets/network.rs @@ -1,4 +1,6 @@ use crate::bar::Alignment; +pub use komorebi_bar_core::widgets::network_config::NetworkConfig; +pub use komorebi_bar_core::widgets::network_config::NetworkSelectConfig; use crate::config::LabelPrefix; use crate::render::RenderConfig; use crate::selected_frame::SelectableFrame; @@ -12,8 +14,6 @@ use eframe::egui::Ui; use eframe::egui::text::LayoutJob; use num_derive::FromPrimitive; use parking_lot::Mutex; -use serde::Deserialize; -use serde::Serialize; use std::fmt; use std::process::Command; use std::sync::Arc; @@ -24,48 +24,14 @@ use std::time::Duration; use std::time::Instant; use sysinfo::Networks; -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Network widget configuration -pub struct NetworkConfig { - /// Enable the Network widget - pub enable: bool, - /// Show total received and transmitted activity - #[serde(alias = "show_total_data_transmitted")] - pub show_total_activity: bool, - /// Show received and transmitted activity - #[serde(alias = "show_network_activity")] - pub show_activity: bool, - /// Show default interface - pub show_default_interface: Option, - /// Characters to reserve for received and transmitted activity - #[serde(alias = "network_activity_fill_characters")] - pub activity_left_padding: Option, - /// Data refresh interval in seconds - #[cfg_attr(feature = "schemars", schemars(extend("default" = 10)))] - pub data_refresh_interval: Option, - /// Display label prefix - pub label_prefix: Option, - /// Select when the value is over a limit (1MiB is 1048576 bytes (1024*1024)) - pub auto_select: Option, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Network select configuration -pub struct NetworkSelectConfig { - /// Select the total received data when it's over this value - pub total_received_over: Option, - /// Select the total transmitted data when it's over this value - pub total_transmitted_over: Option, - /// Select the received data when it's over this value - pub received_over: Option, - /// Select the transmitted data when it's over this value - pub transmitted_over: Option, -} - impl From for Network { fn from(value: NetworkConfig) -> Self { + if value.default_interface_options.is_some() { + tracing::warn!( + "the network widget option `default_interface_options` is not supported on macOS and is ignored" + ); + } + let default_refresh_interval = 10; let data_refresh_interval = value.data_refresh_interval.unwrap_or(10); diff --git a/komorebi-for-mac/komorebi-bar/src/widgets/storage.rs b/komorebi-for-mac/komorebi-bar/src/widgets/storage.rs deleted file mode 100644 index 830e06f8..00000000 --- a/komorebi-for-mac/komorebi-bar/src/widgets/storage.rs +++ /dev/null @@ -1,205 +0,0 @@ -use crate::bar::Alignment; -use crate::config::LabelPrefix; -use crate::render::RenderConfig; -use crate::selected_frame::SelectableFrame; -use crate::widgets::widget::BarWidget; -use eframe::egui::Align; -use eframe::egui::Context; -use eframe::egui::Label; -use eframe::egui::TextFormat; -use eframe::egui::Ui; -use eframe::egui::text::LayoutJob; -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)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Storage widget configuration -pub struct StorageConfig { - /// Enable the Storage widget - pub enable: bool, - /// Data refresh interval in seconds - #[cfg_attr(feature = "schemars", schemars(extend("default" = 10)))] - pub data_refresh_interval: Option, - /// Display label prefix - pub label_prefix: Option, - /// Show disks that are read only - #[cfg_attr(feature = "schemars", schemars(extend("default" = false)))] - pub show_read_only_disks: Option, - /// Show removable disks - #[cfg_attr(feature = "schemars", schemars(extend("default" = true)))] - pub show_removable_disks: Option, - /// Storage display name - pub storage_display_name: Option, - /// Select when the current percentage is over this value [[1-100]] - pub auto_select_over: Option, - /// Hide when the current percentage is under this value [[1-100]] - pub auto_hide_under: Option, -} - -impl From 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), - label_prefix: value.label_prefix.unwrap_or(LabelPrefix::IconAndText), - show_read_only_disks: value.show_read_only_disks.unwrap_or(false), - show_removable_disks: value.show_removable_disks.unwrap_or(true), - storage_display_name: value - .storage_display_name - .unwrap_or(StorageDisplayName::Mount), - auto_select_over: value.auto_select_over.map(|o| o.clamp(1, 100)), - auto_hide_under: value.auto_hide_under.map(|o| o.clamp(1, 100)), - last_updated: Instant::now(), - } - } -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub enum StorageDisplayName { - /// Display label as mount point eg. / - Mount, - /// Display label as name eg. Macintosh HD - Name, - /// Display label as mount then name eg. / Macintosh HD - MountAndName, - /// Display label as name then mount eg. Macintosh HD / - NameAndMount, -} - -struct StorageDisk { - label: String, - selected: bool, -} - -pub struct Storage { - pub enable: bool, - disks: Disks, - data_refresh_interval: u64, - label_prefix: LabelPrefix, - show_read_only_disks: bool, - show_removable_disks: bool, - storage_display_name: StorageDisplayName, - auto_select_over: Option, - auto_hide_under: Option, - last_updated: Instant, -} - -impl Storage { - fn output(&mut self) -> Vec { - let now = Instant::now(); - if now.duration_since(self.last_updated) > Duration::from_secs(self.data_refresh_interval) { - self.disks.refresh(true); - self.last_updated = now; - } - - let mut disks = vec![]; - - for disk in &self.disks { - if disk.is_read_only() && !self.show_read_only_disks { - continue; - } - if disk.is_removable() && !self.show_removable_disks { - continue; - } - let mount = disk.mount_point(); - let name = disk.name(); - let display_name = match self.storage_display_name { - StorageDisplayName::Mount => mount.to_string_lossy(), - StorageDisplayName::Name => name.to_string_lossy(), - StorageDisplayName::MountAndName => { - mount.to_string_lossy() + name.to_string_lossy() - } - StorageDisplayName::NameAndMount => { - name.to_string_lossy() + mount.to_string_lossy() - } - }; - let total = disk.total_space(); - let available = disk.available_space(); - let used = total - available; - let percentage = ((used * 100) / total) as u8; - - let hide = self.auto_hide_under.is_some_and(|u| percentage <= u); - - if !hide { - let selected = self.auto_select_over.is_some_and(|o| percentage >= o); - - disks.push(StorageDisk { - label: match self.label_prefix { - LabelPrefix::Text | LabelPrefix::IconAndText => { - format!("{} {}%", display_name, percentage) - } - LabelPrefix::None | LabelPrefix::Icon => format!("{percentage}%"), - }, - selected, - }) - } - } - - disks.sort_by(|a, b| a.label.cmp(&b.label)); - - disks - } -} - -impl BarWidget for Storage { - fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) { - if self.enable { - let mut output = self.output(); - let is_reversed = matches!(config.alignment, Some(Alignment::Right)); - - if is_reversed { - output.reverse(); - } - - for output in output { - let auto_text_color = config.auto_select_text.filter(|_| output.selected); - - let mut layout_job = LayoutJob::simple( - match self.label_prefix { - LabelPrefix::Icon | LabelPrefix::IconAndText => { - egui_phosphor::regular::HARD_DRIVES.to_string() - } - LabelPrefix::None | LabelPrefix::Text => String::new(), - }, - config.icon_font_id.clone(), - auto_text_color.unwrap_or(ctx.style().visuals.selection.stroke.color), - 100.0, - ); - - layout_job.append( - &output.label, - 10.0, - TextFormat { - font_id: config.text_font_id.clone(), - color: auto_text_color.unwrap_or(ctx.style().visuals.text_color()), - valign: Align::Center, - ..Default::default() - }, - ); - - let auto_focus_fill = config.auto_select_fill; - - config.apply_on_widget(false, ui, |ui| { - if SelectableFrame::new_auto(output.selected, auto_focus_fill) - .show(ui, |ui| ui.add(Label::new(layout_job).selectable(false))) - .clicked() - { - let parts: Vec<&str> = output.label.split(' ').collect(); - let path = parts[..parts.len() - 1].join(" "); - - if let Err(error) = Command::new("open").args([path]).spawn() { - eprintln!("{error}") - } - } - }); - } - } - } -} diff --git a/komorebi-for-mac/komorebi-bar/src/widgets/time.rs b/komorebi-for-mac/komorebi-bar/src/widgets/time.rs deleted file mode 100644 index 384741a9..00000000 --- a/komorebi-for-mac/komorebi-bar/src/widgets/time.rs +++ /dev/null @@ -1,542 +0,0 @@ -use crate::bar::Alignment; -use crate::config::LabelPrefix; -use crate::render::RenderConfig; -use crate::selected_frame::SelectableFrame; -use crate::widgets::widget::BarWidget; -use chrono::Local; -use chrono::NaiveTime; -use chrono_tz::Tz; -use eframe::egui::Align; -use eframe::egui::Context; -use eframe::egui::CornerRadius; -use eframe::egui::Label; -use eframe::egui::Sense; -use eframe::egui::Stroke; -use eframe::egui::TextFormat; -use eframe::egui::Ui; -use eframe::egui::Vec2; -use eframe::egui::text::LayoutJob; -use eframe::epaint::StrokeKind; -use lazy_static::lazy_static; -use serde::Deserialize; -use serde::Serialize; -use std::time::Duration; -use std::time::Instant; - -lazy_static! { - static ref TIME_RANGES: Vec<(&'static str, NaiveTime)> = { - vec![ - ( - egui_phosphor::regular::MOON, - NaiveTime::from_hms_opt(0, 0, 0).expect("invalid"), - ), - ( - egui_phosphor::regular::ALARM, - NaiveTime::from_hms_opt(6, 0, 0).expect("invalid"), - ), - ( - egui_phosphor::regular::BREAD, - NaiveTime::from_hms_opt(6, 1, 0).expect("invalid"), - ), - ( - egui_phosphor::regular::BARBELL, - NaiveTime::from_hms_opt(6, 30, 0).expect("invalid"), - ), - ( - egui_phosphor::regular::COFFEE, - NaiveTime::from_hms_opt(8, 0, 0).expect("invalid"), - ), - ( - egui_phosphor::regular::CLOCK, - NaiveTime::from_hms_opt(8, 30, 0).expect("invalid"), - ), - ( - egui_phosphor::regular::HAMBURGER, - NaiveTime::from_hms_opt(12, 0, 0).expect("invalid"), - ), - ( - egui_phosphor::regular::CLOCK_AFTERNOON, - NaiveTime::from_hms_opt(12, 30, 0).expect("invalid"), - ), - ( - egui_phosphor::regular::FORK_KNIFE, - NaiveTime::from_hms_opt(18, 0, 0).expect("invalid"), - ), - ( - egui_phosphor::regular::MOON_STARS, - NaiveTime::from_hms_opt(18, 30, 0).expect("invalid"), - ), - ] - }; -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Time widget configuration -pub struct TimeConfig { - /// Enable the Time widget - pub enable: bool, - /// Set the Time format - pub format: TimeFormat, - /// Display label prefix - pub label_prefix: Option, - /// TimeZone (https://docs.rs/chrono-tz/latest/chrono_tz/enum.Tz.html) - /// - /// Use a custom format to display additional information, i.e.: - /// ```json - /// { - /// "Time": { - /// "enable": true, - /// "format": { "Custom": "%T %Z (Tokyo)" }, - /// "timezone": "Asia/Tokyo" - /// } - ///} - /// ``` - pub timezone: Option, - /// Change the icon depending on the time. The default icon is used between 8:30 and 12:00 - #[cfg_attr(feature = "schemars", schemars(extend("default" = false)))] - pub changing_icon: Option, -} - -impl From for Time { - fn from(value: TimeConfig) -> Self { - // using 1 second made the widget look "less accurate" and lagging (especially having multiple with seconds). - // This is still better than getting an update every frame - let data_refresh_interval = 500; - - Self { - enable: value.enable, - format: value.format, - label_prefix: value.label_prefix.unwrap_or(LabelPrefix::Icon), - timezone: value.timezone, - changing_icon: value.changing_icon.unwrap_or_default(), - data_refresh_interval_millis: data_refresh_interval, - last_state: TimeOutput::new(), - last_updated: Instant::now() - .checked_sub(Duration::from_millis(data_refresh_interval)) - .unwrap(), - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Time format -pub enum TimeFormat { - /// Twelve-hour format (with seconds) - TwelveHour, - /// Twelve-hour format (without seconds) - TwelveHourWithoutSeconds, - /// Twenty-four-hour format (with seconds) - TwentyFourHour, - /// Twenty-four-hour format (without seconds) - TwentyFourHourWithoutSeconds, - /// Twenty-four-hour format displayed as a binary clock with circles (with seconds) (https://en.wikipedia.org/wiki/Binary_clock) - BinaryCircle, - /// Twenty-four-hour format displayed as a binary clock with rectangles (with seconds) (https://en.wikipedia.org/wiki/Binary_clock) - BinaryRectangle, - /// Custom format (https://docs.rs/chrono/latest/chrono/format/strftime/index.html) - #[cfg_attr(feature = "schemars", schemars(title = "Default"))] - Custom(String), -} - -impl TimeFormat { - pub fn toggle(&mut self) { - match self { - TimeFormat::TwelveHour => *self = TimeFormat::TwelveHourWithoutSeconds, - TimeFormat::TwelveHourWithoutSeconds => *self = TimeFormat::TwentyFourHour, - TimeFormat::TwentyFourHour => *self = TimeFormat::TwentyFourHourWithoutSeconds, - TimeFormat::TwentyFourHourWithoutSeconds => *self = TimeFormat::BinaryCircle, - TimeFormat::BinaryCircle => *self = TimeFormat::BinaryRectangle, - TimeFormat::BinaryRectangle => *self = TimeFormat::TwelveHour, - _ => {} - }; - } - - fn fmt_string(&self) -> String { - match self { - TimeFormat::TwelveHour => String::from("%l:%M:%S %p"), - TimeFormat::TwelveHourWithoutSeconds => String::from("%l:%M %p"), - TimeFormat::TwentyFourHour => String::from("%T"), - TimeFormat::TwentyFourHourWithoutSeconds => String::from("%H:%M"), - TimeFormat::BinaryCircle => String::from("c%T"), - TimeFormat::BinaryRectangle => String::from("r%T"), - TimeFormat::Custom(format) => format.to_string(), - } - } -} - -#[derive(Clone, Debug)] -struct TimeOutput { - label: String, - icon: String, -} - -impl TimeOutput { - fn new() -> Self { - Self { - label: String::new(), - icon: String::new(), - } - } -} - -#[derive(Clone, Debug)] -pub struct Time { - pub enable: bool, - pub format: TimeFormat, - label_prefix: LabelPrefix, - timezone: Option, - changing_icon: bool, - data_refresh_interval_millis: u64, - last_state: TimeOutput, - last_updated: Instant, -} - -impl Time { - fn output(&mut self) -> TimeOutput { - let mut output = self.last_state.clone(); - let now = Instant::now(); - - if now.duration_since(self.last_updated) - > Duration::from_millis(self.data_refresh_interval_millis) - { - let (formatted, current_time) = match &self.timezone { - Some(timezone) => match timezone.parse::() { - Ok(tz) => { - let dt = Local::now().with_timezone(&tz); - ( - dt.format(&self.format.fmt_string()) - .to_string() - .trim() - .to_string(), - Some(dt.time()), - ) - } - Err(_) => (format!("Invalid timezone: {timezone:?}"), None), - }, - None => { - let dt = Local::now(); - ( - dt.format(&self.format.fmt_string()) - .to_string() - .trim() - .to_string(), - Some(dt.time()), - ) - } - }; - - if current_time.is_none() { - return TimeOutput { - label: formatted, - icon: egui_phosphor::regular::WARNING_CIRCLE.to_string(), - }; - } - - let current_range = match &self.changing_icon { - true => TIME_RANGES - .iter() - .rev() - .find(|&(_, start)| current_time.unwrap() > *start) - .cloned(), - false => None, - } - .unwrap_or((egui_phosphor::regular::CLOCK, NaiveTime::default())); - - output = TimeOutput { - label: formatted, - icon: current_range.0.to_string(), - }; - - self.last_state.clone_from(&output); - self.last_updated = now; - } - - output - } - - fn paint_binary_circle( - &mut self, - size: f32, - number: u32, - max_power: usize, - ctx: &Context, - ui: &mut Ui, - ) { - let full_height = size; - let height = full_height / 4.0; - let width = height; - let offset = height / 2.0 - height / 8.0; - - let (response, painter) = - ui.allocate_painter(Vec2::new(width, full_height + offset * 2.0), Sense::hover()); - let color = ctx.style().visuals.text_color(); - - let c = response.rect.center(); - let r = height / 2.0 - 0.5; - - if number == 1 || number == 3 || number == 5 || number == 7 || number == 9 { - painter.circle_filled(c + Vec2::new(0.0, height * 1.50 + offset), r, color); - } else { - painter.circle_filled(c + Vec2::new(0.0, height * 1.50 + offset), r / 2.5, color); - } - - if number == 2 || number == 3 || number == 6 || number == 7 { - painter.circle_filled(c + Vec2::new(0.0, height * 0.50 + offset), r, color); - } else { - painter.circle_filled(c + Vec2::new(0.0, height * 0.50 + offset), r / 2.5, color); - } - - if number == 4 || number == 5 || number == 6 || number == 7 { - painter.circle_filled(c + Vec2::new(0.0, -height * 0.50 + offset), r, color); - } else if max_power > 2 { - painter.circle_filled(c + Vec2::new(0.0, -height * 0.50 + offset), r / 2.5, color); - } - - if number == 8 || number == 9 { - painter.circle_filled(c + Vec2::new(0.0, -height * 1.50 + offset), r, color); - } else if max_power > 3 { - painter.circle_filled(c + Vec2::new(0.0, -height * 1.50 + offset), r / 2.5, color); - } - } - - fn paint_binary_rect( - &mut self, - size: f32, - number: u32, - max_power: usize, - ctx: &Context, - ui: &mut Ui, - ) { - let full_height = size; - let height = full_height / 4.0; - let width = height * 1.5; - let offset = height / 2.0 - height / 8.0; - - let (response, painter) = - ui.allocate_painter(Vec2::new(width, full_height + offset * 2.0), Sense::hover()); - let color = ctx.style().visuals.text_color(); - let stroke = Stroke::new(1.0, color); - - let round_all = CornerRadius::same((response.rect.width() * 0.1) as u8); - let round_top = CornerRadius { - nw: round_all.nw, - ne: round_all.ne, - ..Default::default() - }; - let round_none = CornerRadius::ZERO; - let round_bottom = CornerRadius { - sw: round_all.nw, - se: round_all.ne, - ..Default::default() - }; - - if max_power == 2 { - let mut rect = response - .rect - .shrink2(Vec2::new(stroke.width, stroke.width + offset)); - rect.set_height(rect.height() - height * 2.0); - rect = rect.translate(Vec2::new(0.0, height * 2.0 + offset)); - painter.rect_stroke(rect, round_all, stroke, StrokeKind::Outside); - } else if max_power == 3 { - let mut rect = response - .rect - .shrink2(Vec2::new(stroke.width, stroke.width + offset)); - rect.set_height(rect.height() - height); - rect = rect.translate(Vec2::new(0.0, height + offset)); - painter.rect_stroke(rect, round_all, stroke, StrokeKind::Outside); - } else { - let mut rect = response - .rect - .shrink2(Vec2::new(stroke.width, stroke.width + offset)); - rect = rect.translate(Vec2::new(0.0, 0.0 + offset)); - painter.rect_stroke(rect, round_all, stroke, StrokeKind::Outside); - } - - let mut rect_bin = response.rect; - rect_bin.set_width(width); - - if number == 1 || number == 5 || number == 9 { - rect_bin.set_height(height); - painter.rect_filled( - rect_bin.translate(Vec2::new(stroke.width, height * 3.0 + offset * 2.0)), - round_bottom, - color, - ); - } - if number == 2 { - rect_bin.set_height(height); - painter.rect_filled( - rect_bin.translate(Vec2::new(stroke.width, height * 2.0 + offset * 2.0)), - if max_power == 2 { - round_top - } else { - round_none - }, - color, - ); - } - if number == 3 { - rect_bin.set_height(height * 2.0); - painter.rect_filled( - rect_bin.translate(Vec2::new(stroke.width, height * 2.0 + offset * 2.0)), - round_bottom, - color, - ); - } - if number == 4 || number == 5 { - rect_bin.set_height(height); - painter.rect_filled( - rect_bin.translate(Vec2::new(stroke.width, height * 1.0 + offset * 2.0)), - if max_power == 3 { - round_top - } else { - round_none - }, - color, - ); - } - if number == 6 { - rect_bin.set_height(height * 2.0); - painter.rect_filled( - rect_bin.translate(Vec2::new(stroke.width, height * 1.0 + offset * 2.0)), - if max_power == 3 { - round_top - } else { - round_none - }, - color, - ); - } - if number == 7 { - rect_bin.set_height(height * 3.0); - painter.rect_filled( - rect_bin.translate(Vec2::new(stroke.width, height + offset * 2.0)), - if max_power == 3 { - round_all - } else { - round_bottom - }, - color, - ); - } - if number == 8 || number == 9 { - rect_bin.set_height(height); - painter.rect_filled( - rect_bin.translate(Vec2::new(stroke.width, 0.0 + offset * 2.0)), - round_top, - color, - ); - } - } -} - -impl BarWidget for Time { - fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) { - if self.enable { - let mut output = self.output(); - if !output.label.is_empty() { - let use_binary_circle = output.label.starts_with('c'); - let use_binary_rectangle = output.label.starts_with('r'); - - let mut layout_job = LayoutJob::simple( - match self.label_prefix { - LabelPrefix::Icon | LabelPrefix::IconAndText => output.icon, - LabelPrefix::None | LabelPrefix::Text => String::new(), - }, - config.icon_font_id.clone(), - ctx.style().visuals.selection.stroke.color, - 100.0, - ); - - if let LabelPrefix::Text | LabelPrefix::IconAndText = self.label_prefix { - output.label.insert_str(0, "TIME: "); - } - - if !use_binary_circle && !use_binary_rectangle { - layout_job.append( - &output.label, - 10.0, - TextFormat { - font_id: config.text_font_id.clone(), - color: ctx.style().visuals.text_color(), - valign: Align::Center, - ..Default::default() - }, - ); - } - - let font_id = config.icon_font_id.clone(); - let is_reversed = matches!(config.alignment, Some(Alignment::Right)); - - config.apply_on_widget(false, ui, |ui| { - if SelectableFrame::new(false) - .show(ui, |ui| { - if !is_reversed { - ui.add(Label::new(layout_job.clone()).selectable(false)); - } - - if use_binary_circle || use_binary_rectangle { - let ordered_output = if is_reversed { - output.label.chars().rev().collect() - } else { - output.label - }; - - for (section_index, section) in - ordered_output.split(':').enumerate() - { - ui.scope(|ui| { - ui.spacing_mut().item_spacing = Vec2::splat(2.0); - for (number_index, number_char) in - section.chars().enumerate() - { - if let Some(number) = number_char.to_digit(10) { - // the hour is the second char in the first section (in reverse, it's in the last section) - let max_power = match ( - is_reversed, - section_index, - number_index, - ) { - (true, 2, 1) | (false, 0, 1) => 2, - (true, _, 1) | (false, _, 0) => 3, - _ => 4, - }; - - if use_binary_circle { - self.paint_binary_circle( - font_id.size, - number, - max_power, - ctx, - ui, - ); - } else if use_binary_rectangle { - self.paint_binary_rect( - font_id.size, - number, - max_power, - ctx, - ui, - ); - } - } - } - }); - } - } - - if is_reversed { - ui.add(Label::new(layout_job.clone()).selectable(false)); - } - }) - .clicked() - { - self.format.toggle() - } - }); - } - } - } -} diff --git a/komorebi-for-mac/komorebi-bar/src/widgets/widget.rs b/komorebi-for-mac/komorebi-bar/src/widgets/widget.rs index 0f78b517..6880736b 100644 --- a/komorebi-for-mac/komorebi-bar/src/widgets/widget.rs +++ b/komorebi-for-mac/komorebi-bar/src/widgets/widget.rs @@ -1,110 +1,49 @@ use crate::widgets::applications::Applications; -use crate::widgets::applications::ApplicationsConfig; use crate::widgets::battery::Battery; -use crate::widgets::battery::BatteryConfig; use crate::widgets::cpu::Cpu; -use crate::widgets::cpu::CpuConfig; use crate::widgets::date::Date; -use crate::widgets::date::DateConfig; -// use crate::widgets::keyboard::Keyboard; -// use crate::widgets::keyboard::KeyboardConfig; use crate::widgets::komorebi::Komorebi; -use crate::widgets::komorebi::KomorebiConfig; -// use crate::widgets::media::Media; -// use crate::widgets::media::MediaConfig; use crate::widgets::memory::Memory; -use crate::widgets::memory::MemoryConfig; use crate::widgets::network::Network; -use crate::widgets::network::NetworkConfig; use crate::widgets::storage::Storage; -use crate::widgets::storage::StorageConfig; use crate::widgets::time::Time; -use crate::widgets::time::TimeConfig; use crate::widgets::update::Update; -use crate::widgets::update::UpdateConfig; -use serde::Deserialize; -use serde::Serialize; +use eframe::egui::Context; +use eframe::egui::Ui; pub use komorebi_bar_core::widgets::widget::BarWidget; +pub use komorebi_bar_core::widgets::widget::WidgetConfig; +use komorebi_bar_core::render::RenderConfig; -#[derive(Clone, Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -/// Widget configuration -pub enum WidgetConfig { - /// Applications widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Applications"))] - Applications(ApplicationsConfig), - /// Battery widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Battery"))] - Battery(BatteryConfig), - /// CPU widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Cpu"))] - Cpu(CpuConfig), - /// Date widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Date"))] - Date(DateConfig), - // Keyboard(KeyboardConfig), - /// Komorebi widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Komorebi"))] - Komorebi(KomorebiConfig), - // Media(MediaConfig), - /// Memory widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Memory"))] - Memory(MemoryConfig), - /// Network widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Network"))] - Network(NetworkConfig), - /// Storage widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Storage"))] - Storage(StorageConfig), - /// Time widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Time"))] - Time(TimeConfig), - /// Update widget configuration - #[cfg_attr(feature = "schemars", schemars(title = "Update"))] - Update(UpdateConfig), +/// Stands in for a widget that macOS cannot run; `WidgetConfig::enabled` is false for those, so +/// this is never built from a configuration that the bar loads. +struct Unsupported; + +impl BarWidget for Unsupported { + fn render(&mut self, _ctx: &Context, _ui: &mut Ui, _config: &mut RenderConfig) {} } -impl WidgetConfig { - pub fn as_boxed_bar_widget(&self) -> Box { +/// Turns a widget configuration into the widget that renders it. +pub trait WidgetConfigExt { + fn as_boxed_bar_widget(&self) -> Box; +} + +impl WidgetConfigExt for WidgetConfig { + fn as_boxed_bar_widget(&self) -> Box { match self { WidgetConfig::Applications(config) => Box::new(Applications::from(config)), WidgetConfig::Battery(config) => Box::new(Battery::from(*config)), WidgetConfig::Cpu(config) => Box::new(Cpu::from(*config)), WidgetConfig::Date(config) => Box::new(Date::from(config.clone())), - // WidgetConfig::Keyboard(config) => Box::new(Keyboard::from(*config)), 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())), WidgetConfig::Update(config) => Box::new(Update::from(*config)), - } - } - - pub fn enabled(&self) -> bool { - match self { - WidgetConfig::Applications(config) => config.enable, - WidgetConfig::Battery(config) => config.enable, - WidgetConfig::Cpu(config) => config.enable, - WidgetConfig::Date(config) => config.enable, - // WidgetConfig::Keyboard(config) => config.enable, - WidgetConfig::Komorebi(config) => { - config.workspaces.as_ref().is_some_and(|w| w.enable) - || config.layout.as_ref().is_some_and(|w| w.enable) - || config.focused_container.as_ref().is_some_and(|w| w.enable) - || config - .configuration_switcher - .as_ref() - .is_some_and(|w| w.enable) + WidgetConfig::Keyboard(_) | WidgetConfig::Media(_) | WidgetConfig::Systray(_) => { + Box::new(Unsupported) } - // WidgetConfig::Media(config) => config.enable, - WidgetConfig::Memory(config) => config.enable, - WidgetConfig::Network(config) => config.enable, - WidgetConfig::Storage(config) => config.enable, - WidgetConfig::Time(config) => config.enable, - WidgetConfig::Update(config) => config.enable, } } } diff --git a/komorebi-for-mac/komorebi/Cargo.toml b/komorebi-for-mac/komorebi/Cargo.toml index dfa9213a..2c47c276 100644 --- a/komorebi-for-mac/komorebi/Cargo.toml +++ b/komorebi-for-mac/komorebi/Cargo.toml @@ -10,6 +10,7 @@ komorebi-backend = { workspace = true } komorebi-collections = { workspace = true } komorebi-layouts = { workspace = true } komorebi-config = { workspace = true } +komorebi-settings = { workspace = true } komorebi-protocol = { workspace = true } komorebi-transport = { workspace = true } quickxml_to_serde = { git = "https://github.com/LGUG2Z/quickxml_to_serde.git", version = "0.6.1" } diff --git a/komorebi-for-mac/komorebi/src/lib.rs b/komorebi-for-mac/komorebi/src/lib.rs index 55cf64de..a59194ba 100644 --- a/komorebi-for-mac/komorebi/src/lib.rs +++ b/komorebi-for-mac/komorebi/src/lib.rs @@ -72,6 +72,7 @@ pub mod process_command; pub mod process_event; pub mod reaper; pub mod scrolling; +pub mod settings; pub mod settle; pub mod skylight; pub mod splash; @@ -159,10 +160,12 @@ lazy_static! { Arc::new(Mutex::new(HashMap::new())); } -pub static DEFAULT_WORKSPACE_PADDING: AtomicI32 = AtomicI32::new(5); -pub static DEFAULT_CONTAINER_PADDING: AtomicI32 = AtomicI32::new(5); -pub static DEFAULT_RESIZE_DELTA: i32 = 50; -pub static DEFAULT_MOUSE_FOLLOWS_FOCUS: bool = true; +pub static DEFAULT_WORKSPACE_PADDING: AtomicI32 = + AtomicI32::new(komorebi_settings::shared::PlatformDefaults::MACOS.workspace_padding); +pub static DEFAULT_CONTAINER_PADDING: AtomicI32 = + AtomicI32::new(komorebi_settings::shared::PlatformDefaults::MACOS.container_padding); +pub static DEFAULT_RESIZE_DELTA: i32 = komorebi_settings::shared::DEFAULT_RESIZE_DELTA; +pub static DEFAULT_MOUSE_FOLLOWS_FOCUS: bool = komorebi_settings::shared::DEFAULT_MOUSE_FOLLOWS_FOCUS; pub const PUBLIC_KEY: [u8; 32] = [ 0x5a, 0x69, 0x4a, 0xe1, 0x3c, 0x4b, 0xc8, 0x4e, 0xc3, 0x79, 0x0f, 0xab, 0x27, 0x6b, 0x7e, 0xdd, diff --git a/komorebi-for-mac/komorebi/src/settings.rs b/komorebi-for-mac/komorebi/src/settings.rs new file mode 100644 index 00000000..8d7db4df --- /dev/null +++ b/komorebi-for-mac/komorebi/src/settings.rs @@ -0,0 +1,97 @@ +//! The settings of this daemon, as one snapshot (plan step 10, `docs/dev/settings.md`). +//! +//! Nothing reads this yet: the process-wide statics are still the source of truth, and each +//! migration step moves one group of them (writers and readers together) into +//! [`settings()`]. Until then the value is the starting values of those statics. + +use std::sync::LazyLock; + +use komorebi_settings::Handle; +use komorebi_settings::Shared; +use komorebi_settings::shared::PlatformDefaults; + +/// The part of the settings that only this platform has. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MacosSettings {} + +pub type Settings = komorebi_settings::Settings; + +static SETTINGS: LazyLock> = LazyLock::new(|| { + Handle::new(Settings::new( + Shared::defaults_for(PlatformDefaults::MACOS), + MacosSettings::default(), + )) +}); + +/// The settings of the running daemon: take a [`Handle::snapshot`] once per unit of work. +pub fn settings() -> &'static Handle { + &SETTINGS +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::Ordering; + + use super::*; + use crate::border_manager; + + // The statics are still the source of truth. These pin that the starting values of the + // settings are the starting values of the statics, so that moving a group over changes nothing. + // (They read the statics without any test writing them first: no test here sets them.) + #[test] + fn the_starting_settings_are_the_starting_values_of_the_statics() { + let shared = &settings().snapshot().shared; + + assert_eq!( + shared.defaults.container_padding, + crate::DEFAULT_CONTAINER_PADDING.load(Ordering::SeqCst) + ); + assert_eq!( + shared.defaults.workspace_padding, + crate::DEFAULT_WORKSPACE_PADDING.load(Ordering::SeqCst) + ); + assert_eq!(shared.defaults.resize_delta, crate::DEFAULT_RESIZE_DELTA); + assert_eq!( + shared.defaults.mouse_follows_focus, + crate::DEFAULT_MOUSE_FOLLOWS_FOCUS + ); + assert_eq!( + shared.border.enabled, + border_manager::BORDER_ENABLED.load(Ordering::SeqCst) + ); + assert_eq!( + shared.border.width, + border_manager::BORDER_WIDTH.load(Ordering::SeqCst) + ); + assert_eq!( + shared.border.offset, + border_manager::BORDER_OFFSET.load(Ordering::SeqCst) + ); + assert_eq!( + shared.border.radius, + border_manager::BORDER_RADIUS.load(Ordering::SeqCst) + ); + let colours = shared.border.colours; + assert_eq!( + colours.single, + border_manager::FOCUSED.load(Ordering::SeqCst) + ); + assert_eq!(colours.stack, border_manager::STACK.load(Ordering::SeqCst)); + assert_eq!( + colours.monocle, + border_manager::MONOCLE.load(Ordering::SeqCst) + ); + assert_eq!( + colours.floating, + border_manager::FLOATING.load(Ordering::SeqCst) + ); + assert_eq!( + colours.unfocused, + border_manager::UNFOCUSED.load(Ordering::SeqCst) + ); + assert_eq!( + colours.unfocused_locked, + border_manager::UNFOCUSED_LOCKED.load(Ordering::SeqCst) + ); + } +}