Skip to content

feat(dock): add Hyprland-only macOS-style dock with auto-hiding - #1

Open
7n1m4 wants to merge 3 commits into
mainfrom
feat/hyprland-dock
Open

feat(dock): add Hyprland-only macOS-style dock with auto-hiding#1
7n1m4 wants to merge 3 commits into
mainfrom
feat/hyprland-dock

Conversation

@7n1m4

@7n1m4 7n1m4 commented Jul 12, 2026

Copy link
Copy Markdown
Member

Summary

Adds a new bar.mode = "dock" that transforms the vibepanel instance into a centered bottom "pill" (Dash-to-Dock style) showing pinned launchers and running-window indicators. The dock auto-hides when the mouse leaves and reveals when the cursor touches the bottom screen edge.

Hyprland-only: falls back to a bottom bar on other compositors.

  • Configbar.mode + DockConfig (autohide, icon_size, launchers, pin_to_edge, background_opacity, gap, magnification, magnified_icon_size) with validation, warnings, and hot-reload awareness
  • CSS — dock class constants, centered-pill + launcher-button + running-dot styles, --color-accent / --dock-gap theme-var registration
  • Dock window (src/dock.rs) — bottom-anchored layer-shell surface with a thin 3px hotzone and opacity timer for reveal/hide
  • Launcher widget (src/widgets/launcher.rs) — pinned-app buttons ([widgets.launcher] TOML) that focus a running window or spawn via sh -c, with running-indicator dots powered by WindowListService
  • Running-window data — implements HyprlandBackend::list_windows() / set_window_list_callback() / focus_window() via the clients JSON IPC query and event-driven snapshots, closing the long-standing Hyprland window-list gap

Verification

  • All existing unit/integration tests pass (cargo test --workspace: 642 + 151 + 27 + 1 doctest)
  • cargo clippy --all -- -D warnings clean
  • cargo fmt --check clean

Test plan

  • Under a live Hyprland compositor: set bar.mode = "dock" and confirm the centered bottom pill renders
  • Move cursor to the bottom edge → dock reveals; move away → dock auto-hides after ~200ms
  • Pinned launchers spawn their exec on click when no matching window is open
  • With a matching window open, clicking the launcher focuses the running window
  • Running-window indicator dots appear beneath launchers when their app is open
  • Toggling bar.mode between bar and dock hot-reloads without restart
  • On a non-Hyprland compositor the warning logs and a bottom bar appears (graceful fallback)

🤖 Generated with Claude Code

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new Hyprland-only dock mode for vibepanel, featuring a centered bottom pill with pinned launchers, running window indicators, and an auto-hide mechanism. Feedback on the implementation highlights several key improvements: optimizing the dock window layout by avoiding full-width anchoring (which blocks mouse input) and letting GTK auto-size the width; correctly parsing and registering the quick settings popover; removing unsafe and unnecessary Send/Sync implementations on DockAutoHideState; optimizing the focus_window IPC command in the Hyprland backend; removing a duplicate warning check in the configuration validation; cleaning up an unused field in the launcher widget; and preventing zombie process leaks by asynchronously reaping spawned launcher processes.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread crates/vibepanel/src/dock.rs Outdated
Comment on lines +125 to +129
// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Anchoring the dock window to both Left and Right makes it stretch across the entire width of the screen. Even though the window background is transparent, it will still intercept mouse clicks and block input to any underlying windows at the bottom of the screen.\n\nTo fix this, do not anchor the window to Left and Right. By only anchoring to Bottom, the compositor will automatically center the window horizontally, and its width will naturally shrink to fit the dock content. This completely eliminates the input blocking issue and makes the auto-hide behavior more precise.

Suggested change
// 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);
// Anchor to the bottom edge and center horizontally (do not anchor Left/Right).\n window.set_anchor(Edge::Top, false);\n window.set_anchor(Edge::Bottom, true);\n window.set_anchor(Edge::Left, false);\n window.set_anchor(Edge::Right, false);

