From 4bb3b83d57d43abc2340dc58263dad8740afa6f4 Mon Sep 17 00:00:00 2001 From: JustForFun88 Date: Sun, 15 Jun 2025 00:36:32 +0500 Subject: [PATCH] refactor(bar) split komorebi widget into smaller parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR significantly refactors the komorebi bar rendering logic, simplifying state management, and addressing some found bugs. The primary motivation was to make the codebase more readable and maintainable. Key Changes: - Allocation Reduction: Removed most per-frame structure allocations. - Runtime Matching Elimination: Replaced runtime pattern matching with pre-selected function pointers determined at initialization. Widget validations and configurations are now performed during widget creation rather than per-frame checks. For example, widget enablement is now handled by an Option that wraps each ..Bar structure. If a widget is enabled, its structure is present; otherwise, it is None. This eliminates the need for runtime enabled checks. - Widget Modularity: Code is split into smaller parts, reducing complexity. Bug Fixes: - Corrected icon sizing for floating windows following regular containers, ensuring icons revert correctly from icon_size to text_size. - There was also another bug with a floating window positioned above a monocle container, but I forgot the details 😅 --- komorebi-bar/src/bar.rs | 167 +-- komorebi-bar/src/widgets/komorebi.rs | 1532 ++++++++++++++------------ komorebi-client/src/lib.rs | 8 +- 3 files changed, 915 insertions(+), 792 deletions(-) diff --git a/komorebi-bar/src/bar.rs b/komorebi-bar/src/bar.rs index 05172085..125393b4 100644 --- a/komorebi-bar/src/bar.rs +++ b/komorebi-bar/src/bar.rs @@ -10,7 +10,7 @@ use crate::render::Grouping; use crate::render::RenderConfig; use crate::render::RenderExt; use crate::widgets::komorebi::Komorebi; -use crate::widgets::komorebi::KomorebiNotificationState; +use crate::widgets::komorebi::MonitorInfo; use crate::widgets::widget::BarWidget; use crate::widgets::widget::WidgetConfig; use crate::KomorebiEvent; @@ -47,17 +47,14 @@ use eframe::egui::Visuals; use font_loader::system_fonts; use font_loader::system_fonts::FontPropertyBuilder; use komorebi_client::Colour; -use komorebi_client::KomorebiTheme; use komorebi_client::MonitorNotification; use komorebi_client::NotificationEvent; use komorebi_client::PathExt; use komorebi_client::SocketMessage; use komorebi_client::VirtualDesktopNotification; use komorebi_themes::catppuccin_egui; -use komorebi_themes::Base16Value; use komorebi_themes::Base16Wrapper; use komorebi_themes::Catppuccin; -use komorebi_themes::CatppuccinValue; use lazy_static::lazy_static; use parking_lot::Mutex; use std::cell::RefCell; @@ -153,7 +150,7 @@ pub struct Komobar { pub disabled: bool, pub config: KomobarConfig, pub render_config: Rc>, - pub komorebi_notification_state: Option>>, + pub monitor_info: Option>>, pub left_widgets: Vec>, pub center_widgets: Vec>, pub right_widgets: Vec>, @@ -345,7 +342,7 @@ impl Komobar { pub fn apply_config( &mut self, ctx: &Context, - previous_notification_state: Option>>, + previous_monitor_info: Option>>, ) { MAX_LABEL_WIDTH.store( self.config.max_label_width.unwrap_or(400.0) as i32, @@ -374,7 +371,7 @@ impl Komobar { self.config.icon_scale, )); - let mut komorebi_notification_state = previous_notification_state; + let mut monitor_info = previous_monitor_info; let mut komorebi_widgets = Vec::new(); for (idx, widget_config) in self.config.left_widgets.iter().enumerate() { @@ -426,19 +423,18 @@ impl Komobar { komorebi_widgets .into_iter() .for_each(|(mut widget, idx, side)| { - match komorebi_notification_state { + match monitor_info { None => { - komorebi_notification_state = - Some(widget.komorebi_notification_state.clone()); + monitor_info = Some(widget.monitor_info.clone()); } Some(ref previous) => { - if widget.workspaces.is_some_and(|w| w.enable) { - previous.borrow_mut().update_from_config( - &widget.komorebi_notification_state.borrow(), - ); + if widget.workspaces.is_some() { + previous + .borrow_mut() + .update_from_self(&widget.monitor_info.borrow()); } - widget.komorebi_notification_state = previous.clone(); + widget.monitor_info = previous.clone(); } } @@ -464,17 +460,17 @@ impl Komobar { MonitorConfigOrIndex::Index(idx) => (*idx, None), }; - let mapped_state = self.komorebi_notification_state.as_ref().map(|state| { - let state = state.borrow(); + let mapped_info = self.monitor_info.as_ref().map(|info| { + let monitor = info.borrow(); ( - state.monitor_usr_idx_map.get(&usr_monitor_index).copied(), - state.mouse_follows_focus, + monitor.monitor_usr_idx_map.get(&usr_monitor_index).copied(), + monitor.mouse_follows_focus, ) }); - if let Some(state) = mapped_state { - self.monitor_index = state.0; - self.mouse_follows_focus = state.1; + if let Some(info) = mapped_info { + self.monitor_index = info.0; + self.mouse_follows_focus = info.1; } if let Some(monitor_index) = self.monitor_index { @@ -526,7 +522,7 @@ impl Komobar { } } } - } else if self.komorebi_notification_state.is_some() && !self.disabled { + } else if self.monitor_info.is_some() && !self.disabled { tracing::warn!("couldn't find the monitor index of this bar! Disabling the bar until the monitor connects..."); self.disabled = true; } else { @@ -566,7 +562,7 @@ impl Komobar { tracing::info!("widget configuration options applied"); - self.komorebi_notification_state = komorebi_notification_state; + self.monitor_info = monitor_info; } /// Updates the `size_rect` field. Returns a bool indicating if the field was changed or not @@ -649,26 +645,6 @@ impl Komobar { match komorebi_client::StaticConfig::read(&config) { Ok(config) => { if let Some(theme) = config.theme { - let stack_accent = match theme { - KomorebiTheme::Catppuccin { - name, stack_border, .. - } => stack_border - .unwrap_or(CatppuccinValue::Green) - .color32(name.as_theme()), - KomorebiTheme::Base16 { - name, stack_border, .. - } => stack_border - .unwrap_or(Base16Value::Base0B) - .color32(Base16Wrapper::Base16(name)), - KomorebiTheme::Custom { - ref colours, - stack_border, - .. - } => stack_border - .unwrap_or(Base16Value::Base0B) - .color32(Base16Wrapper::Custom(colours.clone())), - }; - apply_theme( ctx, KomobarTheme::from(theme), @@ -678,10 +654,6 @@ impl Komobar { bar_grouping, self.render_config.clone(), ); - - if let Some(state) = &self.komorebi_notification_state { - state.borrow_mut().stack_accent = Some(stack_accent); - } } } Err(_) => { @@ -724,7 +696,7 @@ impl Komobar { disabled: false, config, render_config: Rc::new(RefCell::new(RenderConfig::new())), - komorebi_notification_state: None, + monitor_info: None, left_widgets: vec![], center_widgets: vec![], right_widgets: vec![], @@ -870,12 +842,12 @@ impl eframe::App for Komobar { if self.scale_factor != ctx.native_pixels_per_point().unwrap_or(1.0) { self.scale_factor = ctx.native_pixels_per_point().unwrap_or(1.0); - self.apply_config(ctx, self.komorebi_notification_state.clone()); + self.apply_config(ctx, self.monitor_info.clone()); } if let Ok(updated_config) = self.rx_config.try_recv() { self.config = updated_config; - self.apply_config(ctx, self.komorebi_notification_state.clone()); + self.apply_config(ctx, self.monitor_info.clone()); } match self.rx_gui.try_recv() { @@ -1000,24 +972,26 @@ impl eframe::App for Komobar { } } - if let Some(komorebi_notification_state) = &self.komorebi_notification_state { - komorebi_notification_state - .borrow_mut() - .handle_notification( - ctx, - self.monitor_index, - notification, - self.bg_color.clone(), - self.bg_color_with_alpha.clone(), - self.config.transparency_alpha, - self.config.grouping, - self.config.theme.clone(), - self.render_config.clone(), - ); + if let Some(monitor_info) = &self.monitor_info { + monitor_info.borrow_mut().update( + self.monitor_index, + notification.state, + self.render_config.borrow().show_all_icons, + ); + handle_notification( + ctx, + notification.event, + self.bg_color.clone(), + self.bg_color_with_alpha.clone(), + self.config.transparency_alpha, + self.config.grouping, + self.config.theme.clone(), + self.render_config.clone(), + ); } if should_apply_config { - self.apply_config(ctx, self.komorebi_notification_state.clone()); + self.apply_config(ctx, self.monitor_info.clone()); // Reposition the Bar self.position_bar(); @@ -1343,3 +1317,64 @@ pub enum Alignment { Center, Right, } + +#[allow(clippy::too_many_arguments)] +fn handle_notification( + ctx: &Context, + event: komorebi_client::NotificationEvent, + bg_color: Rc>, + bg_color_with_alpha: Rc>, + transparency_alpha: Option, + grouping: Option, + default_theme: Option, + render_config: Rc>, +) { + if let NotificationEvent::Socket(message) = event { + match message { + SocketMessage::ReloadStaticConfiguration(path) => { + if let Ok(config) = komorebi_client::StaticConfig::read(&path) { + if let Some(theme) = config.theme { + apply_theme( + ctx, + KomobarTheme::from(theme), + bg_color.clone(), + bg_color_with_alpha.clone(), + transparency_alpha, + grouping, + render_config, + ); + tracing::info!("applied theme from updated komorebi.json"); + } else if let Some(default_theme) = default_theme { + apply_theme( + ctx, + default_theme, + bg_color.clone(), + bg_color_with_alpha.clone(), + transparency_alpha, + grouping, + render_config, + ); + tracing::info!( + "removed theme from updated komorebi.json and applied default theme" + ); + } else { + tracing::warn!("theme was removed from updated komorebi.json but there was no default theme to apply"); + } + } + } + SocketMessage::Theme(theme) => { + apply_theme( + ctx, + KomobarTheme::from(*theme), + bg_color, + bg_color_with_alpha.clone(), + transparency_alpha, + grouping, + render_config, + ); + tracing::info!("applied theme from komorebi socket message"); + } + _ => {} + } + } +} diff --git a/komorebi-bar/src/widgets/komorebi.rs b/komorebi-bar/src/widgets/komorebi.rs index db050654..3245bc62 100644 --- a/komorebi-bar/src/widgets/komorebi.rs +++ b/komorebi-bar/src/widgets/komorebi.rs @@ -1,9 +1,7 @@ use super::ImageIcon; -use crate::bar::apply_theme; use crate::config::DisplayFormat; -use crate::config::KomobarTheme; +use crate::config::DisplayFormat::*; use crate::config::WorkspacesDisplayFormat; -use crate::render::Grouping; use crate::render::RenderConfig; use crate::selected_frame::SelectableFrame; use crate::ui::CustomUi; @@ -17,10 +15,12 @@ use eframe::egui::Align; 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::Response; use eframe::egui::RichText; use eframe::egui::Sense; use eframe::egui::Stroke; @@ -29,10 +29,11 @@ use eframe::egui::TextFormat; use eframe::egui::Ui; use eframe::egui::Vec2; use komorebi_client::Container; -use komorebi_client::NotificationEvent; use komorebi_client::PathExt; use komorebi_client::Rect; use komorebi_client::SocketMessage; +use komorebi_client::SocketMessage::*; +use komorebi_client::State; use komorebi_client::Window; use komorebi_client::Workspace; use komorebi_client::WorkspaceLayer; @@ -41,7 +42,8 @@ use serde::Serialize; use std::cell::RefCell; use std::collections::BTreeMap; use std::collections::HashMap; -use std::path::PathBuf; +use std::io::Result as IoResult; +use std::path::Path; use std::rc::Rc; use std::sync::atomic::Ordering; @@ -128,44 +130,30 @@ pub struct KomorebiConfigurationSwitcherConfig { } impl From<&KomorebiConfig> for Komorebi { - fn from(value: &KomorebiConfig) -> Self { - let configuration_switcher = - if let Some(configuration_switcher) = &value.configuration_switcher { - let mut configuration_switcher = configuration_switcher.clone(); - for (_, location) in configuration_switcher.configurations.iter_mut() { - *location = dunce::simplified(&PathBuf::from(location.clone()).replace_env()) - .to_string_lossy() - .to_string(); - } - Some(configuration_switcher) - } else { - None - }; + fn from(cfg: &KomorebiConfig) -> Self { + let configuration_switcher = cfg.configuration_switcher.clone().map(|mut cs| { + for location in cs.configurations.values_mut() { + let path = Path::new(location).replace_env(); + *location = dunce::simplified(&path).to_string_lossy().to_string(); + } + cs + }); Self { - komorebi_notification_state: Rc::new(RefCell::new(KomorebiNotificationState { - selected_workspace: String::new(), - layout: KomorebiLayout::Default(komorebi_client::DefaultLayout::BSP), - workspaces: vec![], - hide_empty_workspaces: value + monitor_info: Rc::new(RefCell::new(MonitorInfo { + hide_empty_workspaces: cfg .workspaces .map(|w| w.hide_empty_workspaces) .unwrap_or_default(), - mouse_follows_focus: true, - work_area_offset: None, - focused_container_information: ( - false, - KomorebiNotificationStateContainerInformation::EMPTY, - ), - stack_accent: None, - monitor_index: MONITOR_INDEX.load(Ordering::SeqCst), - monitor_usr_idx_map: HashMap::new(), + ..Default::default() })), - workspaces: value.workspaces, - layout: value.layout.clone(), - focused_container: value.focused_container, - workspace_layer: value.workspace_layer, - locked_container: value.locked_container, + workspaces: cfg.workspaces.and_then(WorkspacesBar::try_from), + layout: cfg.layout.clone(), + focused_container: cfg + .focused_container + .and_then(FocusedContainerBar::try_from), + workspace_layer: cfg.workspace_layer.and_then(WorkspaceLayerBar::try_from), + locked_container: cfg.locked_container.and_then(LockedContainerBar::try_from), configuration_switcher, } } @@ -173,761 +161,857 @@ impl From<&KomorebiConfig> for Komorebi { #[derive(Clone, Debug)] pub struct Komorebi { - pub komorebi_notification_state: Rc>, - pub workspaces: Option, + pub monitor_info: Rc>, + pub workspaces: Option, pub layout: Option, - pub focused_container: Option, - pub workspace_layer: Option, - pub locked_container: Option, + pub focused_container: Option, + pub workspace_layer: Option, + pub locked_container: Option, pub configuration_switcher: Option, } impl BarWidget for Komorebi { fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) { - let mut komorebi_notification_state = self.komorebi_notification_state.borrow_mut(); - let icon_size = Vec2::splat(config.icon_font_id.size); - let text_size = Vec2::splat(config.text_font_id.size); + self.render_workspaces(ctx, ui, config); + self.render_workspace_layer(ctx, ui, config); + self.render_layout(ctx, ui, config); + self.render_config_switcher(ui, config); + self.render_locked_container(ctx, ui, config); + self.render_focused_container(ctx, ui, config); + } +} - if let Some(workspaces) = self.workspaces { - if workspaces.enable { - let mut update = None; +impl Komorebi { + /// Renders the workspace bar for the current monitor. + /// Updates the focused workspace when a workspace is clicked. + fn render_workspaces(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) { + let monitor_info = &mut *self.monitor_info.borrow_mut(); - if !komorebi_notification_state.workspaces.is_empty() { - let format = workspaces.display.unwrap_or(DisplayFormat::Text.into()); + let bar = match &mut self.workspaces { + Some(wg) if !monitor_info.workspaces.is_empty() => wg, + _ => return, + }; - config.apply_on_widget(false, ui, |ui| { - for (i, (ws, containers, _, should_show)) in - komorebi_notification_state.workspaces.iter().enumerate() - { - if *should_show { - let is_selected = komorebi_notification_state.selected_workspace.eq(ws); + bar.text_size = Vec2::splat(config.text_font_id.size); + bar.icon_size = Vec2::splat(config.icon_font_id.size); - if SelectableFrame::new( - is_selected, - ) - .show(ui, |ui| { - let mut has_icon = false; - - if format == WorkspacesDisplayFormat::AllIcons - || format == WorkspacesDisplayFormat::AllIconsAndText - || format == WorkspacesDisplayFormat::AllIconsAndTextOnSelected - || format == DisplayFormat::Icon.into() - || format == DisplayFormat::IconAndText.into() - || format == DisplayFormat::IconAndTextOnSelected.into() - || (format == DisplayFormat::TextAndIconOnSelected.into() && is_selected) - { - has_icon = containers.iter().any(|(_, container_info)| { - container_info.icons.iter().any(|icon| icon.is_some()) - }); - - if has_icon { - Frame::NONE - .inner_margin(Margin::same( - ui.style().spacing.button_padding.y as i8, - )) - .show(ui, |ui| { - for (is_focused, container) in containers { - for icon in container.icons.iter().flatten().collect::>() { - ui.add( - Image::from(&icon.texture(ctx)) - .maintain_aspect_ratio(true) - .fit_to_exact_size(if *is_focused { icon_size } else { text_size }), - ); - } - } - }); - } - } - - // draw a custom icon when there is no app icon or text - if !has_icon && (matches!(format, WorkspacesDisplayFormat::AllIcons | WorkspacesDisplayFormat::Existing(DisplayFormat::Icon)) - || (!is_selected && matches!(format, WorkspacesDisplayFormat::AllIconsAndTextOnSelected | WorkspacesDisplayFormat::Existing(DisplayFormat::IconAndTextOnSelected)))) { - let (response, painter) = - ui.allocate_painter(icon_size, Sense::hover()); - let stroke = Stroke::new( - 1.0, - if is_selected { ctx.style().visuals.selection.stroke.color} else { 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); - - response.on_hover_text(ws.to_string()) - // add hover text when there are only icons - } else if match format { - WorkspacesDisplayFormat::AllIcons | WorkspacesDisplayFormat::Existing(DisplayFormat::Icon) => has_icon, - _ => false, - } { - ui.response().on_hover_text(ws.to_string()) - // add label only - } else if (format != WorkspacesDisplayFormat::AllIconsAndTextOnSelected && format != DisplayFormat::IconAndTextOnSelected.into()) - || (is_selected && matches!(format, WorkspacesDisplayFormat::AllIconsAndTextOnSelected | WorkspacesDisplayFormat::Existing(DisplayFormat::IconAndTextOnSelected))) - { - if is_selected { - ui.add(Label::new(RichText::new(ws.to_string()).color(ctx.style().visuals.selection.stroke.color)).selectable(false)) - } - else { - ui.add(Label::new(ws.to_string()).selectable(false)) - } - } else { - ui.response() - } - }) - .clicked() - { - update = Some(ws.to_string()); - - if komorebi_notification_state.mouse_follows_focus { - if komorebi_client::send_batch([ - SocketMessage::MouseFollowsFocus(false), - SocketMessage::FocusMonitorWorkspaceNumber( - komorebi_notification_state.monitor_index, - i, - ), - SocketMessage::MouseFollowsFocus(true), - ]) - .is_err() - { - tracing::error!( - "could not send the following batch of messages to komorebi:\n - MouseFollowsFocus(false)\n - FocusMonitorWorkspaceNumber({}, {})\n - MouseFollowsFocus(true)\n", - komorebi_notification_state.monitor_index, - i, - ); - } - } else if komorebi_client::send_batch([ - SocketMessage::FocusMonitorWorkspaceNumber( - komorebi_notification_state.monitor_index, - i, - ), - ]) - .is_err() - { - tracing::error!( - "could not send the following batch of messages to komorebi:\n - FocusMonitorWorkspaceNumber({}, {})\n", - komorebi_notification_state.monitor_index, - i, - ); - } - } - } - } - }); + config.apply_on_widget(false, ui, |ui| { + for (index, workspace) in monitor_info.workspaces.iter().enumerate() { + if !workspace.should_show { + continue; } - if let Some(update) = update { - komorebi_notification_state.selected_workspace = update; - } - } - } + let response = SelectableFrame::new(workspace.is_selected) + .show(ui, |ui| (bar.renderer)(bar, ctx, ui, workspace)); - if let Some(layer_config) = &self.workspace_layer { - if layer_config.enable { - let layer = komorebi_notification_state - .workspaces - .iter() - .find(|o| komorebi_notification_state.selected_workspace.eq(&o.0)) - .map(|(_, _, layer, _)| layer); - - if let Some(layer) = layer { - if (layer_config.show_when_tiling.unwrap_or_default() - && matches!(layer, WorkspaceLayer::Tiling)) - || matches!(layer, WorkspaceLayer::Floating) - { - let display_format = layer_config.display.unwrap_or(DisplayFormat::Text); - let size = Vec2::splat(config.icon_font_id.size); - - config.apply_on_widget(false, ui, |ui| { - let layer_frame = SelectableFrame::new(false) - .show(ui, |ui| { - if display_format != DisplayFormat::Text { - if matches!(layer, WorkspaceLayer::Tiling) { - let (response, painter) = - ui.allocate_painter(size, Sense::hover()); - let color = ctx.style().visuals.selection.stroke.color; - let stroke = Stroke::new(1.0, color); - let mut rect = response.rect; - let corner = - CornerRadius::same((rect.width() * 0.1) as u8); - rect = rect.shrink(stroke.width); - - // tiling - let mut rect_left = response.rect; - rect_left.set_width(rect.width() * 0.48); - rect_left.set_height(rect.height() * 0.98); - let mut rect_right = rect_left; - rect_left = rect_left.translate(Vec2::new( - rect.width() * 0.01 + stroke.width, - rect.width() * 0.01 + stroke.width, - )); - rect_right = rect_right.translate(Vec2::new( - rect.width() * 0.51 + stroke.width, - rect.width() * 0.01 + stroke.width, - )); - painter.rect_filled(rect_left, corner, color); - painter.rect_stroke( - rect_right, - corner, - stroke, - StrokeKind::Outside, - ); - } else { - let (response, painter) = - ui.allocate_painter(size, Sense::hover()); - let color = ctx.style().visuals.selection.stroke.color; - let stroke = Stroke::new(1.0, color); - let mut rect = response.rect; - let corner = - CornerRadius::same((rect.width() * 0.1) as u8); - rect = rect.shrink(stroke.width); - - // floating - let mut rect_left = response.rect; - rect_left.set_width(rect.width() * 0.65); - rect_left.set_height(rect.height() * 0.65); - let mut rect_right = rect_left; - rect_left = rect_left.translate(Vec2::new( - rect.width() * 0.01 + stroke.width, - rect.width() * 0.01 + stroke.width, - )); - rect_right = rect_right.translate(Vec2::new( - rect.width() * 0.34 + stroke.width, - rect.width() * 0.34 + stroke.width, - )); - painter.rect_filled(rect_left, corner, color); - painter.rect_stroke( - rect_right, - corner, - stroke, - StrokeKind::Outside, - ); - } - } - - if display_format != DisplayFormat::Icon { - ui.add(Label::new(layer.to_string()).selectable(false)); - } - }) - .on_hover_text(layer.to_string()); - - if layer_frame.clicked() - && komorebi_client::send_batch([ - SocketMessage::FocusMonitorAtCursor, - SocketMessage::MouseFollowsFocus(false), - SocketMessage::ToggleWorkspaceLayer, - SocketMessage::MouseFollowsFocus( - komorebi_notification_state.mouse_follows_focus, - ), - ]) - .is_err() - { - tracing::error!( - "could not send the following batch of messages to komorebi:\n\ - MouseFollowsFocus(false), - ToggleWorkspaceLayer, - MouseFollowsFocus({})", - komorebi_notification_state.mouse_follows_focus, - ); - } - }); + if response.clicked() { + let message = FocusMonitorWorkspaceNumber(monitor_info.monitor_index, index); + if Self::send_with_mouse_follow_off(monitor_info, message).is_ok() { + monitor_info.focused_workspace_idx = Some(index); } } } - } + }); + } + /// Renders the workspace layer bar for the current monitor. + /// Handles user clicks to toggle the workspace layer. + fn render_workspace_layer(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) { + let Some(bar) = &self.workspace_layer else { + return; + }; + + let monitor_info = &*self.monitor_info.borrow(); + let Some(layer) = monitor_info.focused_workspace_layer() else { + return; + }; + + if (matches!(layer, WorkspaceLayer::Floating) + || bar.show_when_tiling && matches!(layer, WorkspaceLayer::Tiling)) + { + let size = Vec2::splat(config.icon_font_id.size); + config.apply_on_widget(false, ui, |ui| { + let layer_frame = SelectableFrame::new(false) + .show(ui, |ui| (bar.renderer)(ctx, ui, &layer, size)) + .on_hover_text(layer.to_string()); + + if layer_frame.clicked() { + let _ = Self::send_messages(&[ + FocusMonitorAtCursor, + MouseFollowsFocus(false), + ToggleWorkspaceLayer, + MouseFollowsFocus(monitor_info.mouse_follows_focus), + ]); + } + }); + } + } + + /// Renders the configuration switcher bar. + /// For each available configuration, displays a selectable label. + /// On click, sends a message to replace the current Komorebi configuration. + fn render_config_switcher(&mut self, ui: &mut Ui, config: &mut RenderConfig) { + let configuration_switcher = match &self.configuration_switcher { + Some(config) if config.enable => config, + _ => return, + }; + for (name, location) in configuration_switcher.configurations.iter() { + let path = Path::new(location); + if path.is_file() { + config.apply_on_widget(false, ui, |ui| { + let response = SelectableFrame::new(false) + .show(ui, |ui| ui.add(Label::new(name).selectable(false))); + if response.clicked() { + let path = dunce::canonicalize(path).unwrap_or_else(|_| path.to_owned()); + let _ = Self::send_messages(&[ReplaceConfiguration(path)]); + } + }); + } + } + } + + fn render_layout(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) { if let Some(layout_config) = &self.layout { if layout_config.enable { - let workspace_idx: Option = komorebi_notification_state - .workspaces - .iter() - .position(|o| komorebi_notification_state.selected_workspace.eq(&o.0)); - - komorebi_notification_state.layout.show( - ctx, - ui, - config, - layout_config, - workspace_idx, - ); + let monitor_info = &mut *self.monitor_info.borrow_mut(); + let workspace_idx = monitor_info.focused_workspace_idx; + monitor_info + .layout + .show(ctx, ui, config, layout_config, workspace_idx); } } + } - if let Some(configuration_switcher) = &self.configuration_switcher { - if configuration_switcher.enable { - for (name, location) in configuration_switcher.configurations.iter() { - let path = PathBuf::from(location); - if path.is_file() { - config.apply_on_widget(false, ui, |ui| { - if SelectableFrame::new(false) - .show(ui, |ui| ui.add(Label::new(name).selectable(false))) - .clicked() - { - let canonicalized = - dunce::canonicalize(path.clone()).unwrap_or(path); + /// Renders the locked container bar for the current monitor. + /// + /// Shows lock status and allows toggling the lock state when clicked. + /// Only renders if a focused container exists and either the container is locked + /// or `show_when_unlocked` is enabled in the bar configuration. + fn render_locked_container(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) { + let Some(bar) = &self.locked_container else { + return; + }; - if komorebi_client::send_message( - &SocketMessage::ReplaceConfiguration(canonicalized), - ) - .is_err() - { - tracing::error!( - "could not send message to komorebi: ReplaceConfiguration" - ); - } - } - }); - } + let monitor_info = &*self.monitor_info.borrow(); + let is_locked = monitor_info + .focused_container() + .map(|container| container.is_locked) + .unwrap_or_default(); + + let (icon_font, txt_font) = (config.icon_font_id.clone(), config.text_font_id.clone()); + if (bar.show_when_unlocked || is_locked) && monitor_info.focused_container().is_some() { + config.apply_on_widget(false, ui, |ui| { + SelectableFrame::new(false) + .show(ui, |ui| { + (bar.renderer)(ctx, ui, is_locked, icon_font, txt_font) + }) + .clicked() + .then(|| Self::send_messages(&[FocusMonitorAtCursor, ToggleLock])); + }); + } + } + + fn render_focused_container(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) { + let Some(bar) = &self.focused_container else { + return; + }; + let monitor_info = &*self.monitor_info.borrow(); + let Some(container) = monitor_info.focused_container() else { + return; + }; + config.apply_on_widget(false, ui, |ui| { + let (len, focused_idx) = (container.windows.len(), container.focused_window_idx); + + for (idx, window) in container.windows.iter().enumerate() { + let selected = idx == focused_idx && len != 1; + let text_color = if selected { + ctx.style().visuals.selection.stroke.color + } else { + ui.style().visuals.text_color() + }; + + let response = SelectableFrame::new(selected).show(ui, |ui| { + (bar.renderer)(bar, ctx, ui, window, text_color, idx == focused_idx) + }); + + if response.clicked() && !selected { + let _ = Self::send_with_mouse_follow_off(monitor_info, FocusStackWindow(idx)); } } + }); + } + + /// Sends a message to Komorebi, temporarily disabling MouseFollowsFocus if it's enabled. + fn send_with_mouse_follow_off(monitor: &MonitorInfo, message: SocketMessage) -> IoResult<()> { + let messages: &[SocketMessage] = if monitor.mouse_follows_focus { + &[MouseFollowsFocus(false), message, MouseFollowsFocus(true)] + } else { + &[message] + }; + Self::send_messages(messages) + } + + /// Sends a batch of messages to Komorebi, logging errors on failure. + fn send_messages(messages: &[SocketMessage]) -> IoResult<()> { + komorebi_client::send_batch(messages).map_err(|err| { + tracing::error!("Failed to send message(s): {:?}\nError: {}", messages, err); + err + }) + } +} + +/// Workspace bar with a pre-selected render strategy for efficient +/// workspace display +#[derive(Clone, Debug)] +pub struct WorkspacesBar { + /// Chosen rendering function for this widget + renderer: fn(&Self, &Context, &mut Ui, &WorkspaceInfo), + /// Text size (default: 12.5) + text_size: Vec2, + /// Icon size (default: 12.5 * 1.4) + icon_size: Vec2, +} + +impl WorkspacesBar { + /// Creates a `WorkspacesBar` instance from a workspace configuration. + /// + /// Selects a render strategy based on the given display format + /// for optimal performance. Returns `None` if the widget is disabled. + fn try_from(value: KomorebiWorkspacesConfig) -> Option { + use WorkspacesDisplayFormat::*; + if !value.enable { + return None; } + // Selects a render strategy according to the workspace config's display format + // for better performance + let renderer: fn(&Self, &Context, &mut Ui, &WorkspaceInfo) = + match value.display.unwrap_or(DisplayFormat::Text.into()) { + // 1: Show icons if any, fallback if none | Only hover workspace name + AllIcons | Existing(DisplayFormat::Icon) => |bar, ctx, ui, ws| { + bar.show_icons(ctx, ui, ws) + .unwrap_or_else(|| bar.show_fallback_icon(ctx, ui, ws)) + .on_hover_text(&ws.name); + }, + // 2: Show icons, with no fallback | Label workspace name (no hover) + AllIconsAndText | Existing(DisplayFormat::IconAndText) => |bar, ctx, ui, ws| { + bar.show_icons(ctx, ui, ws); + Self::show_label(ctx, ui, ws); + }, + // 3: Show icons, fallback if no icons and not selected | Label workspace name if selected else hover + AllIconsAndTextOnSelected | Existing(DisplayFormat::IconAndTextOnSelected) => { + |bar, ctx, ui, ws| { + if bar.show_icons(ctx, ui, ws).is_none() && !ws.is_selected { + bar.show_fallback_icon(ctx, ui, ws); + } + if ws.is_selected { + Self::show_label(ctx, ui, ws); + } else { + ui.response().on_hover_text(&ws.name); + } + } + } + // 4: Show icons if selected and has icons (no fallback) | Label workspace name + Existing(DisplayFormat::TextAndIconOnSelected) => |bar, ctx, ui, ws| { + if ws.is_selected { + bar.show_icons(ctx, ui, ws); + } + Self::show_label(ctx, ui, ws); + }, + // 5: Never show icon (no icons at all) | Label workspace name always + Existing(DisplayFormat::Text) => |_, ctx, ui, ws| { + Self::show_label(ctx, ui, ws); + }, + }; - if let Some(locked_container_config) = self.locked_container { - if locked_container_config.enable { - let is_locked = komorebi_notification_state.focused_container_information.0; + Some(Self { + renderer, + icon_size: Vec2::splat(12.5), + text_size: Vec2::splat(12.5 * 1.4), + }) + } - if locked_container_config - .show_when_unlocked - .unwrap_or_default() - || is_locked - { - let titles = &komorebi_notification_state - .focused_container_information - .1 - .titles; - - if !titles.is_empty() { - let display_format = locked_container_config - .display - .unwrap_or(DisplayFormat::Text); - - let mut layout_job = LayoutJob::simple( - if display_format != DisplayFormat::Text { - if is_locked { - egui_phosphor::regular::LOCK_KEY.to_string() - } else { - egui_phosphor::regular::LOCK_SIMPLE_OPEN.to_string() - } - } else { - String::new() - }, - config.icon_font_id.clone(), - ctx.style().visuals.selection.stroke.color, - 100.0, - ); - - if display_format != DisplayFormat::Icon { - layout_job.append( - if is_locked { "Locked" } else { "Unlocked" }, - 10.0, - TextFormat { - font_id: config.text_font_id.clone(), - color: ctx.style().visuals.text_color(), - valign: Align::Center, - ..Default::default() - }, + /// Draws all window icons for the workspace, using larger size for the focused container. + /// Returns response if icons exist, or None. + fn show_icons(&self, ctx: &Context, ui: &mut Ui, ws: &WorkspaceInfo) -> Option { + ws.has_icons.then(|| { + Frame::NONE + .inner_margin(Margin::same(ui.style().spacing.button_padding.y as i8)) + .show(ui, |ui| { + for container in &ws.containers { + for icon in container.windows.iter().filter_map(|win| win.icon.as_ref()) { + ui.add( + Image::from(&icon.texture(ctx)) + .maintain_aspect_ratio(true) + .fit_to_exact_size(if container.is_focused { + self.icon_size + } else { + self.text_size + }), ); } - - config.apply_on_widget(false, ui, |ui| { - if SelectableFrame::new(false) - .show(ui, |ui| ui.add(Label::new(layout_job).selectable(false))) - .clicked() - && komorebi_client::send_batch([ - SocketMessage::FocusMonitorAtCursor, - SocketMessage::ToggleLock, - ]) - .is_err() - { - tracing::error!("could not send ToggleLock"); - } - }); } - } - } - } + }) + .response + }) + } - if let Some(focused_container_config) = self.focused_container { - if focused_container_config.enable { - let titles = &komorebi_notification_state - .focused_container_information - .1 - .titles; + /// Draws a fallback icon (a rectangle with a diagonal) for the workspace. + fn show_fallback_icon(&self, ctx: &Context, ui: &mut Ui, ws: &WorkspaceInfo) -> Response { + let (response, painter) = ui.allocate_painter(self.icon_size, Sense::hover()); + let stroke: Stroke = Stroke::new( + 1.0, + if ws.is_selected { + ctx.style().visuals.selection.stroke.color + } else { + 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); + response + } - if !titles.is_empty() { - config.apply_on_widget(false, ui, |ui| { - let icons = &komorebi_notification_state - .focused_container_information.1 - .icons; - let focused_window_idx = komorebi_notification_state - .focused_container_information.1 - .focused_window_idx; - - let iter = titles.iter().zip(icons.iter()); - let len = iter.len(); - - for (i, (title, icon)) in iter.enumerate() { - let selected = i == focused_window_idx && len != 1; - let text_color = if selected { ctx.style().visuals.selection.stroke.color } else { ui.style().visuals.text_color() }; - - if SelectableFrame::new(selected) - .show(ui, |ui| { - // handle legacy setting - let format = focused_container_config.display.unwrap_or( - if focused_container_config.show_icon.unwrap_or(false) { - DisplayFormat::IconAndText - } else { - DisplayFormat::Text - }, - ); - - if format == DisplayFormat::Icon - || format == DisplayFormat::IconAndText - || format == DisplayFormat::IconAndTextOnSelected - || (format == DisplayFormat::TextAndIconOnSelected - && i == focused_window_idx) - { - if let Some(img) = icon { - Frame::NONE - .inner_margin(Margin::same( - ui.style().spacing.button_padding.y as i8, - )) - .show(ui, |ui| { - let response = ui.add( - Image::from(&img.texture(ctx) ) - .maintain_aspect_ratio(true) - .fit_to_exact_size(icon_size), - ); - - if let DisplayFormat::Icon = format { - response.on_hover_text(title); - } - }); - } - } - - if format == DisplayFormat::Text - || format == DisplayFormat::IconAndText - || format == DisplayFormat::TextAndIconOnSelected - || (format == DisplayFormat::IconAndTextOnSelected - && i == focused_window_idx) - { - let available_height = ui.available_height(); - let mut custom_ui = CustomUi(ui); - - custom_ui.add_sized_left_to_right( - Vec2::new( - MAX_LABEL_WIDTH.load(Ordering::SeqCst) as f32, - available_height, - ), - Label::new(RichText::new( title).color(text_color)).selectable(false).truncate(), - ); - } - }) - .clicked() - { - if selected { - return; - } - - if komorebi_notification_state.mouse_follows_focus { - if komorebi_client::send_batch([ - SocketMessage::MouseFollowsFocus(false), - SocketMessage::FocusStackWindow(i), - SocketMessage::MouseFollowsFocus(true), - ]).is_err() { - tracing::error!( - "could not send the following batch of messages to komorebi:\n - MouseFollowsFocus(false)\n - FocusStackWindow({})\n - MouseFollowsFocus(true)\n", - i, - ); - } - } else if komorebi_client::send_message( - &SocketMessage::FocusStackWindow(i) - ).is_err() { - tracing::error!( - "could not send message to komorebi: FocusStackWindow" - ); - } - } - } - }); - } - } + /// Shows the workspace label (colored if selected). + fn show_label(ctx: &Context, ui: &mut Ui, ws: &WorkspaceInfo) -> Response { + if ws.is_selected { + let text = RichText::new(&ws.name).color(ctx.style().visuals.selection.stroke.color); + ui.add(Label::new(text).selectable(false)) + } else { + ui.add(Label::new(&ws.name).selectable(false)) } } } -#[allow(clippy::type_complexity)] +/// Structure for displaying the focused container's windows in the bar. +/// Uses a pre-selected rendering strategy (based on config) for efficient display #[derive(Clone, Debug)] -pub struct KomorebiNotificationState { - pub workspaces: Vec<( - String, - Vec<(bool, KomorebiNotificationStateContainerInformation)>, - WorkspaceLayer, - bool, - )>, - pub selected_workspace: String, - pub focused_container_information: (bool, KomorebiNotificationStateContainerInformation), - pub layout: KomorebiLayout, - pub hide_empty_workspaces: bool, - pub mouse_follows_focus: bool, - pub work_area_offset: Option, - pub stack_accent: Option, - pub monitor_index: usize, - pub monitor_usr_idx_map: HashMap, +pub struct FocusedContainerBar { + /// Chosen rendering function for this widget + renderer: fn(&Self, &Context, &mut Ui, &WindowInfo, Color32, focused: bool), + /// Icon size (default: 12.5 * 1.4) + icon_size: Vec2, } -impl KomorebiNotificationState { - pub fn update_from_config(&mut self, config: &Self) { +impl FocusedContainerBar { + /// Creates a `FocusedContainerBar` from the given configuration. + /// + /// Selects a render strategy based on the display format or legacy icon setting. + /// Returns `None` if the widget is disabled. + fn try_from(value: KomorebiFocusedContainerConfig) -> Option { + if !value.enable { + return None; + } + // Handle legacy setting - convert show_icon to display format + let format = value + .display + .unwrap_or(if value.show_icon.unwrap_or(false) { + IconAndText + } else { + Text + }); + + // Select renderer strategy based on display format for better performance + let renderer: fn(&FocusedContainerBar, &Context, &mut Ui, &WindowInfo, Color32, bool) = + match format { + Icon => |_self, ctx, ui, info, _color, _focused| { + FocusedContainerBar::show_icon::(_self, ctx, ui, info); + }, + Text => |_self, _ctx, ui, info, color, _focused| { + FocusedContainerBar::show_title(_self, ui, info, color); + }, + IconAndText => |_self, ctx, ui, info, color, _focused| { + FocusedContainerBar::show_icon::(_self, ctx, ui, info); + FocusedContainerBar::show_title(_self, ui, info, color); + }, + IconAndTextOnSelected => |_self, ctx, ui, info, color, focused| { + FocusedContainerBar::show_icon::(_self, ctx, ui, info); + if focused { + FocusedContainerBar::show_title(_self, ui, info, color); + } + }, + TextAndIconOnSelected => |_self, ctx, ui, info, color, focused| { + if focused { + FocusedContainerBar::show_icon::(_self, ctx, ui, info); + } + FocusedContainerBar::show_title(_self, ui, info, color); + }, + }; + + Some(FocusedContainerBar { + renderer, + icon_size: Vec2::splat(12.5 * 1.4), + }) + } + + /// Draws window icon(s) at configured size; adds hover text if HOVEL is true. + fn show_icon(&self, ctx: &Context, ui: &mut Ui, info: &WindowInfo) { + let inner_response = Frame::NONE + .inner_margin(Margin::same(ui.style().spacing.button_padding.y as i8)) + .show(ui, |ui| { + for icon in info.icon.iter() { + let img = Image::from(&icon.texture(ctx)).maintain_aspect_ratio(true); + ui.add(img.fit_to_exact_size(self.icon_size)); + } + }); + if HOVEL { + if let Some(title) = &info.title { + inner_response.response.on_hover_text(title); + } + } + } + + /// Renders the window title label in color, truncating if needed. + fn show_title(&self, ui: &mut Ui, info: &WindowInfo, color: Color32) { + if let Some(title) = &info.title { + let text = Label::new(RichText::new(title).color(color)).selectable(false); + + let x = MAX_LABEL_WIDTH.load(Ordering::SeqCst) as f32; + let y = ui.available_height(); + CustomUi(ui).add_sized_left_to_right(Vec2::new(x, y), text.truncate()); + } + } +} + +/// Workspace layer bar with a pre-selected render strategy for efficient +/// layer display +#[derive(Clone, Debug)] +pub struct WorkspaceLayerBar { + /// Show the widget even if the layer is Tiling + show_when_tiling: bool, + /// Chosen rendering function for this widget + renderer: fn(&Context, &mut Ui, &WorkspaceLayer, Vec2), +} + +impl WorkspaceLayerBar { + /// Creates a `WorkspaceLayerBar` from the given configuration. + /// + /// Selects a render strategy based on the display format. + /// Returns `None` if the widget is disabled. + fn try_from(value: KomorebiWorkspaceLayerConfig) -> Option { + if !value.enable { + return None; + } + let display_format = value.display.unwrap_or(Text); + + // Select renderer strategy based on display format for better performance + let renderer: fn(&Context, &mut Ui, &WorkspaceLayer, Vec2) = match display_format { + Icon => Self::draw_layer_icon, + Text => |_ctx, ui, layer, _size| { + ui.add(Label::new(layer.to_string()).selectable(false)); + }, + _ => |ctx, ui, layer, size| { + Self::draw_layer_icon(ctx, ui, layer, size); + ui.add(Label::new(layer.to_string()).selectable(false)); + }, + }; + + Some(Self { + show_when_tiling: value.show_when_tiling.unwrap_or_default(), + renderer, + }) + } + + /// Draws an icon representing the current workspace layer. + fn draw_layer_icon(ctx: &Context, ui: &mut Ui, layer: &WorkspaceLayer, size: Vec2) { + if matches!(layer, WorkspaceLayer::Tiling) { + let (response, painter) = ui.allocate_painter(size, Sense::hover()); + let color = ctx.style().visuals.selection.stroke.color; + let stroke = Stroke::new(1.0, color); + let mut rect = response.rect; + let corner = CornerRadius::same((rect.width() * 0.1) as u8); + rect = rect.shrink(stroke.width); + + // tiling + let mut rect_left = response.rect; + rect_left.set_width(rect.width() * 0.48); + rect_left.set_height(rect.height() * 0.98); + let mut rect_right = rect_left; + rect_left = rect_left.translate(Vec2::new( + rect.width() * 0.01 + stroke.width, + rect.width() * 0.01 + stroke.width, + )); + rect_right = rect_right.translate(Vec2::new( + rect.width() * 0.51 + stroke.width, + rect.width() * 0.01 + stroke.width, + )); + painter.rect_filled(rect_left, corner, color); + painter.rect_stroke(rect_right, corner, stroke, StrokeKind::Outside); + } else { + let (response, painter) = ui.allocate_painter(size, Sense::hover()); + let color = ctx.style().visuals.selection.stroke.color; + let stroke = Stroke::new(1.0, color); + let mut rect = response.rect; + let corner = CornerRadius::same((rect.width() * 0.1) as u8); + rect = rect.shrink(stroke.width); + + // floating + let mut rect_left = response.rect; + rect_left.set_width(rect.width() * 0.65); + rect_left.set_height(rect.height() * 0.65); + let mut rect_right = rect_left; + rect_left = rect_left.translate(Vec2::new( + rect.width() * 0.01 + stroke.width, + rect.width() * 0.01 + stroke.width, + )); + rect_right = rect_right.translate(Vec2::new( + rect.width() * 0.34 + stroke.width, + rect.width() * 0.34 + stroke.width, + )); + painter.rect_filled(rect_left, corner, color); + painter.rect_stroke(rect_right, corner, stroke, StrokeKind::Outside); + } + } +} + +/// Locked container bar for displaying and interacting with container lock state +#[derive(Clone, Debug)] +pub struct LockedContainerBar { + /// Show the widget even when unlocked + show_when_unlocked: bool, + /// Chosen rendering function for this widget + renderer: fn(&Context, &mut Ui, bool, FontId, FontId), +} + +impl LockedContainerBar { + /// Creates a `LockedContainerBar` from the given configuration. + /// + /// Selects a render strategy based on the display format. + /// Returns `None` if the widget is disabled. + fn try_from(value: KomorebiLockedContainerConfig) -> Option { + if !value.enable { + return None; + } + let display_format = value.display.unwrap_or(DisplayFormat::Text); + + // Select renderer strategy based on display format for better performance + let renderer: fn(&Context, &mut Ui, bool, FontId, FontId) = match display_format { + DisplayFormat::Icon => |ctx, ui, is_locked, icon_font, _txt_font| { + let layout_job = Self::icon_layout(ctx, is_locked, icon_font); + ui.add(Label::new(layout_job).selectable(false)); + }, + DisplayFormat::Text => |ctx, ui, is_locked, _icon_font, txt_font| { + let mut layout_job = LayoutJob::default(); + Self::append_text(&mut layout_job, ctx, is_locked, txt_font); + ui.add(Label::new(layout_job).selectable(false)); + }, + _ => |ctx, ui: &mut Ui, is_locked, icon_font, txt_font| { + let mut layout_job = Self::icon_layout(ctx, is_locked, icon_font); + Self::append_text(&mut layout_job, ctx, is_locked, txt_font); + ui.add(Label::new(layout_job).selectable(false)); + }, + }; + + Some(Self { + show_when_unlocked: value.show_when_unlocked.unwrap_or_default(), + renderer, + }) + } + + /// Builds a layout job for the lock/unlock icon, + /// using the provided font and current lock state. + fn icon_layout(ctx: &Context, is_locked: bool, icon_font: FontId) -> LayoutJob { + LayoutJob::simple( + if is_locked { + egui_phosphor::regular::LOCK_KEY.to_string() + } else { + egui_phosphor::regular::LOCK_SIMPLE_OPEN.to_string() + }, + icon_font, + ctx.style().visuals.selection.stroke.color, + 100.0, + ) + } + + /// Appends lock/unlock status text to the given layout job, + /// using the specified font and current lock state. + fn append_text(job: &mut LayoutJob, ctx: &Context, is_locked: bool, txt_font: FontId) { + job.append( + if is_locked { "Locked" } else { "Unlocked" }, + 10.0, + TextFormat { + font_id: txt_font, + color: ctx.style().visuals.text_color(), + valign: Align::Center, + ..Default::default() + }, + ) + } +} + +/// Represents the full state of a single monitor for the Komorebi bar/UI. +/// +/// Includes all workspaces, containers, windows, layout, focus, +/// display options, and monitor indices. Used to render the current monitor +/// state in UI widgets. +/// +/// Updated whenever Komorebi state changes. +#[derive(Clone, Debug)] +pub struct MonitorInfo { + pub workspaces: Vec, + pub layout: KomorebiLayout, + pub mouse_follows_focus: bool, + pub work_area_offset: Option, + pub monitor_index: usize, + pub monitor_usr_idx_map: HashMap, + pub focused_workspace_idx: Option, + pub show_all_icons: bool, + pub hide_empty_workspaces: bool, +} + +impl Default for MonitorInfo { + fn default() -> Self { + Self { + workspaces: Vec::new(), + layout: KomorebiLayout::Default(komorebi_client::DefaultLayout::BSP), + mouse_follows_focus: true, + work_area_offset: None, + monitor_index: MONITOR_INDEX.load(Ordering::SeqCst), + monitor_usr_idx_map: HashMap::new(), + focused_workspace_idx: None, + show_all_icons: false, + hide_empty_workspaces: false, + } + } +} + +impl MonitorInfo { + /// Returns a reference to the currently focused workspace, if any. + pub fn focused_workspace(&self) -> Option<&WorkspaceInfo> { + self.workspaces.get(self.focused_workspace_idx?) + } + + /// Returns the layer of the focused workspace, if available. + pub fn focused_workspace_layer(&self) -> Option { + self.focused_workspace().map(|ws| ws.layer) + } + + /// Returns the focused container within the focused workspace, if any. + pub fn focused_container(&self) -> Option<&ContainerInfo> { + self.focused_workspace() + .and_then(WorkspaceInfo::focused_container) + } +} + +impl MonitorInfo { + pub fn update_from_self(&mut self, config: &Self) { self.hide_empty_workspaces = config.hide_empty_workspaces; } - #[allow(clippy::too_many_arguments)] - pub fn handle_notification( - &mut self, - ctx: &Context, - monitor_index: Option, - notification: komorebi_client::Notification, - bg_color: Rc>, - bg_color_with_alpha: Rc>, - transparency_alpha: Option, - grouping: Option, - default_theme: Option, - render_config: Rc>, - ) { - let show_all_icons = render_config.borrow().show_all_icons; + /// Updates monitor state from the given State, setting all fields based on the selected + /// monitor and its workspaces + pub fn update(&mut self, monitor_index: Option, state: State, show_all_icons: bool) { + self.show_all_icons = show_all_icons; + self.monitor_usr_idx_map = state.monitor_usr_idx_map; - match notification.event { - NotificationEvent::VirtualDesktop(_) => {} - NotificationEvent::WindowManager(_) => {} - NotificationEvent::Monitor(_) => {} - NotificationEvent::Socket(message) => match message { - SocketMessage::ReloadStaticConfiguration(path) => { - if let Ok(config) = komorebi_client::StaticConfig::read(&path) { - if let Some(theme) = config.theme { - apply_theme( - ctx, - KomobarTheme::from(theme), - bg_color.clone(), - bg_color_with_alpha.clone(), - transparency_alpha, - grouping, - render_config, - ); - tracing::info!("applied theme from updated komorebi.json"); - } else if let Some(default_theme) = default_theme { - apply_theme( - ctx, - default_theme, - bg_color.clone(), - bg_color_with_alpha.clone(), - transparency_alpha, - grouping, - render_config, - ); - tracing::info!("removed theme from updated komorebi.json and applied default theme"); - } else { - tracing::warn!("theme was removed from updated komorebi.json but there was no default theme to apply"); - } - } - } - SocketMessage::Theme(theme) => { - apply_theme( - ctx, - KomobarTheme::from(*theme), - bg_color, - bg_color_with_alpha.clone(), - transparency_alpha, - grouping, - render_config, - ); - tracing::info!("applied theme from komorebi socket message"); - } - _ => {} - }, - } - - self.monitor_usr_idx_map = notification.state.monitor_usr_idx_map.clone(); - - if monitor_index.is_none() - || monitor_index.is_some_and(|idx| idx >= notification.state.monitors.elements().len()) - { + match monitor_index { + Some(idx) if idx < state.monitors.elements().len() => self.monitor_index = idx, // The bar's monitor is diconnected, so the bar is disabled no need to check anything // any further otherwise we'll get `OutOfBounds` panics. - return; - } - let monitor_index = monitor_index.expect("should have a monitor index"); - self.monitor_index = monitor_index; + _ => return, + }; + self.mouse_follows_focus = state.mouse_follows_focus; - self.mouse_follows_focus = notification.state.mouse_follows_focus; + let monitor = &state.monitors.elements()[self.monitor_index]; + self.work_area_offset = monitor.work_area_offset(); + self.focused_workspace_idx = Some(monitor.focused_workspace_idx()); - let monitor = ¬ification.state.monitors.elements()[monitor_index]; - self.work_area_offset = - notification.state.monitors.elements()[monitor_index].work_area_offset(); + // Layout + let focused_ws = &monitor.workspaces()[monitor.focused_workspace_idx()]; + self.layout = Self::resolve_layout(focused_ws, state.is_paused); - let focused_workspace_idx = monitor.focused_workspace_idx(); + self.workspaces.clear(); + self.workspaces.extend(Self::workspaces( + self.show_all_icons, + self.hide_empty_workspaces, + self.focused_workspace_idx, + monitor.workspaces().iter().enumerate(), + )); + } - let mut workspaces = vec![]; - - self.selected_workspace = monitor.workspaces()[focused_workspace_idx] - .name() - .to_owned() - .unwrap_or_else(|| format!("{}", focused_workspace_idx + 1)); - - for (i, ws) in monitor.workspaces().iter().enumerate() { - let should_show = if self.hide_empty_workspaces { - focused_workspace_idx == i || !ws.is_empty() - } else { - true - }; - - workspaces.push(( - ws.name().to_owned().unwrap_or_else(|| format!("{}", i + 1)), - if show_all_icons { - let mut containers = vec![]; - let mut has_monocle = false; - - // add monocle container - if let Some(container) = ws.monocle_container() { - containers.push((true, container.into())); - has_monocle = true; - } - - // add all tiled windows - for (i, container) in ws.containers().iter().enumerate() { - containers.push(( - !has_monocle && i == ws.focused_container_idx(), - container.into(), - )); - } - - // add all floating windows - for floating_window in ws.floating_windows() { - containers.push(( - !has_monocle && floating_window.is_focused(), - floating_window.into(), - )); - } - - containers - } else { - vec![(true, ws.into())] - }, - ws.layer().to_owned(), - should_show, - )); - } - - self.workspaces = workspaces; - - if monitor.workspaces()[focused_workspace_idx] - .monocle_container() - .is_some() - { - self.layout = KomorebiLayout::Monocle; - } else if !*monitor.workspaces()[focused_workspace_idx].tile() { - self.layout = KomorebiLayout::Floating; - } else if notification.state.is_paused { - self.layout = KomorebiLayout::Paused; + /// Builds an iterator of WorkspaceInfo for the monitor. + fn workspaces<'a, I>( + show_all_icons: bool, + hide_empty_ws: bool, + focused_ws_idx: Option, + iter: I, + ) -> impl Iterator + 'a + where + I: Iterator + 'a, + { + let fn_containers_from = if show_all_icons { + |ws| ContainerInfo::from_all_containers(ws) } else { - self.layout = match monitor.workspaces()[focused_workspace_idx].layout() { + |ws| { + ContainerInfo::from_focused_container(ws) + .into_iter() + .collect() + } + }; + iter.map(move |(index, ws)| { + let containers = fn_containers_from(ws); + WorkspaceInfo { + name: ws + .name() + .to_owned() + .unwrap_or_else(|| format!("{}", index + 1)), + focused_container_idx: containers.iter().position(|c| c.is_focused), + has_icons: containers + .iter() + .any(|container| container.windows.iter().any(|window| window.icon.is_some())), + containers, + layer: *ws.layer(), + should_show: !hide_empty_ws || focused_ws_idx == Some(index) || !ws.is_empty(), + is_selected: focused_ws_idx == Some(index), + } + }) + } + + /// Determines the current layout of the focused workspace + fn resolve_layout(focused_ws: &Workspace, is_paused: bool) -> KomorebiLayout { + if focused_ws.monocle_container().is_some() { + KomorebiLayout::Monocle + } else if !focused_ws.tile() { + KomorebiLayout::Floating + } else if is_paused { + KomorebiLayout::Paused + } else { + match focused_ws.layout() { komorebi_client::Layout::Default(layout) => KomorebiLayout::Default(*layout), komorebi_client::Layout::Custom(_) => KomorebiLayout::Custom, - }; - } - - let focused_workspace = &monitor.workspaces()[focused_workspace_idx]; - let is_locked = match focused_workspace.focused_container() { - Some(container) => container.locked(), - None => false, - }; - - self.focused_container_information = (is_locked, focused_workspace.into()); - } -} - -#[derive(Clone, Debug)] -pub struct KomorebiNotificationStateContainerInformation { - pub titles: Vec, - pub icons: Vec>, - pub focused_window_idx: usize, -} - -impl From<&Workspace> for KomorebiNotificationStateContainerInformation { - fn from(value: &Workspace) -> Self { - let mut container_info = Self::EMPTY; - - if let Some(container) = value.monocle_container() { - container_info = container.into(); - } else if let Some(container) = value.focused_container() { - container_info = container.into(); - } - - for floating_window in value.floating_windows() { - if floating_window.is_focused() { - container_info = floating_window.into(); } } - - container_info } } -impl From<&Container> for KomorebiNotificationStateContainerInformation { - fn from(value: &Container) -> Self { - let windows = value.windows().iter().collect::>(); +/// Describes the state of a single workspace on a monitor. +/// +/// Contains the workspace name, its containers (tiled, floating, +/// monocle), focus state, layer (tiling/floating), and display +/// flags for UI rendering. +#[derive(Clone, Debug)] +pub struct WorkspaceInfo { + pub name: String, + pub containers: Vec, + pub focused_container_idx: Option, + pub layer: WorkspaceLayer, + pub should_show: bool, + pub is_selected: bool, + pub has_icons: bool, +} - let icons = windows - .iter() - .map(|window| { - ImageIcon::try_load(window.hwnd, || { - windows_icons::get_icon_by_hwnd(window.hwnd).or_else(|| { - windows_icons_fallback::get_icon_by_process_id(window.process_id()) - }) - }) - }) - .collect::>(); - - Self { - titles: value - .windows() - .iter() - .map(|w| w.title().unwrap_or_default()) - .collect::>(), - icons, - focused_window_idx: value.focused_window_idx(), - } +impl WorkspaceInfo { + /// Returns a reference to the focused container in this workspace, if any. + pub fn focused_container(&self) -> Option<&ContainerInfo> { + self.containers.get(self.focused_container_idx?) } } -impl From<&Window> for KomorebiNotificationStateContainerInformation { - fn from(value: &Window) -> Self { - let icons = ImageIcon::try_load(value.hwnd, || { - windows_icons::get_icon_by_hwnd(value.hwnd) - .or_else(|| windows_icons_fallback::get_icon_by_process_id(value.process_id())) +/// Holds information about a window container (tiled, floating, or monocle) +/// within a workspace. +/// +/// Includes a list of windows, the focused window index, and flags for focus +/// and lock status. +#[derive(Clone, Debug)] +pub struct ContainerInfo { + pub windows: Vec, + pub focused_window_idx: usize, + pub is_focused: bool, + pub is_locked: bool, +} + +impl ContainerInfo { + /// Returns all containers for the given workspace in the following order: + /// + /// 1. The monocle container (if present) is included first. + /// 2. All tiled containers are included next. + /// 3. All floating windows are added last, each as a separate container. + /// + /// Function ensures only one container is marked as focused, prioritizing + /// floating → monocle → tiled. + pub fn from_all_containers(ws: &Workspace) -> Vec { + let has_focused_float = ws.floating_windows().iter().any(|w| w.is_focused()); + + // Monocle container first if present + let monocle = ws + .monocle_container() + .as_ref() + .map(|c| Self::from_container(c, !has_focused_float)); + + // All tiled containers, focus only if there's no monocle/focused float + let has_focused_monocle_or_float = has_focused_float || monocle.is_some(); + let tiled = ws.containers().iter().enumerate().map(|(i, c)| { + let is_focused = !has_focused_monocle_or_float && i == ws.focused_container_idx(); + Self::from_container(c, is_focused) }); + // All floating windows + let floats = ws.floating_windows().iter().map(Self::from_window); + // All windows + monocle.into_iter().chain(tiled).chain(floats).collect() + } + + /// Creates a `ContainerInfo` for the currently focused item in the workspace. + /// + /// The function checks focus in the following order: + /// 1. Focused floating window + /// 2. Monocle container + /// 3. Focused tiled container + pub fn from_focused_container(ws: &Workspace) -> Option { + if let Some(window) = ws.floating_windows().iter().find(|w| w.is_focused()) { + return Some(Self::from_window(window)); + } + if let Some(container) = ws.monocle_container() { + Some(Self::from_container(container, true)) + } else { + ws.focused_container() + .map(|container| Self::from_container(container, true)) + } + } + + /// Creates a `ContainerInfo` from a given container. + pub fn from_container(container: &Container, is_focused: bool) -> Self { Self { - titles: vec![value.title().unwrap_or_default()], - icons: vec![icons], + windows: container.windows().iter().map(WindowInfo::from).collect(), + focused_window_idx: container.focused_window_idx(), + is_focused, + is_locked: container.locked(), + } + } + + /// Creates a `ContainerInfo` from a single floating window. + /// The window becomes the only entry in `windows`, is marked as focused + /// if applicable, and `is_locked` is set to false. + pub fn from_window(window: &Window) -> Self { + Self { + windows: vec![window.into()], focused_window_idx: 0, + is_focused: window.is_focused(), + is_locked: false, // locked is only container feature } } } -impl KomorebiNotificationStateContainerInformation { - pub const EMPTY: Self = Self { - titles: vec![], - icons: vec![], - focused_window_idx: 0, - }; +/// Stores basic information about a single window in a container. +/// Contains the window's title and its icon, if available. +#[derive(Clone, Debug)] +pub struct WindowInfo { + pub title: Option, + pub icon: Option, +} + +impl From<&Window> for WindowInfo { + fn from(value: &Window) -> Self { + Self { + title: value.title().ok(), + icon: ImageIcon::try_load(value.hwnd, || { + windows_icons::get_icon_by_hwnd(value.hwnd) + .or_else(|| windows_icons_fallback::get_icon_by_process_id(value.process_id())) + }), + } + } } diff --git a/komorebi-client/src/lib.rs b/komorebi-client/src/lib.rs index 909d85ba..da178b2e 100644 --- a/komorebi-client/src/lib.rs +++ b/komorebi-client/src/lib.rs @@ -77,6 +77,7 @@ pub use komorebi::WorkspaceConfig; use komorebi::DATA_DIR; +use std::borrow::Borrow; use std::io::BufReader; use std::io::Read; use std::io::Write; @@ -94,12 +95,15 @@ pub fn send_message(message: &SocketMessage) -> std::io::Result<()> { stream.write_all(serde_json::to_string(message)?.as_bytes()) } -pub fn send_batch(messages: impl IntoIterator) -> std::io::Result<()> { +pub fn send_batch(messages: impl IntoIterator) -> std::io::Result<()> +where + Q: Borrow, +{ let socket = DATA_DIR.join(KOMOREBI); let mut stream = UnixStream::connect(socket)?; stream.set_write_timeout(Some(Duration::from_secs(1)))?; let msgs = messages.into_iter().fold(String::new(), |mut s, m| { - if let Ok(m_str) = serde_json::to_string(&m) { + if let Ok(m_str) = serde_json::to_string(m.borrow()) { s.push_str(&m_str); s.push('\n'); }