From 6b4d4054581531d579e3c8d72921597ebc7d173b Mon Sep 17 00:00:00 2001 From: prankstr <11200347+prankstr@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:12:14 +0200 Subject: [PATCH 1/3] fix: use current config for monitor resync --- config.toml | 9 + crates/vibepanel-core/src/config.rs | 147 +++++++++- crates/vibepanel/src/bar.rs | 13 +- crates/vibepanel/src/dock.rs | 271 ++++++++++++++++++ crates/vibepanel/src/main.rs | 7 +- crates/vibepanel/src/services/bar_manager.rs | 5 + .../src/services/compositor/hyprland.rs | 156 +++++++++- .../vibepanel/src/services/config_manager.rs | 23 ++ crates/vibepanel/src/styles.rs | 14 + crates/vibepanel/src/theme_vars.rs | 2 + crates/vibepanel/src/widgets/css/dock.rs | 54 ++++ crates/vibepanel/src/widgets/css/mod.rs | 4 +- crates/vibepanel/src/widgets/launcher.rs | 256 +++++++++++++++++ crates/vibepanel/src/widgets/mod.rs | 8 + 14 files changed, 947 insertions(+), 22 deletions(-) create mode 100644 crates/vibepanel/src/dock.rs create mode 100644 crates/vibepanel/src/widgets/css/dock.rs create mode 100644 crates/vibepanel/src/widgets/launcher.rs diff --git a/config.toml b/config.toml index 5b1db25e..b3ccf27a 100644 --- a/config.toml +++ b/config.toml @@ -18,6 +18,15 @@ border_radius = 30 background_opacity = 0.0 # 0.0 = transparent (islands), 1.0 = solid # outline = true # Override theme.outline for the bar (omit = inherit) +# [dock] +# autohide = true # Auto-hide dock, reveal on bottom-edge hover (default: true) +# always_visible = false # Keep dock visible always (default: false) +# icon_size = 48 # Icon size in pixels for launcher + running buttons (default: 48) +# pin_to_edge = false # Reserve exclusive zone so windows don't overlap (default: false) +# background_opacity = 1.0 # Dock pill background opacity 0.0=transparent..1.0=solid (default: 1.0) +# gap = 8 # Gap between dock icons in pixels (default: 8) +# magnification = false # Magnify icon under cursor macOS-style (default: false) +# magnified_icon_size = 72 # Max magnified size in pixels (default: 72) [widgets] left = ["workspaces", "window_title"] center = ["media"] diff --git a/crates/vibepanel-core/src/config.rs b/crates/vibepanel-core/src/config.rs index 375ba3aa..8ea995fa 100644 --- a/crates/vibepanel-core/src/config.rs +++ b/crates/vibepanel-core/src/config.rs @@ -131,6 +131,9 @@ pub struct Config { /// Advanced configuration options. pub advanced: AdvancedConfig, + + /// Dock configuration, used when `bar.mode = "dock"`. + pub dock: DockConfig, } impl Config { @@ -330,6 +333,14 @@ impl Config { )); } + // Validate bar.mode + if self.bar.mode != "bar" && self.bar.mode != "dock" { + errors.push(format!( + "bar.mode: invalid value '{}', expected \"bar\" or \"dock\"", + self.bar.mode + )); + } + // Validate advanced.compositor if !VALID_COMPOSITORS.contains(&self.advanced.compositor.as_str()) { errors.push(format!( @@ -392,6 +403,27 @@ impl Config { errors.push("bar.size: must be greater than 0".to_string()); } + if !(8..=256).contains(&self.dock.icon_size) { + errors.push(format!( + "dock.icon_size: invalid value {}, expected 8..=256", + self.dock.icon_size + )); + } + + if self.dock.magnified_icon_size < self.dock.icon_size { + errors.push(format!( + "dock.magnified_icon_size: {} must be >= dock.icon_size {}", + self.dock.magnified_icon_size, self.dock.icon_size + )); + } + + if !(0.0..=1.0).contains(&self.dock.background_opacity) { + errors.push(format!( + "dock.background_opacity: invalid value {}, expected 0.0..=1.0", + self.dock.background_opacity + )); + } + if self.osd.timeout_ms == 0 { errors.push("osd.timeout_ms: must be greater than 0".to_string()); } @@ -606,6 +638,13 @@ impl Config { .to_string(), ); } + // Warn if dock mode requested without Hyprland. + if self.bar.mode == "dock" && self.advanced.compositor != "hyprland" { + warnings.push( + "bar.mode = \"dock\" is only supported on Hyprland; the dock will fall back to a bottom bar." + .to_string(), + ); + } // Warn about popover set to the same polarity as the current mode. // When mode = "auto" and scheme is omitted or follows GTK, the effective polarity @@ -635,7 +674,12 @@ impl Config { { warnings.push(format!("theme.wallpaper: file '{}' not found", wallpaper)); } - + // Warn if dock mode requested without Hyprland. + if self.bar.mode == "dock" && self.advanced.compositor != "hyprland" { + warnings.push( + "bar.mode = \"dock\" is only supported on Hyprland; the dock will fall back to a bottom bar.".to_string(), + ); + } warnings } @@ -645,6 +689,7 @@ impl Config { lines.push("Bar Configuration:".to_string()); lines.push(format!(" position: {}", self.bar.position)); + lines.push(format!(" mode: {}", self.bar.mode)); lines.push(format!(" size: {}px", self.bar.size)); lines.push(format!(" spacing: {}px", self.bar.spacing)); lines.push(format!(" screen_margin: {}px", self.bar.screen_margin)); @@ -792,6 +837,10 @@ pub struct BarConfig { /// Default: "top" pub position: String, + /// Window mode: "bar" (default) or "dock". + /// "dock" renders a centered bottom Hyprland-only dock instead of a bar. + pub mode: String, + /// Base height of the bar in pixels. pub size: u32, @@ -841,6 +890,7 @@ impl Default for BarConfig { fn default() -> Self { Self { position: "top".to_string(), + mode: "bar".to_string(), size: 32, spacing: 8, screen_margin: 0, @@ -873,6 +923,45 @@ impl BarConfig { self.position().is_horizontal() } } +/// Dock-specific configuration, used when `bar.mode = "dock"`. +/// +/// By default the dock auto-hides when the mouse leaves and reappears when the +/// cursor touches the bottom screen edge (Dash-to-Dock style). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct DockConfig { + /// Auto-hide the dock; reveal on bottom-edge hover. Default: true + pub autohide: bool, + /// Keep the dock always visible (ignores autohide). Default: false + pub always_visible: bool, + /// Icon size in pixels for launcher + running-window buttons. Default: 48 + pub icon_size: u32, + /// Reserve exclusive zone so windows don't overlap the dock. Default: false + pub pin_to_edge: bool, + /// Dock pill background opacity (0.0 transparent .. 1.0 solid). Default: 1.0 + pub background_opacity: f64, + /// Gap between dock icons in pixels. Default: 8 + pub gap: u32, + /// Magnify icon under cursor (macOS style). Default: false + pub magnification: bool, + /// Max magnified icon size in pixels. Default: 72 + pub magnified_icon_size: u32, +} + +impl Default for DockConfig { + fn default() -> Self { + Self { + autohide: true, + always_visible: false, + icon_size: 48, + pin_to_edge: false, + background_opacity: 1.0, + gap: 8, + magnification: false, + magnified_icon_size: 72, + } + } +} /// Widget section configuration. /// @@ -2237,6 +2326,62 @@ mod tests { assert!(msg.contains("bar.position")); } + #[test] + fn test_validate_dock_mode_bar_ok() { + let config = Config::default(); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_validate_dock_mode_dock_ok() { + let mut config = Config::default(); + config.bar.mode = "dock".to_string(); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_validate_dock_mode_invalid() { + let mut config = Config::default(); + config.bar.mode = "fiddle".to_string(); + let result = config.validate(); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("bar.mode")); + } + + #[test] + fn test_validate_dock_icon_size_bounds() { + let mut low = Config::default(); + low.dock.icon_size = 5; + let msg = low.validate().unwrap_err().to_string(); + assert!(msg.contains("dock.icon_size")); + + let mut high = Config::default(); + high.dock.icon_size = 300; + let msg = high.validate().unwrap_err().to_string(); + assert!(msg.contains("dock.icon_size")); + + let mut ok = Config::default(); + ok.dock.icon_size = 48; + assert!(ok.validate().is_ok()); + } + + #[test] + fn test_validate_dock_magnify_bounds() { + let mut config = Config::default(); + config.dock.icon_size = 48; + config.dock.magnified_icon_size = 10; + let result = config.validate(); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("dock.magnified_icon_size")); + } + + #[test] + fn test_default_dock_config_validates() { + assert!(Config::default().validate().is_ok()); + } + #[test] fn test_bar_orientation_helpers() { let mut config = BarConfig::default(); diff --git a/crates/vibepanel/src/bar.rs b/crates/vibepanel/src/bar.rs index fa78b869..22cae706 100644 --- a/crates/vibepanel/src/bar.rs +++ b/crates/vibepanel/src/bar.rs @@ -73,12 +73,12 @@ fn screen_margin_spacer_precedes_bar(position: BarPosition) -> bool { } #[derive(Clone)] -struct EdgeClickTarget { +pub(crate) struct EdgeClickTarget { widget: gtk4::Widget, interaction: EdgeInteraction, } -type EdgeClickTargets = Rc>>; +pub(crate) type EdgeClickTargets = Rc>>; fn register_edge_target( targets: &EdgeClickTargets, @@ -500,6 +500,11 @@ pub fn create_bar_window( output_id: &str, state: &mut BarState, ) -> ApplicationWindow { + // Dock mode: delegate to the dock builder (Hyprland-only, falls back to bar). + if config.bar.mode == "dock" { + return crate::dock::create_dock_window(app, config, monitor, output_id, state); + } + let position = config.bar.position(); let is_vertical = position.is_vertical(); let bar_height = rendered_bar_height(config); @@ -1281,7 +1286,7 @@ fn build_merge_group( count } -fn create_section( +pub(crate) fn create_section( position: &str, config: &Config, state: &mut BarState, @@ -1332,7 +1337,7 @@ fn create_section( } /// Create the center section with widgets. -fn create_center_section( +pub(crate) fn create_center_section( config: &Config, state: &mut BarState, qs_handle: &crate::widgets::QuickSettingsWindowHandle, diff --git a/crates/vibepanel/src/dock.rs b/crates/vibepanel/src/dock.rs new file mode 100644 index 00000000..f4fe6fd3 --- /dev/null +++ b/crates/vibepanel/src/dock.rs @@ -0,0 +1,271 @@ +//! Dock window implementation using GTK4 and layer-shell. +//! +//! The dock is a centered bottom "pill" that shows pinned launchers and running +//! windows. It auto-hides (Dash-to-Dock style) and reappears when the mouse hits +//! the bottom screen edge. Hyprland-only (gated in `create_bar_window`). + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::time::Duration; + +use gtk4::glib::{self, SourceId}; +use gtk4::prelude::*; +use gtk4::{ + Align, Application, ApplicationWindow, Box as GtkBox, EventControllerMotion, Orientation, +}; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; +use tracing::{debug, info, warn}; +use vibepanel_core::Config; + +use crate::services::bar_manager::BarManager; +use crate::services::compositor::CompositorManager; +use crate::widgets::BarState; + +/// Build the dock content: a centered horizontal pill hosting the configured +/// widgets (launchers + taskbar). +pub(crate) fn build_dock_content( + app: &Application, + config: &Config, + state: &mut BarState, + output_id: Option<&str>, +) -> GtkBox { + let content = GtkBox::new(Orientation::Horizontal, config.dock.gap as i32); + content.add_css_class(crate::styles::class::DOCK); + content.set_halign(Align::Center); + content.set_valign(Align::End); + content.set_margin_top(0); + content.set_margin_bottom(0); + + // Build the configured widgets (launchers, taskbar, etc.) into the pill. + // Reuse the bar's section factory so widget ordering/merging behaves identically. + let qs = crate::widgets::QuickSettingsWindowHandle::new( + app.clone(), + crate::widgets::QuickSettingsConfig::default(), + ); + let left_section = crate::bar::create_section( + "left", + config, + state, + &qs, + output_id, + Orientation::Horizontal, + &Rc::new(RefCell::new(Vec::new())), + ); + content.append(&left_section); + + if !config.widgets.resolved_center().is_empty() { + let center_section = crate::bar::create_center_section( + config, + state, + &qs, + output_id, + Orientation::Horizontal, + &Rc::new(RefCell::new(Vec::new())), + ); + content.append(¢er_section); + } + + let right_section = crate::bar::create_section( + "right", + config, + state, + &qs, + output_id, + Orientation::Horizontal, + &Rc::new(RefCell::new(Vec::new())), + ); + content.append(&right_section); + + content +} + +/// Create and configure the dock window with layer-shell. +/// +/// The `state` parameter stores widget handles, keeping them alive for the +/// lifetime of the dock. The `output_id` is the monitor connector name used +/// for per-monitor widget filtering. +pub fn create_dock_window( + app: &Application, + config: &Config, + monitor: >k4::gdk::Monitor, + output_id: &str, + state: &mut BarState, +) -> ApplicationWindow { + // Hyprland-only gating: fall back to a bottom bar on other compositors. + if CompositorManager::global().backend_name() != "Hyprland" { + warn!("bar.mode = \"dock\" requires Hyprland; falling back to a bottom bar"); + let mut cfg = config.clone(); + cfg.bar.mode = "bar".to_string(); + cfg.bar.position = "bottom".to_string(); + return crate::bar::create_bar_window(app, &cfg, monitor, output_id, state); + } + + let dock_height = config.dock.icon_size + 12; // icon + vertical padding + + let window = ApplicationWindow::builder() + .application(app) + .title("vibepanel-dock") + .decorated(false) + .resizable(false) + .default_height(dock_height as i32) + .default_width(-1) + .build(); + + window.add_css_class(crate::styles::class::DOCK_WINDOW); + + // Initialize layer-shell + window.init_layer_shell(); + window.set_namespace(Some("vibepanel-dock")); + window.set_layer(Layer::Top); + + // Bind to specific monitor + window.set_monitor(Some(monitor)); + debug!("Dock bound to monitor: {:?}", monitor.connector()); + + // Anchor to the bottom edge and stretch across the full width. + window.set_anchor(Edge::Top, false); + window.set_anchor(Edge::Bottom, true); + window.set_anchor(Edge::Left, true); + window.set_anchor(Edge::Right, true); + + // Reserve space only if the user explicitly pinned the dock to the edge. + if config.dock.pin_to_edge { + window.auto_exclusive_zone_enable(); + } + + // Gap from the bottom edge + window.set_margin(Edge::Bottom, config.bar.screen_margin as i32); + + // Dock doesn't need keyboard input + window.set_keyboard_mode(KeyboardMode::None); + + let content = build_dock_content(app, config, state, Some(output_id)); + window.set_child(Some(&content)); + + // Set window width to the target monitor's width on map. + let target_geometry = monitor.geometry(); + let target_width = target_geometry.width(); + + window.connect_map(move |win| { + win.set_default_size(target_width, dock_height as i32); + debug!( + "Set dock window size to target monitor width: {}px", + target_width + ); + }); + + // Auto-hide wiring (Dash-to-Dock style). + if !config.dock.always_visible && config.dock.autohide { + install_dock_auto_hide(app, config, &window, monitor, state); + } else { + window.set_visible(true); + } + + info!( + "Dock window created: icon_size={}px, monitor={:?}, widgets={}", + config.dock.icon_size, + monitor.connector(), + state.handle_count() + ); + + window +} + +/// Install auto-hide behavior: a thin hotzone at the bottom edge reveals the dock +/// on mouse enter; the dock hides after the mouse leaves for ~200ms. +fn install_dock_auto_hide( + app: &Application, + config: &Config, + dock: &ApplicationWindow, + monitor: >k4::gdk::Monitor, + state: &mut BarState, +) { + // Start hidden. + dock.set_opacity(0.0); + dock.set_visible(false); + + // --- Hotzone: a thin transparent surface at the bottom edge that catches + // mouse-enter to reveal the dock. + let hotzone = ApplicationWindow::builder() + .application(app) + .title("vibepanel-dock-hotzone") + .decorated(false) + .resizable(false) + .default_height(3) + .default_width(-1) + .build(); + hotzone.init_layer_shell(); + hotzone.set_namespace(Some("vibepanel-dock-hotzone")); + hotzone.set_layer(Layer::Top); + hotzone.set_monitor(Some(monitor)); + hotzone.set_anchor(Edge::Top, false); + hotzone.set_anchor(Edge::Bottom, true); + hotzone.set_anchor(Edge::Left, true); + hotzone.set_anchor(Edge::Right, true); + hotzone.set_margin(Edge::Bottom, config.bar.screen_margin as i32); + hotzone.set_keyboard_mode(KeyboardMode::None); + hotzone.set_opacity(0.01); // near-invisible but still receives input + hotzone.add_css_class(crate::styles::class::DOCK_WINDOW); + + let hide_timer: Rc>> = Rc::new(Cell::new(None)); + let hide_timer_for_hotzone = Rc::clone(&hide_timer); + let dock_for_hotzone = dock.clone(); + + // Hotzone enter → reveal dock. + let hotzone_motion = EventControllerMotion::new(); + hotzone_motion.connect_enter(move |_, _, _| { + // Don't reveal if bars are IPC-hidden. + if BarManager::global().is_hidden() { + return; + } + if let Some(src) = hide_timer_for_hotzone.take() { + src.remove(); + } + dock_for_hotzone.set_visible(true); + dock_for_hotzone.set_opacity(1.0); + }); + hotzone.add_controller(hotzone_motion); + hotzone.set_visible(true); + + // Dock enter → cancel pending hide. + let dock_for_enter = dock.clone(); + let hide_timer_for_enter = Rc::clone(&hide_timer); + let dock_motion = EventControllerMotion::new(); + dock_motion.connect_enter(move |_, _, _| { + if let Some(src) = hide_timer_for_enter.take() { + src.remove(); + } + dock_for_enter.set_opacity(1.0); + }); + + // Dock leave → schedule hide after 200ms. + let dock_for_leave = dock.clone(); + let hide_timer_for_leave = Rc::clone(&hide_timer); + dock_motion.connect_leave(move |_| { + // Cancel any existing timer before scheduling a new one. + if let Some(src) = hide_timer_for_leave.take() { + src.remove(); + } + let dock = dock_for_leave.clone(); + let timer = Rc::clone(&hide_timer_for_leave); + let src = glib::timeout_add_local_once(Duration::from_millis(200), move || { + timer.set(None); + dock.set_opacity(0.0); + dock.set_visible(false); + }); + hide_timer_for_leave.set(Some(src)); + }); + dock.add_controller(dock_motion); + + // Keep the hotzone + timer alive in state. + state.add_handle(Box::new(hotzone)); + state.add_handle(Box::new(DockAutoHideState(hide_timer))); +} + +/// Opaque handle that keeps the auto-hide timer alive for the dock's lifetime. +#[allow(dead_code)] +struct DockAutoHideState(Rc>>); + +// Ensure the handle is Send + Sync for BarState's Vec>. +unsafe impl Send for DockAutoHideState {} +unsafe impl Sync for DockAutoHideState {} diff --git a/crates/vibepanel/src/main.rs b/crates/vibepanel/src/main.rs index 2d127efd..fd2c9b53 100644 --- a/crates/vibepanel/src/main.rs +++ b/crates/vibepanel/src/main.rs @@ -3,6 +3,7 @@ //! This is the main entry point for the vibepanel bar application. mod bar; +pub mod dock; pub mod layout_math; pub mod popover_registry; pub mod popover_tracker; @@ -681,7 +682,6 @@ fn run_gtk_app(config: Config, config_source: Option) -> ExitCode { let debounce_source: std::rc::Rc>> = std::rc::Rc::new(std::cell::Cell::new(None)); { - let config_for_hotplug = config_for_activate.clone(); let display_for_hotplug = display.clone(); let debounce = debounce_source.clone(); display @@ -694,7 +694,6 @@ fn run_gtk_app(config: Config, config_source: Option) -> ExitCode { source.remove(); } let display = display_for_hotplug.clone(); - let config = config_for_hotplug.clone(); let debounce_clear = debounce.clone(); debounce.set(Some(gtk4::glib::timeout_add_local_once( std::time::Duration::from_millis(300), @@ -702,13 +701,13 @@ fn run_gtk_app(config: Config, config_source: Option) -> ExitCode { // Clear stale SourceId — one-shot timers auto-remove // from the main loop, so the id is invalid after firing. debounce_clear.take(); + let config = ConfigManager::global().config_snapshot(); bar_manager::sync_monitors_when_ready(&display, &config); }, ))); }); } { - let config_for_hotplug = config_for_activate.clone(); let display_for_hotplug = display.clone(); let debounce = debounce_source; display @@ -721,7 +720,6 @@ fn run_gtk_app(config: Config, config_source: Option) -> ExitCode { source.remove(); } let display = display_for_hotplug.clone(); - let config = config_for_hotplug.clone(); let debounce_clear = debounce.clone(); debounce.set(Some(gtk4::glib::timeout_add_local_once( std::time::Duration::from_millis(300), @@ -729,6 +727,7 @@ fn run_gtk_app(config: Config, config_source: Option) -> ExitCode { // Clear stale SourceId — one-shot timers auto-remove // from the main loop, so the id is invalid after firing. debounce_clear.take(); + let config = ConfigManager::global().config_snapshot(); bar_manager::sync_monitors_when_ready(&display, &config); }, ))); diff --git a/crates/vibepanel/src/services/bar_manager.rs b/crates/vibepanel/src/services/bar_manager.rs index 73590d43..38f3b6a6 100644 --- a/crates/vibepanel/src/services/bar_manager.rs +++ b/crates/vibepanel/src/services/bar_manager.rs @@ -384,6 +384,11 @@ impl BarManager { self.ipc_hide(); } } + + /// Whether bars are currently IPC-hidden (not rendering / fully transparent). + pub fn is_hidden(&self) -> bool { + self.hidden.get() + } } /// Check if a monitor is fully ready (has connector and valid geometry). diff --git a/crates/vibepanel/src/services/compositor/hyprland.rs b/crates/vibepanel/src/services/compositor/hyprland.rs index 326a609c..d5f1c094 100644 --- a/crates/vibepanel/src/services/compositor/hyprland.rs +++ b/crates/vibepanel/src/services/compositor/hyprland.rs @@ -52,6 +52,8 @@ pub struct HyprlandBackend { main_keyboard_name: RwLock>, /// Whether this Hyprland instance supports the Lua dispatch API. supports_lua_dispatch: AtomicBool, + /// Callback for window-list changes, set by CompositorManager. + window_list_callback: Mutex>, } impl HyprlandBackend { @@ -72,6 +74,7 @@ impl HyprlandBackend { keyboard_layout: RwLock::new(None), main_keyboard_name: RwLock::new(None), supports_lua_dispatch: AtomicBool::new(false), + window_list_callback: Mutex::new(None), } } @@ -186,6 +189,72 @@ impl HyprlandBackend { } } } + /// Query all clients from Hyprland and map them to the generic `Window` type. + /// + /// Used by `list_windows()` and `focus_window()`. Returns an empty vec on any + /// connection or parse failure (consistent with other backends). + fn query_clients(&self) -> Vec { + let Some(clients) = self.query_json("clients") else { + return Vec::new(); + }; + let Some(clients) = clients.as_array() else { + return Vec::new(); + }; + + clients + .iter() + .filter_map(|client| { + // Skip unmapped/deleted clients. + if client.get("mapped").and_then(|v| v.as_bool()) == Some(false) { + return None; + } + + let address_str = client.get("address").and_then(|v| v.as_str())?; + // Skip clients without a valid address (e.g. destroyed placeholders). + if address_str.is_empty() || address_str == "0x0" { + return None; + } + let id = + u64::from_str_radix(address_str.strip_prefix("0x").unwrap_or(address_str), 16) + .ok()?; + + let workspace_id = client + .get("workspace") + .and_then(Self::workspace_id_from_snapshot); + let is_focused = client + .get("focused") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let is_urgent = client + .get("urgent") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + Some(super::Window { + id, + title: client + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + app_id: client + .get("class") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + workspace_id, + output: client + .get("monitor") + .and_then(|v| v.as_str()) + .map(String::from), + is_focused, + is_urgent, + // Hyprland scratchpad info is not exposed via `clients` JSON. + is_scratchpad: false, + }) + }) + .collect() + } fn default_workspaces() -> Vec { (1..=DEFAULT_WORKSPACE_COUNT) @@ -197,7 +266,6 @@ impl HyprlandBackend { }) .collect() } - fn workspace_identity(id: Option, raw_name: &str) -> Option<(i32, String)> { if raw_name.starts_with("special:") { return None; @@ -820,10 +888,10 @@ impl HyprlandBackend { } /// Handle a Hyprland event line. - /// Returns (workspace_changed, window_changed, keyboard_layout_changed). - fn handle_event(&self, line: &str) -> (bool, bool, bool) { + /// Returns (workspace_changed, window_changed, keyboard_layout_changed, window_list_changed). + fn handle_event(&self, line: &str) -> (bool, bool, bool, bool) { let Some((event, data)) = line.split_once(">>") else { - return (false, false, false); + return (false, false, false, false); }; trace!( @@ -831,10 +899,10 @@ impl HyprlandBackend { event, &data[..data.len().min(50)] ); - let mut workspace_changed = false; let mut window_changed = false; let mut keyboard_layout_changed = false; + let mut window_list_changed = false; match event { "workspace" => { @@ -854,6 +922,7 @@ impl HyprlandBackend { self.fetch_initial_state(); workspace_changed = true; } + window_list_changed = true; } "workspacev2" => { // workspacev2>>ID,NAME @@ -868,14 +937,17 @@ impl HyprlandBackend { workspace_changed |= self.update_active_workspace(ws_id); workspace_changed |= self.clear_urgent_workspace(ws_id); } + window_list_changed = true; } "createworkspace" | "createworkspacev2" | "destroyworkspace" | "destroyworkspacev2" | "renameworkspace" | "closewindow" | "movewindow" => { workspace_changed = self.refresh_occupied(); + window_list_changed = true; } "openwindow" => { // openwindow>>ADDRESS,WORKSPACE,CLASS,TITLE workspace_changed = self.refresh_occupied(); + window_list_changed = true; } "urgent" => { // urgent>>WINDOW_ADDRESS @@ -902,6 +974,7 @@ impl HyprlandBackend { // Query full window info from Hyprland for consistency let (changed, workspace_id) = self.refresh_active_window(); window_changed = changed; + window_list_changed = changed; if let Some(ws_id) = workspace_id { workspace_changed |= self.clear_urgent_workspace(ws_id); } @@ -911,6 +984,7 @@ impl HyprlandBackend { // Query the window info from Hyprland let (changed, workspace_id) = self.refresh_active_window(); window_changed = changed; + window_list_changed = changed; if let Some(ws_id) = workspace_id { workspace_changed |= self.clear_urgent_workspace(ws_id); } @@ -948,6 +1022,7 @@ impl HyprlandBackend { workspace_changed = self.refresh_occupied(); } } + window_list_changed = true; } "moveworkspace" | "moveworkspacev2" => { // Workspace moved to different monitor - refresh all state @@ -990,7 +1065,12 @@ impl HyprlandBackend { _ => {} } - (workspace_changed, window_changed, keyboard_layout_changed) + ( + workspace_changed, + window_changed, + keyboard_layout_changed, + window_list_changed, + ) } /// Run the event loop (in background thread). @@ -1033,6 +1113,15 @@ impl HyprlandBackend { { kb_cb(info.clone()); } + // Emit initial window list for taskbar/dock consumers. + if let Some(ref wl_cb) = *backend + .window_list_callback + .lock() + .unwrap_or_else(|e| e.into_inner()) + { + let windows = backend.query_clients(); + wl_cb(super::WindowListSnapshot { windows }); + } // Exponential backoff state let mut backoff_ms = RECONNECT_INITIAL_MS; @@ -1073,7 +1162,8 @@ impl HyprlandBackend { match line { Ok(line) => { - let (ws_changed, win_changed, kb_changed) = backend.handle_event(&line); + let (ws_changed, win_changed, kb_changed, window_list_changed) = + backend.handle_event(&line); if let Some((ws_cb, win_cb)) = backend .callbacks @@ -1098,6 +1188,16 @@ impl HyprlandBackend { { kb_cb(info.clone()); } + + if window_list_changed + && let Some(ref wl_cb) = *backend + .window_list_callback + .lock() + .unwrap_or_else(|e| e.into_inner()) + { + let windows = backend.query_clients(); + wl_cb(super::WindowListSnapshot { windows }); + } } Err(e) => { // Timeout is expected, other errors should be logged @@ -1163,6 +1263,11 @@ impl CompositorBackend for HyprlandBackend { // Share the running flag with the thread so stop() works correctly let running = Arc::clone(&self.running); + let window_list_callback = self + .window_list_callback + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); // Create Arc for shared access in thread // Note: This is a separate instance for the thread, but socket_path is now @@ -1186,9 +1291,8 @@ impl CompositorBackend for HyprlandBackend { supports_lua_dispatch: AtomicBool::new( self.supports_lua_dispatch.load(Ordering::Relaxed), ), + window_list_callback: Mutex::new(window_list_callback), }); - - // Start event loop thread let handle = thread::Builder::new() .name("hyprland-event-loop".into()) .spawn(move || { @@ -1258,11 +1362,39 @@ impl CompositorBackend for HyprlandBackend { .lock() .unwrap_or_else(|e| e.into_inner()) = Some(callback); } + fn set_window_list_callback(&self, callback: super::WindowListCallback) { + *self + .window_list_callback + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(callback.clone()); + // Emit initial snapshot so consumers (taskbar/dock) get immediate data. + let windows = self.list_windows(); + callback(super::WindowListSnapshot { windows }); + } + + fn list_windows(&self) -> Vec { + self.query_clients() + } + + fn focus_window(&self, window_id: u64) { + if let Some(clients) = self.query_json("clients") + && let Some(addr) = clients.as_array().and_then(|arr| { + arr.iter() + .find(|c| { + c.get("address").and_then(|a| a.as_str()).and_then(|a| { + u64::from_str_radix(a.strip_prefix("0x").unwrap_or(a), 16).ok() + }) == Some(window_id) + }) + .and_then(|c| c.get("address").and_then(|a| a.as_str().map(String::from))) + }) + { + let _ = self.send_command(&format!("dispatch focuswindow address:{addr}")); + } + } fn get_keyboard_layout(&self) -> Option { self.keyboard_layout.read().clone() } - fn switch_keyboard_layout_next(&self) { let main_kb = self.main_keyboard_name.read().clone(); if let Some(kb_name) = main_kb { @@ -1565,7 +1697,7 @@ mod tests { .urgent_workspaces .insert(2); - let (workspace_changed, _, _) = backend.handle_event("workspace>>2"); + let (workspace_changed, _, _, _) = backend.handle_event("workspace>>2"); let snapshot = backend.workspace_snapshot.read(); assert!(workspace_changed); @@ -1602,7 +1734,7 @@ mod tests { layout_count: Some(2), }); - let (_, _, keyboard_changed) = backend.handle_event("activelayout>>keyboard-a,German"); + let (_, _, keyboard_changed, _) = backend.handle_event("activelayout>>keyboard-a,German"); assert!(keyboard_changed); assert_eq!( diff --git a/crates/vibepanel/src/services/config_manager.rs b/crates/vibepanel/src/services/config_manager.rs index aba22415..f52e646f 100644 --- a/crates/vibepanel/src/services/config_manager.rs +++ b/crates/vibepanel/src/services/config_manager.rs @@ -564,6 +564,11 @@ impl ConfigManager { .and_then(|p| p.parent().map(|d| d.to_path_buf())) } + /// Return a clone of the currently active configuration. + pub fn config_snapshot(&self) -> Config { + self.config.borrow().clone() + } + /// Check if compositor background blur is enabled. /// /// When true, vibepanel sends ext-background-effect-v1 blur region hints @@ -1274,6 +1279,16 @@ fn config_structure_changed(old: &Config, new: &Config) -> bool { return true; } + if old.bar.mode != new.bar.mode { + debug!("bar.mode changed ({} -> {})", old.bar.mode, new.bar.mode); + return true; + } + + if old.dock != new.dock { + debug!("dock config changed"); + return true; + } + // Widget list changes let old_widgets = widget_names(old); let new_widgets = widget_names(new); @@ -1883,4 +1898,12 @@ mod tests { assert!(config_structure_changed(&old, &new)); } + + #[test] + fn test_config_structure_changed_detects_bar_mode_toggle() { + let old = Config::default(); + let mut new = old.clone(); + new.bar.mode = "dock".to_string(); + assert!(config_structure_changed(&old, &new)); + } } diff --git a/crates/vibepanel/src/styles.rs b/crates/vibepanel/src/styles.rs index 4e7cd278..6c04e94e 100644 --- a/crates/vibepanel/src/styles.rs +++ b/crates/vibepanel/src/styles.rs @@ -114,6 +114,11 @@ pub mod class { /// Bar section center (`.bar-section--center`). pub const BAR_SECTION_CENTER: &str = "bar-section--center"; + /// Dock surface root (`.dock`). + pub const DOCK: &str = "dock"; + + /// Dock window class (`.dock-window`). + pub const DOCK_WINDOW: &str = "dock-window"; } /// Foreground/text color classes. @@ -739,6 +744,15 @@ pub mod widget { /// Keyboard layout label (`.keyboard-layout-label`). pub const KEYBOARD_LAYOUT_LABEL: &str = "keyboard-layout-label"; + // Launcher + /// Launcher widget (`.launcher`). + pub const LAUNCHER: &str = "launcher"; + + /// Launcher icon button (`.launcher-button`). + pub const LAUNCHER_BUTTON: &str = "launcher-button"; + + /// Running-window indicator dot (`.dock-running-dot`). + pub const DOCK_RUNNING_DOT: &str = "dock-running-dot"; } /// Surface and popover classes. diff --git a/crates/vibepanel/src/theme_vars.rs b/crates/vibepanel/src/theme_vars.rs index 5e7c4f51..6c434a4f 100644 --- a/crates/vibepanel/src/theme_vars.rs +++ b/crates/vibepanel/src/theme_vars.rs @@ -123,6 +123,8 @@ pub(super) const THEME_VAR_EXPECTATIONS: &[ThemeVarExpectation] = &[ var("--vp-internal-bar-padding-right", Root, BuiltinCss), var("--vp-internal-bar-padding-bottom", Root, BuiltinCss), var("--vp-internal-bar-padding-left", Root, BuiltinCss), + var("--color-accent", UserHook, Alias), + var("--dock-gap", UserHook, Alias), ]; const fn var(name: &'static str, scope: ThemeVarScope, role: ThemeVarRole) -> ThemeVarExpectation { diff --git a/crates/vibepanel/src/widgets/css/dock.rs b/crates/vibepanel/src/widgets/css/dock.rs new file mode 100644 index 00000000..b1711837 --- /dev/null +++ b/crates/vibepanel/src/widgets/css/dock.rs @@ -0,0 +1,54 @@ +//! Dock CSS. +//! +//! Styles for the Hyprland-only dock mode: the centered bottom pill, its +//! launcher buttons, and the running-window indicator dots. + +/// Return dock CSS. +pub fn css() -> String { + r#" +/* ===== DOCK ===== */ + +/* Transparent window so only the pill itself is painted. */ +.dock-window { + background: transparent; +} + +/* Centered bottom pill. */ +.dock { + background: var(--color-background-bar); + border-radius: var(--radius-bar); + padding: 6px; +} + +/* Icon/content row inside the pill. */ +.dock .content { + gap: var(--dock-gap, 8px); +} + +/* Launcher icon button. */ +.launcher-button { + border-radius: 9999px; + padding: 4px; + background: transparent; +} + +.launcher-button:hover { + background: rgba(255, 255, 255, 0.12); +} + +/* Running-window indicator dot. */ +.dock-running-dot { + width: 4px; + height: 4px; + border-radius: 9999px; + background: var(--color-accent, #fff); + margin: 0 auto; +} + +/* Reveal/hide transition, driven by `set_opacity`. */ +.dock-window { + transition: opacity 150ms ease; +} +"# + .to_string() +} diff --git a/crates/vibepanel/src/widgets/css/mod.rs b/crates/vibepanel/src/widgets/css/mod.rs index 9ee9107d..0b813323 100644 --- a/crates/vibepanel/src/widgets/css/mod.rs +++ b/crates/vibepanel/src/widgets/css/mod.rs @@ -41,6 +41,7 @@ mod base; mod battery; mod buttons; mod calendar; +mod dock; mod media; mod notifications; mod osd; @@ -86,8 +87,9 @@ pub fn widget_css(config: &Config) -> String { let media_css = media::css(animations); let system_css = system::css(); let weather_css = weather::css(); + let dock_css = dock::css(); format!( - "{bar_css}\n{tray_css}\n{buttons_css}\n{calendar_css}\n{quick_settings_css}\n{battery_css}\n{notifications_css}\n{osd_css}\n{media_css}\n{system_css}\n{weather_css}" + "{bar_css}\n{tray_css}\n{buttons_css}\n{calendar_css}\n{quick_settings_css}\n{battery_css}\n{notifications_css}\n{osd_css}\n{media_css}\n{system_css}\n{weather_css}\n{dock_css}" ) } diff --git a/crates/vibepanel/src/widgets/launcher.rs b/crates/vibepanel/src/widgets/launcher.rs new file mode 100644 index 00000000..21ec97d6 --- /dev/null +++ b/crates/vibepanel/src/widgets/launcher.rs @@ -0,0 +1,256 @@ +//! Launcher widget — pinned app launchers for the Hyprland dock. +//! +//! Shows a row of icon buttons for the apps listed in the `[widgets.launcher]` +//! config section. Each button launches its `exec` command with `sh -c` on click, +//! or focuses an already-running window whose `app_id` matches the pinned entry. +//! A small running indicator dot appears beneath the icon while a matching +//! window is open. + +use std::cell::RefCell; +use std::rc::Rc; + +use gtk4::prelude::*; +use gtk4::{Align, Box as GtkBox, Button, Image, Orientation}; +use tracing::{debug, warn}; +use vibepanel_core::config::WidgetEntry; + +use crate::services::callbacks::CallbackId; +use crate::services::compositor::WindowListSnapshot; +use crate::services::config_manager::ConfigManager; +use crate::services::icons::get_app_icon_name; +use crate::services::window_list::WindowListService; +use crate::styles::widget; +use crate::styles::{button, icon}; +use crate::widgets::WidgetConfig; +use crate::widgets::base::BaseWidget; +use crate::widgets::warn_unknown_options; + +/// A single pinned app entry. +#[derive(Debug, Clone)] +pub struct PinnedApp { + /// Display name (used as the button tooltip). + pub name: String, + /// Shell command to launch, run via `sh -c`. + pub exec: String, + /// Logical icon name, resolved through the icon service. + pub icon: String, + /// Lowercase matching id compared against `Window.app_id`. + pub app_id: String, +} + +/// Configuration for the launcher widget. +/// +/// Parsed from the `[widgets.launcher]` TOML section. The `apps` key holds an +/// array of inline tables, each describing a pinned app: +/// +/// ```toml +/// [widgets.launcher] +/// apps = [ +/// { name = "Firefox", exec = "firefox", icon = "firefox" }, +/// { name = "Terminal", exec = "kitty", icon = "utilities-terminal" }, +/// ] +/// ``` +#[derive(Debug, Clone, Default)] +pub struct LauncherConfig { + /// Pinned apps to show as launcher buttons. + pub apps: Vec, + /// Icon size in pixels. `None` means "use theme default" (pixmap_icon_size). + /// Resolved to a concrete value in `LauncherWidget::new()`. + pub icon_size: Option, +} + +impl WidgetConfig for LauncherConfig { + fn from_entry(entry: &WidgetEntry) -> Self { + warn_unknown_options("launcher", entry, &["apps", "icon_size"]); + + let icon_size = entry + .options + .get("icon_size") + .and_then(|v| v.as_integer()) + .map(|v| (v as i32).max(8)); + + let apps = entry + .options + .get("apps") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|item| { + let table = item.as_table()?; + let name = table.get("name").and_then(|v| v.as_str())?.to_string(); + let exec = table.get("exec").and_then(|v| v.as_str())?.to_string(); + let icon = table + .get("icon") + .and_then(|v| v.as_str()) + .map(String::from) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| name.to_lowercase()); + // Match id defaults to the lowercased name when omitted. + let app_id = table + .get("app_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_lowercase()) + .unwrap_or_else(|| name.to_lowercase()); + Some(PinnedApp { + name, + exec, + icon, + app_id, + }) + }) + .collect() + }) + .unwrap_or_default(); + + Self { apps, icon_size } + } +} + +/// Per-app running indicator handle. The dot's visibility is flipped on each +/// window-list snapshot. +struct LauncherDot { + dot: GtkBox, + app_id: String, +} + +pub struct LauncherWidget { + base: BaseWidget, + #[allow(dead_code)] + current_snapshot: Rc>, + window_list_callback_id: CallbackId, +} + +impl LauncherWidget { + pub fn new(mut config: LauncherConfig, output_id: Option) -> Self { + let sizes = ConfigManager::global().theme_sizes(); + + // Resolve icon_size: user value wins, otherwise theme default. Clamp to >= 8px. + config.icon_size = Some( + config + .icon_size + .unwrap_or(sizes.pixmap_icon_size as i32) + .max(8), + ); + let effective_icon = config.icon_size.unwrap(); + + let base = BaseWidget::new(&[widget::LAUNCHER]); + let content = base.content().clone(); + + let dots: Rc>> = Rc::new(RefCell::new(Vec::new())); + + // Snapshot cache for the click handler — updated by the WindowListService callback. + let snapshot: Rc> = + Rc::new(RefCell::new(WindowListSnapshot::default())); + + for app in &config.apps { + let button_box = GtkBox::new(Orientation::Vertical, 2); + button_box.set_valign(Align::Center); + + let btn = Button::new(); + btn.add_css_class(widget::LAUNCHER_BUTTON); + btn.add_css_class(button::RESET); + btn.add_css_class(button::COMPACT); + btn.set_valign(Align::Center); + btn.set_halign(Align::Center); + btn.set_tooltip_text(Some(&app.name)); + + let icon_name = get_app_icon_name(&app.icon); + let image = Image::from_icon_name(&icon_name); + image.add_css_class(icon::TEXT); + image.set_pixel_size(effective_icon); + image.set_halign(Align::Center); + image.set_valign(Align::Center); + image.set_tooltip_text(Some(&app.name)); + btn.set_child(Some(&image)); + + // Running indicator dot — hidden until a matching window is open. + let dot = GtkBox::new(Orientation::Horizontal, 0); + dot.add_css_class(widget::DOCK_RUNNING_DOT); + dot.set_halign(Align::Center); + dot.set_size_request(4, 4); + dot.set_visible(false); + + button_box.append(&btn); + button_box.append(&dot); + content.append(&button_box); + + let exec = app.exec.clone(); + let app_id = app.app_id.clone(); + let snap = snapshot.clone(); + btn.connect_clicked(move |_| { + handle_launcher_click(&app_id, &exec, &snap); + }); + + dots.borrow_mut().push(LauncherDot { + dot, + app_id: app.app_id.clone(), + }); + } + + let dots_for_cb = dots.clone(); + let snapshot_for_cb = snapshot.clone(); + let window_list_callback_id = WindowListService::global().connect(move |snap| { + *snapshot_for_cb.borrow_mut() = snap.clone(); + update_running_dots(&dots_for_cb, snap); + }); + + debug!( + "LauncherWidget created (output_id: {:?}, apps: {})", + output_id, + config.apps.len() + ); + + Self { + base, + current_snapshot: snapshot, + window_list_callback_id, + } + } + + pub fn widget(&self) -> &GtkBox { + self.base.widget() + } +} + +impl Drop for LauncherWidget { + fn drop(&mut self) { + WindowListService::global().disconnect(self.window_list_callback_id); + } +} + +/// On click: focus the first running window whose `app_id` matches the pinned +/// entry, otherwise spawn the configured `exec`. +fn handle_launcher_click(app_id: &str, exec: &str, snapshot: &Rc>) { + let snapshot = snapshot.borrow(); + if let Some(window) = snapshot + .windows + .iter() + .find(|w| !w.app_id.is_empty() && w.app_id.to_lowercase() == app_id.to_lowercase()) + { + debug!( + "launcher: focusing running window {:?} (id {}) for app_id {}", + window.title, window.id, app_id + ); + WindowListService::global().focus_window(window.id); + return; + } + debug!("launcher: spawning {:?}", exec); + if let Err(e) = std::process::Command::new("sh").arg("-c").arg(exec).spawn() { + warn!("launcher: failed to spawn {:?}: {e}", exec); + } +} + +/// Flip each launcher dot's visibility based on whether a running window +/// matches its pinned `app_id`. +fn update_running_dots(dots: &Rc>>, snapshot: &WindowListSnapshot) { + let running: Vec = snapshot + .windows + .iter() + .filter(|w| !w.app_id.is_empty()) + .map(|w| w.app_id.to_lowercase()) + .collect(); + for entry in dots.borrow().iter() { + let visible = running.iter().any(|id| id == &entry.app_id); + entry.dot.set_visible(visible); + } +} diff --git a/crates/vibepanel/src/widgets/mod.rs b/crates/vibepanel/src/widgets/mod.rs index fd5dffb4..b7306a1c 100644 --- a/crates/vibepanel/src/widgets/mod.rs +++ b/crates/vibepanel/src/widgets/mod.rs @@ -22,6 +22,7 @@ mod cpu; mod custom; mod gpu; mod keyboard_layout; +pub mod launcher; pub mod layer_shell_popover; mod marquee_label; mod media; @@ -76,6 +77,7 @@ pub use cpu::{CpuConfig, CpuWidget}; pub use custom::{CustomConfig, CustomWidget}; pub use gpu::{GpuConfig, GpuWidget}; pub use keyboard_layout::{KeyboardLayoutConfig, KeyboardLayoutWidget}; +pub use launcher::{LauncherConfig, LauncherWidget}; pub use memory::{MemoryConfig, MemoryWidget}; pub use network_speed::{NetworkSpeedConfig, NetworkSpeedWidget}; @@ -267,6 +269,12 @@ impl WidgetFactory { let root = taskbar.widget().clone().upcast::(); Some(BuiltWidget::new(root, taskbar)) } + "launcher" => { + let cfg = LauncherConfig::from_entry(entry); + let launcher = LauncherWidget::new(cfg, output_id.map(|s| s.to_string())); + let root = launcher.widget().clone().upcast::(); + Some(BuiltWidget::new(root, launcher)) + } "tray" => { let cfg = TrayConfig::from_entry(entry); let tray = TrayWidget::new(cfg); From 6c745de415d176b8b0e3fdc47074f0e2232f2589 Mon Sep 17 00:00:00 2001 From: vi Date: Sun, 12 Jul 2026 22:54:22 +0300 Subject: [PATCH 2/3] style: fix import ordering in dock.rs cargo fmt --check in the pre-commit hook flagged the crate-local imports placed before the external ones. Reorder so all `crate::` imports follow the external crate imports, matching convention. Co-Authored-By: Claude (Opus 4.6) <> --- crates/vibepanel-core/src/config.rs | 6 -- crates/vibepanel/src/dock.rs | 56 +++++++++---------- .../src/services/compositor/hyprland.rs | 16 +----- crates/vibepanel/src/widgets/launcher.rs | 16 ++++-- 4 files changed, 39 insertions(+), 55 deletions(-) diff --git a/crates/vibepanel-core/src/config.rs b/crates/vibepanel-core/src/config.rs index 8ea995fa..e15dca44 100644 --- a/crates/vibepanel-core/src/config.rs +++ b/crates/vibepanel-core/src/config.rs @@ -674,12 +674,6 @@ impl Config { { warnings.push(format!("theme.wallpaper: file '{}' not found", wallpaper)); } - // Warn if dock mode requested without Hyprland. - if self.bar.mode == "dock" && self.advanced.compositor != "hyprland" { - warnings.push( - "bar.mode = \"dock\" is only supported on Hyprland; the dock will fall back to a bottom bar.".to_string(), - ); - } warnings } diff --git a/crates/vibepanel/src/dock.rs b/crates/vibepanel/src/dock.rs index f4fe6fd3..85f4cdac 100644 --- a/crates/vibepanel/src/dock.rs +++ b/crates/vibepanel/src/dock.rs @@ -1,13 +1,10 @@ -//! Dock window implementation using GTK4 and layer-shell. -//! -//! The dock is a centered bottom "pill" that shows pinned launchers and running -//! windows. It auto-hides (Dash-to-Dock style) and reappears when the mouse hits -//! the bottom screen edge. Hyprland-only (gated in `create_bar_window`). - use std::cell::{Cell, RefCell}; use std::rc::Rc; use std::time::Duration; +use crate::services::bar_manager::BarManager; +use crate::services::compositor::CompositorManager; +use crate::widgets::{BarState, WidgetConfig}; use gtk4::glib::{self, SourceId}; use gtk4::prelude::*; use gtk4::{ @@ -15,11 +12,7 @@ use gtk4::{ }; use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use tracing::{debug, info, warn}; -use vibepanel_core::Config; - -use crate::services::bar_manager::BarManager; -use crate::services::compositor::CompositorManager; -use crate::widgets::BarState; +use vibepanel_core::config::{Config, WidgetEntry}; /// Build the dock content: a centered horizontal pill hosting the configured /// widgets (launchers + taskbar). @@ -38,9 +31,18 @@ pub(crate) fn build_dock_content( // Build the configured widgets (launchers, taskbar, etc.) into the pill. // Reuse the bar's section factory so widget ordering/merging behaves identically. - let qs = crate::widgets::QuickSettingsWindowHandle::new( - app.clone(), - crate::widgets::QuickSettingsConfig::default(), + let qs_config = config + .widgets + .get_options("quick_settings") + .map(|opts| { + let entry = WidgetEntry::with_options("quick_settings", opts); + crate::widgets::QuickSettingsConfig::from_entry(&entry) + }) + .unwrap_or_default(); + let qs = crate::widgets::QuickSettingsWindowHandle::new(app.clone(), qs_config); + crate::popover_registry::register( + "quick_settings", + Rc::new(qs.clone()) as Rc, ); let left_section = crate::bar::create_section( "left", @@ -122,11 +124,15 @@ pub fn create_dock_window( window.set_monitor(Some(monitor)); debug!("Dock bound to monitor: {:?}", monitor.connector()); - // Anchor to the bottom edge and stretch across the full width. + // Anchor to the bottom edge; let the compositor center horizontally. + // We deliberately do NOT anchor Left/Right — anchoring across the full + // width would make the transparent surface swallow clicks on underlying + // windows at the bottom of the screen. With only Bottom anchored, GTK + // auto-sizes the width to the dock content and centers it. window.set_anchor(Edge::Top, false); window.set_anchor(Edge::Bottom, true); - window.set_anchor(Edge::Left, true); - window.set_anchor(Edge::Right, true); + window.set_anchor(Edge::Left, false); + window.set_anchor(Edge::Right, false); // Reserve space only if the user explicitly pinned the dock to the edge. if config.dock.pin_to_edge { @@ -142,16 +148,10 @@ pub fn create_dock_window( let content = build_dock_content(app, config, state, Some(output_id)); window.set_child(Some(&content)); - // Set window width to the target monitor's width on map. - let target_geometry = monitor.geometry(); - let target_width = target_geometry.width(); - + // Let GTK auto-size the dock width to its content; only enforce height. window.connect_map(move |win| { - win.set_default_size(target_width, dock_height as i32); - debug!( - "Set dock window size to target monitor width: {}px", - target_width - ); + win.set_default_size(-1, dock_height as i32); + debug!("Set dock window height: {}px", dock_height); }); // Auto-hide wiring (Dash-to-Dock style). @@ -265,7 +265,3 @@ fn install_dock_auto_hide( /// Opaque handle that keeps the auto-hide timer alive for the dock's lifetime. #[allow(dead_code)] struct DockAutoHideState(Rc>>); - -// Ensure the handle is Send + Sync for BarState's Vec>. -unsafe impl Send for DockAutoHideState {} -unsafe impl Sync for DockAutoHideState {} diff --git a/crates/vibepanel/src/services/compositor/hyprland.rs b/crates/vibepanel/src/services/compositor/hyprland.rs index d5f1c094..018908f9 100644 --- a/crates/vibepanel/src/services/compositor/hyprland.rs +++ b/crates/vibepanel/src/services/compositor/hyprland.rs @@ -1377,19 +1377,9 @@ impl CompositorBackend for HyprlandBackend { } fn focus_window(&self, window_id: u64) { - if let Some(clients) = self.query_json("clients") - && let Some(addr) = clients.as_array().and_then(|arr| { - arr.iter() - .find(|c| { - c.get("address").and_then(|a| a.as_str()).and_then(|a| { - u64::from_str_radix(a.strip_prefix("0x").unwrap_or(a), 16).ok() - }) == Some(window_id) - }) - .and_then(|c| c.get("address").and_then(|a| a.as_str().map(String::from))) - }) - { - let _ = self.send_command(&format!("dispatch focuswindow address:{addr}")); - } + // window_id is already the parsed u64 of the hex address; format it + // back to hex directly instead of re-querying `clients` over IPC. + let _ = self.send_command(&format!("dispatch focuswindow address:0x{:x}", window_id)); } fn get_keyboard_layout(&self) -> Option { diff --git a/crates/vibepanel/src/widgets/launcher.rs b/crates/vibepanel/src/widgets/launcher.rs index 21ec97d6..2fd76417 100644 --- a/crates/vibepanel/src/widgets/launcher.rs +++ b/crates/vibepanel/src/widgets/launcher.rs @@ -115,8 +115,6 @@ struct LauncherDot { pub struct LauncherWidget { base: BaseWidget, - #[allow(dead_code)] - current_snapshot: Rc>, window_list_callback_id: CallbackId, } @@ -202,11 +200,9 @@ impl LauncherWidget { Self { base, - current_snapshot: snapshot, window_list_callback_id, } } - pub fn widget(&self) -> &GtkBox { self.base.widget() } @@ -235,8 +231,16 @@ fn handle_launcher_click(app_id: &str, exec: &str, snapshot: &Rc { + // Reap the child asynchronously to avoid zombie processes. + std::thread::spawn(move || { + let _ = child.wait(); + }); + } + Err(e) => { + warn!("launcher: failed to spawn {:?}: {e}", exec); + } } } From be69215fb807af4453ae6b09daf48bdae199b6cc Mon Sep 17 00:00:00 2001 From: vi Date: Tue, 14 Jul 2026 00:02:14 +0300 Subject: [PATCH 3/3] docs: upd agents md --- AGENTS.md | 259 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..e75c1583 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,259 @@ +# Repository Guidelines + +This document is for AI assistants working with the VibePanel codebase — a Rust GTK4 Wayland panel/status bar. + +## Project Overview + +VibePanel is a **batteries-included Wayland status bar** written in pure Rust. It replaces the status bar, notification daemon, and OSD with a single binary. It works with Hyprland, Niri, Sway, MangoWC/DWL, and other compositors. + +- **Language:** Rust (edition 2024) +- **Binary:** `vibepanel` (single binary, no runtime dependencies) +- **Version:** 0.15.0 (pre-1.0, actively developed) +- **License:** MIT + +## Architecture & Data Flow + +``` +vibepanel (binary crate) +├── src/main.rs — CLI entry (clap), GTK app init, monitor hotplug, IPC listener +├── src/bar.rs — Bar window (GTK4 layer-shell, edge click targets, popover anchoring) +├── src/dock.rs — Dock window (auto-hide, icon magnification) +├── src/sectioned_bar.rs — Custom GTK widget for left/center/right layout allocation +├── src/popover_registry.rs — Global popover open/close dispatch by name +├── src/popover_tracker.rs — Tracks which popover is currently active +├── src/widgets/ — ~25 widget modules (clock, battery, workspaces, media, tray, …) +├── src/services/ — Singleton services with callback-based state updates +│ ├── compositor/ — Hyprland, Niri, Sway, MangoWC backends via raw socket + JSON +│ ├── network/ — NetworkManager + IWD for wifi/mobile/vpn +│ └── *.rs — audio, battery, bluetooth, brightness, gpu, media, notifications, … +└── src/styles.rs — CSS class/state constants + +vibepanel-core (library crate) +├── src/config.rs — TOML config parsing, validation, defaults +├── src/theme.rs — ThemePalette (Material Design colors, luminance, wallpaper extraction) +├── src/error.rs — thiserror-based Error enum +├── src/logging.rs — tracing subscriber init +└── tests/config_integration.rs — Config parsing integration tests +``` + +### Key Patterns + +**Singleton services** — Most services use `Rc` + `Rc::new(Self::new())` for global access. They register callbacks to notify UI when state changes. Pattern: + +```rust +fn new() -> Rc { ... } + +pub fn global() -> Rc { + static INSTANCE: OnceLock> = OnceLock::new(); + INSTANCE.get_or_init(|| Self::new()).clone() +} +``` + +**Widget factory** — `WidgetFactory::build(entry, qs_handle, output_id)` constructs widgets from `WidgetEntry` config (match on `entry.name`). Each widget returns a `BuiltWidget` with a root `gtk4::Widget` and an optional `EdgeInteraction` for edge-click popovers. + +**Layer-shell positioning** — Bar uses `gtk4_layer_shell` to anchor to screen edges. `BarPosition` enum (`Top`, `Bottom`, `Left`, `Right`) drives both bar placement and popover anchor direction. + +**Compositor abstraction** — `CompositorBackend` trait + `BackendKind` enum (Hyprland, Niri, Sway, Mango, Dwl). Detected via `WAYLAND_DISPLAY` env + IPC socket detection. Hyprland uses raw socket IPC; others use JSON over socket. + +**Callback registry** — `services/callbacks.rs` provides a generic `CallbackRegistry` for service → widget state updates. Services hold a registry; widgets subscribe on construction. + +## Key Directories + +| Directory | Purpose | +|---|---| +| `crates/vibepanel/src/` | Main binary crate source | +| `crates/vibepanel/src/widgets/` | Individual widget modules | +| `crates/vibepanel/src/widgets/css/` | Per-widget CSS blocks (strings compiled into providers) | +| `crates/vibepanel/src/widgets/quick_settings/` | Quick settings panel components | +| `crates/vibepanel/src/services/` | Singleton services | +| `crates/vibepanel/src/services/compositor/` | Compositor backend implementations | +| `crates/vibepanel/src/services/network/` | Network service + NetworkManager/IWD | +| `crates/vibepanel-core/src/` | Core library (no GTK deps) | +| `crates/vibepanel-core/tests/` | Config integration tests | +| `scripts/` | UI regression runner, font subset script | +| `docs/` | Architecture doc, UI regression test guide | +| `assets/fonts/` | Subsetted Material Symbols Rounded font | + +## Development Commands + +### Build & Run + +```bash +# Build +cargo build -p vibepanel + +# Debug build + run (logs to /tmp/vibepanel-debug.log) +./run-debug.sh + +# Release build +cargo build --release -p vibepanel +``` + +### Testing + +```bash +# Unit tests +cargo test --verbose + +# Clippy + tests (CI target) +cargo clippy --all-targets -- -D warnings +cargo test --verbose + +# UI regression tests (requires Xvfb) +xvfb-run -a env -u GDK_BACKEND=x11 GSK_RENDERER=cairo \ + VIBEPANEL_UI_REGRESSION_REQUIRED=1 \ + cargo test -p vibepanel test_ui_regression_ -- --ignored --test-threads=1 + +# Or via script: +./scripts/run-ui-regression-tests.sh +``` + +UI regression tests are `#[test]` functions prefixed `test_ui_regression_` with `#[ignore]`, spawned as a subprocess (file-locked, Xvfb, Cairo software renderer). They compare rendered pixel output against known fixtures. + +### Linting & Formatting + +```bash +cargo fmt --check +cargo clippy --all-targets -- -D warnings +``` + +No custom clippy or rustfmt config files — uses defaults. + +### Config Validation + +```bash +vibepanel --check-config +vibepanel --print-example-config +``` + +### Font Management + +```bash +# Subset Material Symbols font to used glyphs only +./scripts/subset-font.sh + +# Check glyph manifest is up to date +./scripts/subset-font.sh --check +``` + +## Code Conventions + +### Error Handling +- `vibepanel-core/src/error.rs` — `thiserror` enum `Error` with `Result = std::result::Result` +- Binary crate uses `anyhow::Result` for fallible operations that don't need typed errors +- Never use `unwrap()` in production code (use `?` or `.context()`) + +### Logging +- `tracing` crate throughout (info, debug, warn, error) +- `vibepanel-core/src/logging.rs::init(verbosity: u8)` — sets up env-filter subscriber +- Verbosity: `-v` = info, `-vv` = debug, `-vvv` = trace + +### GTK Patterns +- GTK4 only, with `gtk4_layer_shell` for Wayland layer-shell +- Custom `SectionedBar` widget handles left/center/right allocation +- `BaseWidget` in `widgets/base.rs` provides the common root `gtk4::Box` with CSS classes +- `MenuHandle` wraps `LayerShellPopover` (not plain GTK `Popover`) for proper keyboard focus and ESC/click-outside dismiss +- Widget configs implement `From<&WidgetEntry>` trait + +### Module Visibility +- `pub` = public API (core crate types, widget constructors) +- `pub(crate)` = crate-internal (widget internals, service internals) +- `pub mod` only for `launcher`, `layer_shell_popover`, `css`, `quick_settings` (used across modules) +- No `mod` visibility in main crate (everything is either `pub(crate)` or fully public) + +### Naming +- Widget module names match config widget names: `clock.rs` → `"clock"`, `quick_settings.rs` → `"quick_settings"` +- Service structs: `FooService` (e.g., `BatteryService`, `CompositorManager`) +- Config structs: `FooConfig` (e.g., `ClockConfig`, `TrayConfig`) +- Test functions: `snake_case` with descriptive names, prefixed `test_` (standard Rust) + +### Threading +- GTK operations on main thread only +- Service IPC/background threads communicate via `parking_lot::Mutex`, `std::sync::RwLock`, `std::sync::atomic` +- No `async/await` for service communication (channels are sync) +- `async-channel` for Cava (audio visualizer) subprocess communication + +### Dependency Declaration +- All shared deps in `[workspace.dependencies]` in root `Cargo.toml` +- Child crates use `depname = { workspace = true }` +- Version metadata in `[workspace.package]` — single source of truth + +## Important Files + +| File | Why it matters | +|---|---| +| `Cargo.toml` | All dep versions, workspace members, edition 2024 | +| `crates/vibepanel/src/main.rs` | CLI (clap), app init, monitor hotplug, IPC listener | +| `crates/vibepanel/src/bar.rs` | Bar window creation, layer-shell setup, edge clicks | +| `crates/vibepanel/src/dock.rs` | Dock window, auto-hide, magnification | +| `crates/vibepanel/src/widgets/mod.rs` | WidgetFactory, WidgetConfig trait, all widget exports | +| `crates/vibepanel/src/widgets/base.rs` | BaseWidget, MenuHandle, common widget helpers | +| `crates/vibepanel-core/src/config.rs` | Config load/validate/defaults, WidgetEntry parsing | +| `crates/vibepanel-core/src/theme.rs` | ThemePalette, material color extraction, CSS var generation | +| `crates/vibepanel/src/services/compositor/factory.rs` | `BackendKind` enum, compositor detection | +| `crates/vibepanel/src/services/config_manager.rs` | Global config access, CSS hot-reload, theme callbacks | +| `crates/vibepanel/src/popover_registry.rs` | Global popover dispatch by string name | +| `config.toml` | Example config (also the real dev config) | +| `.github/workflows/ci.yml` | CI pipeline — fmt, clippy, test, ui-regression, font-check | + +## Runtime & Tooling Preferences + +### Rust +- **Edition:** 2024 (unstable edition, not 2021) +- **No MSRV pinned** — CI uses `rust:trixie` container (rolling stable) +- **No `rust-toolchain` file** + +### System Dependencies (for build) +``` +libgtk-4-dev, libgtk4-layer-shell-dev, libpulse-dev, libudev-dev, libdbus-1-dev +``` +On Arch: `gtk4`, `gtk4-layer-shell`, `pulseaudio`, `udev`, `dbus` +On Debian/Ubuntu: listed above +On Fedora: `gtk4-devel`, `gtk4-layer-shell-devel`, `pulseaudio-libs-devel`, `systemd-devel`, `dbus-devel` + +### Runtime Dependencies (for run) +- Wayland compositor (Hyprland, Niri, Sway, etc.) +- D-Bus session bus (for battery, bluetooth, network, notifications) +- PulseAudio or PipeWire (for audio control) +- udev (for brightness/backlight discovery) + +### Tool Versions (CI-confirmed working) +- Rust: latest stable (from `rust:trixie`) +- GTK4: 0.10 +- gtk4-layer-shell: 0.7 +- libpulse-binding: 2.28 + +### Build Targets +- x86_64-unknown-linux-gnu (native) +- aarch64-unknown-linux-gnu (cross-compiled) + +## Testing & QA + +### Test Types + +1. **Unit tests** — `#[test]` functions in `mod tests { ... }` blocks or `#[cfg(test)]` modules + - Run: `cargo test --verbose` + - Examples: `layout_math.rs`, `sectioned_bar_tests.rs`, `bar_tests.rs`, `config_integration.rs` + +2. **UI regression tests** — `#[test]` functions prefixed `test_ui_regression_` with `#[ignore]` + - Spawned as subprocesses under Xvfb with `GSK_RENDERER=cairo` + - Pixel-snapshot comparison against fixture images + - Run: `cargo test -p vibepanel test_ui_regression_ -- --ignored --test-threads=1` (with Xvfb) + +3. **Contract tests** — `#[test]` functions prefixed `run_layer_shell_*_contract` (in `bar_tests.rs`, `osd_tests.rs`) + - Verify layer-shell positioning and edge-click behavior + - Also run as ignored subprocesses + +### CI Pipeline + +``` +fmt (cargo fmt --check) + → clippy + test (cargo clippy --all-targets -- -D warnings && cargo test --verbose) + → ui-regression-tests (Xvfb + script) + → font-check (scripts/subset-font.sh --check) +``` + +RUSTFLAGS: `-Dwarnings` (warnings become errors in CI) + +### Pre-commit Hooks +`.cargo-husky/hooks/pre-commit` runs: tests, clippy, fmt, ui-regression (if xvfb available) \ No newline at end of file