Comment on lines +149 to +155
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
);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When centering the dock window horizontally (by not anchoring to Left and Right), we should not force the window width to target_width. Instead, we should let GTK auto-size the width to fit the content naturally, while only enforcing the height.

    window.connect_map(move |win| {\n        win.set_default_size(-1, dock_height as i32);\n        debug!(\n            \"Set dock window height: {}px\",\n            dock_height\n        );\n    });

Comment thread crates/vibepanel/src/dock.rs Outdated
Comment on lines +41 to +44
let qs = crate::widgets::QuickSettingsWindowHandle::new(
app.clone(),
crate::widgets::QuickSettingsConfig::default(),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Creating the QuickSettingsWindowHandle with QuickSettingsConfig::default() ignores the user's custom quick settings configuration (such as custom shutdown/reboot commands). Additionally, the handle is not registered with the popover registry, which breaks IPC toggling and popover tracking.\n\nWe should parse the quick settings configuration from the user's config and register the handle with the popover registry, matching the behavior in bar.rs.

    let qs_config = config\n        .widgets\n        .get_options(\"quick_settings\")\n        .map(|opts| {\n            let entry = vibepanel_core::config::WidgetEntry::with_options(\"quick_settings\", opts);\n            crate::widgets::QuickSettingsConfig::from_entry(&entry)\n        })\n        .unwrap_or_default();\n    let qs = crate::widgets::QuickSettingsWindowHandle::new(app.clone(), qs_config);\n    crate::popover_registry::register(\n        \"quick_settings\",\n        Rc::new(qs.clone()) as Rc<dyn crate::popover_registry::PopoverToggleable>,\n    );

Comment on lines +1379 to +1393
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}"));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This implementation of focus_window is highly inefficient. It queries all clients from Hyprland via IPC and parses a potentially large JSON array just to find the hex address string matching window_id.\n\nSince window_id is already the parsed u64 representation of the hex address, we can directly format it back to hex using 0x{:x} and send the command immediately. This completely avoids the IPC query and JSON parsing overhead.

    fn focus_window(&self, window_id: u64) {\n        let _ = self.send_command(&format!(\"dispatch focuswindow address:0x{:x}\", window_id));\n    }

Comment thread crates/vibepanel-core/src/config.rs Outdated
Comment on lines +677 to +682
// 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(),
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The warning checking if bar.mode == "dock" on a non-Hyprland compositor is duplicated in the warnings() function. It is already checked and added to warnings on lines 641-647. This duplicate block should be removed to prevent duplicate warnings from being emitted.

Comment thread crates/vibepanel/src/dock.rs Outdated
Comment on lines +269 to +271
// Ensure the handle is Send + Sync for BarState's Vec<Box<dyn Any>>.
unsafe impl Send for DockAutoHideState {}
unsafe impl Sync for DockAutoHideState {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Implementing Send and Sync unsafely on DockAutoHideState is dangerous and unnecessary. DockAutoHideState contains Rc<Cell<Option<SourceId>>>, which are fundamentally thread-unsafe (!Send and !Sync). Since BarState::add_handle accepts Box<dyn Any> without requiring Send or Sync bounds, these unsafe implementations can be safely removed to preserve Rust's safety guarantees.

Comment on lines +118 to +119
#[allow(dead_code)]
current_snapshot: Rc<RefCell<WindowListSnapshot>>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current_snapshot field is marked with #[allow(dead_code)] because it is never read. The click handler and window list callback capture their own cloned Rc references directly. We can remove this field from the struct to keep the code clean and maintainable.


Self {
base,
current_snapshot: snapshot,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Remove the initialization of the unused current_snapshot field.

Comment on lines +238 to +240
if let Err(e) = std::process::Command::new("sh").arg("-c").arg(exec).spawn() {
warn!("launcher: failed to spawn {:?}: {e}", exec);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Spawning a child process using std::process::Command::spawn without calling wait() or reaping it will leak zombie processes when the spawned application exits.\n\nTo prevent zombie processes, we should spawn a helper thread to wait on the child process asynchronously.

    match std::process::Command::new(\"sh\").arg(\"-c\").arg(exec).spawn() {\n        Ok(mut child) => {\n            std::thread::spawn(move || {\n                let _ = child.wait();\n            });\n        }\n        Err(e) => {\n            warn!(\"launcher: failed to spawn {:?}: {e}\", exec);\n        }\n    }

vi70x3 added 2 commits July 12, 2026 22:54
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) <<EMAIL>>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants