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'); }