Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 42 additions & 22 deletions crates/vibepanel/src/bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,28 +229,32 @@ fn build_widget_or_group(

// Create a shared island container for the group
let island = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
island.add_css_class(class::WIDGET);
island.add_css_class(class::WIDGET_GROUP);

// Add the first widget's name as a CSS class for per-widget CSS variable targeting
// Normalize underscores to hyphens for CSS conventions
if let Some(first_entry) = group.first() {
island.add_css_class(&first_entry.name.replace('_', "-"));
}
island.add_css_class(class::WIDGET_WRAPPER);

// Create inner content box (matching BaseWidget structure)
let content = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
content.add_css_class(class::CONTENT);
content.set_vexpand(true);
content.set_valign(gtk4::Align::Fill);

// Visual surface for rounded background (see WIDGET_SURFACE doc).
// Visual surface — also carries .widget-group so user CSS
// targeting either class hits the background element, not the wrapper.
let surface = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
surface.add_css_class(class::WIDGET_SURFACE);
surface.add_css_class(class::WIDGET);
surface.add_css_class(class::WIDGET_GROUP);
surface.set_overflow(gtk4::Overflow::Hidden);
surface.set_hexpand(true);
surface.set_vexpand(true);

// Add the first widget's name as a CSS class on the surface for
// per-widget CSS variable targeting. Theme-generated selectors
// like `.widget.cpu` and user CSS like `.cpu { ... }` both hit
// the painted surface — not the transparent wrapper.
if let Some(first_entry) = group.first() {
let css_name = first_entry.name.replace('_', "-");
surface.add_css_class(&css_name);
}

surface.append(&content);
island.append(&surface);

Expand All @@ -273,20 +277,36 @@ fn build_widget_or_group(
let mut count = 0;

// Build entries individually (used for singletons and merge fallback).
let build_individually =
|entries: &[WidgetEntry], content: &gtk4::Box, state: &mut BarState| -> usize {
let mut n = 0;
for entry in entries {
if let Some(built) = WidgetFactory::build(entry, Some(qs_handle), output_id)
{
built.widget.remove_css_class(class::WIDGET);
content.append(&built.widget);
state.add_handle(built.handle);
n += 1;
let build_individually = |entries: &[WidgetEntry],
content: &gtk4::Box,
state: &mut BarState|
-> usize {
let mut n = 0;
for entry in entries {
if let Some(built) = WidgetFactory::build(entry, Some(qs_handle), output_id) {
// Group surface owns the background, so strip the
// standalone wrapper class and force the inner .widget
// surface transparent. .widget is kept for border-radius
// (ripple clipping); a scoped provider at transient
// priority beats user CSS that also targets .widget.
built.widget.remove_css_class(class::WIDGET_WRAPPER);
if let Some(surface) = built.widget.first_child() {
let provider = gtk4::CssProvider::new();
provider.load_from_string(
".widget { background-color: transparent; background-image: none; }",
);
#[allow(deprecated)]
surface
.style_context()
.add_provider(&provider, TRANSIENT_CSS_PRIORITY);
}
content.append(&built.widget);
state.add_handle(built.handle);
n += 1;
}
n
};
}
n
};

for (kind, start, end) in &runs {
let run_entries = &group[*start..*end];
Expand Down
9 changes: 8 additions & 1 deletion crates/vibepanel/src/services/surfaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,14 @@ popover.widget-menu.background * {{
let selector = if has_menu_content_class {
format!(".{}", surface::WIDGET_MENU_CONTENT)
} else {
css_name.to_string()
// Use the widget's first CSS class so the rule only targets
// the styled surface, not every descendant GtkBox.
let classes = widget.css_classes();
if let Some(first) = classes.first() {
format!(".{}", first.as_str())
} else {
css_name.to_string()
}
};

// Inner popover panels (.widget-menu-content) don't need shadow -
Expand Down
59 changes: 45 additions & 14 deletions crates/vibepanel/src/styles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@
//! This module centralizes all CSS class names used across the codebase,
//! making them discoverable, avoiding typos, and enabling IDE autocompletion.
//!
//! # Naming Convention
//!
//! - **`vp-` prefix**: Generic names that collide with GTK4/Adwaita CSS
//! classes (e.g., `card` → `vp-card`, `row` → `vp-row`, `primary` → `vp-primary`).
//! - **No prefix**: Domain-specific names unlikely to collide
//! (e.g., `widget`, `clock`, `bar`, `popover`, `notification-toast`).
//! - **Widget prefix**: Sub-element classes namespaced by widget name
//! (e.g., `media-*`, `notification-*`, `qs-*`, `battery-popover-*`).
//! - **State modifiers**: Short names used only in compound selectors
//! (e.g., `.active`, `.expanded`, `.clickable`).
//!
//! When adding a new class, check if the name exists in GTK4 or Adwaita
//! theme CSS. If it does, use the `vp-` prefix.
//!
//! # Usage
//!
//! ```ignore
Expand All @@ -15,7 +29,12 @@

/// Core structural/layout CSS classes.
pub mod class {
/// Base widget container class (`.widget`).
/// Outer widget wrapper (`.widget-wrapper`).
/// Rectangular hit target so clicks register in rounded corners.
pub const WIDGET_WRAPPER: &str = "widget-wrapper";

/// Widget visual surface (`.widget`).
/// Carries background and border-radius; the class users target in custom CSS.
pub const WIDGET: &str = "widget";

/// Widget item class (`.widget-item`).
Expand All @@ -37,12 +56,6 @@ pub mod class {
/// Passive widget marker (`.passive`).
pub const PASSIVE: &str = "passive";

/// Widget visual surface class (`.widget-surface`).
/// Applied to a GtkBox that carries the widget's background-color and
/// border-radius. Separated from `.widget` so the click target remains
/// rectangular (Fitts's Law) while the visual appearance is rounded.
pub const WIDGET_SURFACE: &str = "widget-surface";

/// Widget content inner box (`.content`).
pub const CONTENT: &str = "content";

Expand Down Expand Up @@ -642,13 +655,25 @@ pub mod widget {

/// Surface and popover classes.
pub mod surface {
/// Popover surface style (`.vp-surface-popover`).
pub const POPOVER: &str = "vp-surface-popover";
/// Layer-shell popover surface (`.popover`). Canonical user-facing class.
pub const POPOVER: &str = "popover";

/// Outer transparent wrapper around a layer-shell popover (`.popover-wrapper`).
pub const POPOVER_WRAPPER: &str = "popover-wrapper";

/// Widget menu popover (`.widget-menu`).
/// Deprecated: use [`POPOVER`]. Internal popover surface marker (`.vp-surface-popover`).
pub const SURFACE_POPOVER: &str = "vp-surface-popover";

/// Deprecated: use [`POPOVER`]. Popover styling (`.widget-menu`).
///
/// Still applied to native `gtk4::Popover` shells for CSS reset rules
/// and to layer-shell surfaces as a deprecated alias.
pub const WIDGET_MENU: &str = "widget-menu";

/// Widget menu content (`.widget-menu-content`).
/// Deprecated: use [`POPOVER_WRAPPER`]. Wrapper (`.widget-menu-wrapper`).
pub const WIDGET_MENU_WRAPPER: &str = "widget-menu-wrapper";

/// Widget menu content (`.widget-menu-content`). Internal.
pub const WIDGET_MENU_CONTENT: &str = "widget-menu-content";

/// No focus outline container (`.vp-no-focus`).
Expand Down Expand Up @@ -782,9 +807,12 @@ pub mod notification {
pub const ROW_DISMISSING: &str = "notification-row-dismissing";

// Toast
/// Toast window (`.notification-toast`).
/// Toast surface (`.notification-toast`). User-facing, on the styled container.
pub const TOAST: &str = "notification-toast";

/// Toast transparent window (`.notification-toast-wrapper`).
pub const TOAST_WRAPPER: &str = "notification-toast-wrapper";

/// Toast container (`.notification-toast-container`).
pub const TOAST_CONTAINER: &str = "notification-toast-container";

Expand Down Expand Up @@ -824,8 +852,11 @@ pub mod notification {

/// On-Screen Display (OSD) classes.
pub mod osd {
/// OSD window (`.osd-window`).
pub const WINDOW: &str = "osd-window";
/// OSD surface (`.osd`). User-facing, on the styled container.
pub const OSD: &str = "osd";

/// OSD transparent window (`.osd-wrapper`).
pub const WRAPPER: &str = "osd-wrapper";

/// OSD widget container (`.osd-widget`).
pub const WIDGET: &str = "osd-widget";
Expand Down
24 changes: 19 additions & 5 deletions crates/vibepanel/src/widgets/base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,14 +505,20 @@ impl BaseWidget {

fn new_inner(extra_classes: &[&str], passive: bool) -> Self {
let container = GtkBox::new(Orientation::Horizontal, 0);
container.add_css_class(class::WIDGET);
container.add_css_class(class::WIDGET_WRAPPER);
container.add_css_class(class::WIDGET_ITEM);
if passive {
container.add_css_class(class::PASSIVE);
}
container.set_hexpand(false);
for cls in extra_classes {
container.add_css_class(cls);

// Widget-specific classes (e.g. "clock", "battery") are added to the
// surface, not the wrapper. Passive widgets have no surface so they
// keep the classes on the container directly.
if passive {
for cls in extra_classes {
container.add_css_class(cls);
}
}

// First extra class is the widget name (e.g., "clock", "battery")
Expand Down Expand Up @@ -550,15 +556,23 @@ impl BaseWidget {
// Visual surface: rounded background + overflow clipping.
// Must be a GtkBox (not Overlay) — Overlay doesn't clip background to border-radius.
let surface = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
surface.add_css_class(class::WIDGET_SURFACE);
surface.add_css_class(class::WIDGET);
// Widget-specific classes live on the surface so user CSS like
// `.clock { background: ... }` targets the painted element only.
for cls in extra_classes {
surface.add_css_class(cls);
}
surface.set_overflow(gtk4::Overflow::Hidden);
surface.set_hexpand(true);
surface.set_vexpand(true);

// Wrap content in an Overlay so the ripple effect can sit on top
// without affecting the widget background or content opacity.
// overflow:hidden + inherited border-radius clips the ripple to
// rounded corners (GtkBox parent overflow alone doesn't suffice).
let overlay = Overlay::new();
overlay.set_child(Some(&content));
overlay.set_overflow(gtk4::Overflow::Hidden);
overlay.set_hexpand(true);
overlay.set_vexpand(true);

Expand Down Expand Up @@ -877,7 +891,7 @@ impl BaseWidget {

/// Get the root GTK container for this widget.
///
/// This is the outermost box with the `widget` CSS class.
/// This is the outermost box (`.widget-wrapper`).
/// Most widgets should use `content()` to add children instead.
pub fn widget(&self) -> &GtkBox {
&self.container
Expand Down
42 changes: 23 additions & 19 deletions crates/vibepanel/src/widgets/css/bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,21 +60,25 @@ sectioned-bar.bar {{
color: var(--color-foreground-primary);
}}

/* Widget - individual widget containers */
.widget {{
/* Widget wrapper — transparent so only .widget paints a background layer */
.widget-wrapper {{
min-height: var(--widget-height);
background: transparent;
}}

/* Widget visual surface — rounded background, rectangular click target */
.widget-surface {{
/* Widget — visual surface */
.widget {{
background-color: {widget_bg};
border-radius: var(--radius-widget);
}}

/* Padding on .content (not the container) so the ripple overlay
fills the entire widget background area edge-to-edge */
fills the entire widget background area edge-to-edge.
Passive widgets (merge groups) have no .widget surface — target
their .content directly via the third selector. */
.widget:not(.widget-group) .content,
.widget-group .content > .widget-item .content {{
.widget-group > .content > .widget-item .content,
.widget-item.passive > .content {{
padding: var(--widget-padding-y) 10px;
}}

Expand All @@ -83,13 +87,16 @@ sectioned-bar.bar {{
padding: 0;
}}

/* Widget hover — :hover on rectangular .widget, visual on rounded .widget-surface */
.widget.clickable:not(.widget-group):hover > .widget-surface {{
/* Hover targets the wrapper but paints on the surface child */
.widget-wrapper.clickable:hover > .widget:not(.widget-group) {{
background-color: {widget_bg_hover};
}}

/* Pull non-first items left to overlap adjacent .content padding (2 × 10px) */
.widget-group .content > .widget-item:not(:first-child) {{
/* Pull non-first items left to overlap adjacent .content padding (2 × 10px).
Merge groups (.widget-merge-group) are also direct children of .content,
so they need the same treatment when they follow another item. */
.widget-group .content > .widget-item:not(:first-child),
.widget-group .content > .widget-merge-group:not(:first-child) {{
margin-left: -20px;
}}

Expand All @@ -99,16 +106,15 @@ sectioned-bar.bar {{
border-radius: var(--radius-widget);
}}

/* Reset nested surface: group already provides background.
Keep border-radius: inherit so overflow:hidden still clips the ripple. */
.widget-group .widget-surface .widget-surface {{
/* Nested surfaces transparent — theme-priority fallback for grouped active
widgets. The primary suppression is a scoped CSS provider in bar.rs
(transient priority), but this catches edge cases at theme priority. */
.widget.widget-group .widget {{
background-color: transparent;
border-radius: inherit;
}}

/* Widget items inside groups - individual clickable hover targets.
Use only the tint overlay (not the full hover color) because the
parent .widget-group already provides the base widget background. */
/* Grouped item hover — tint only (group surface provides base background) */
.widget-group .content > .widget-item.clickable:hover {{
background-color: color-mix(in srgb, transparent 92%, var(--widget-hover-tint));
}}
Expand All @@ -127,9 +133,7 @@ sectioned-bar.bar {{
background-color: color-mix(in srgb, transparent 92%, var(--widget-hover-tint));
}}

/* Passive widgets inside a merge group must not show their own
hover — the wrapper provides it. Background is already transparent
via .widget-group .widget-surface .widget-surface rule. */
/* Passive items in merge groups don't show their own hover */
.merge-group-content > .widget-item.passive:hover {{
background-color: transparent;
}}
Expand Down
9 changes: 6 additions & 3 deletions crates/vibepanel/src/widgets/css/base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ label link:active {{
color: var(--color-foreground-primary);
}}

popover.widget-menu {{
popover.widget-menu,
box.popover-wrapper,
box.widget-menu-wrapper {{
background: transparent;
border: none;
box-shadow: none;
Expand All @@ -151,6 +153,7 @@ popover.widget-menu.background > contents {{
/* When GTK's 3 s focus_visible timeout fires, the focused widget keeps :focus
but loses :focus-visible. Suppress Adwaita's residual :focus outline so no
faint ring lingers after keyboard nav times out. */
.popover *:focus:not(:focus-visible),
.vp-surface-popover *:focus:not(:focus-visible) {{
outline: none;
box-shadow: none;
Expand Down Expand Up @@ -258,7 +261,7 @@ popover.widget-menu.background > contents {{
}}

/* Inherit border-radius so the ripple clips to the rounded shape */
.widget-surface > overlay,
.widget > overlay,
.widget-item overlay {{
border-radius: inherit;
}}
Expand All @@ -285,7 +288,7 @@ button:hover {{
{hover_transition}
}}

.widget-surface {{
.widget {{
{hover_transition}
}}

Expand Down
Loading
Loading