control panel: real padding, seat grab, key hints

- GtkBox set_border_width shrinks the widget's own background, so the card was
  inset 30px while its contents sat flush against the card edge. Padding is css
  now: 12px transparent gutter outside, 20px inside. Measured on a virtual
  display rather than guessed.
- the panel grabs the seat on map, like rofi, so it keeps keyboard and pointer
  focus wherever the cursor is; the grab is released on close
- every control shows its key in the bottom-right: n b v d p w for the tiles,
  a c s t for the row beneath, l o z r q for power, m/i to mute, arrows for
  volume

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cn3dHfbgNYdbZvzb1b3GQt
This commit is contained in:
Sarthak 2026-09-04 08:46:40 +05:30
parent 9853553782
commit ddf9321901
3 changed files with 109 additions and 28 deletions

View file

@ -59,7 +59,7 @@ bspc config pointer_action2 resize_side
bspc config pointer_action3 resize_corner
# Rules
bspc rule -a Control-panel.py state=floating layer=above center=on sticky=on border=off
bspc rule -a Control-panel.py state=floating layer=above center=on sticky=on border=off follow=on focus=on
bspc rule -a feh state=floating
bspc rule -a '*:sun-awt-X11-XWindowPeer' manage=off
bspc rule -a 'vlc' state=floating center=true

View file

@ -51,7 +51,20 @@ sliders are live and stepping back between views never redraws the panel:
| Wallpaper / Particles | shuffle, toggle the particle layer |
| Power | lock, log out, suspend, reboot, shut down |
Escape steps back from a subview; Escape on the main view closes it.
Escape steps back from a subview; Escape on the main view closes it. The panel
grabs the seat like rofi does, so it keeps focus wherever the pointer is.
### Panel keys
Each control shows its key in the bottom-right corner.
| | | | |
|---|---|---|---|
| `n` network | `b` bluetooth | `v` vpn | `d` do-not-disturb |
| `p` particles | `w` wallpaper | `a` audio | `c` clipboard |
| `s` screenshot | `t` toggle bar | `m` mute | `i` mute mic |
| `l` lock | `o` log out | `z` suspend | `r` reboot |
| `q` shut down | `←/→` volume | `Esc` back / close | |
## Keybindings

View file

@ -32,11 +32,17 @@ window.panel { background-color: transparent; }
background-color: rgba(15, 13, 23, 0.88);
border: 1px solid rgba(146, 185, 234, 0.16);
border-radius: 18px;
padding: 20px;
}
.title { font-weight: bold; font-size: 15px; color: #e9eefb; }
.subtle { color: #8d85a4; font-size: 11px; }
.section { color: #92b9ea; font-size: 10px; font-weight: bold; letter-spacing: 1px; }
.hint {
color: #6f6889;
font-size: 9px;
font-weight: bold;
}
.tile-icon { font-size: 20px; color: #e9eefb; }
.tile-name { font-size: 12px; color: #e9eefb; }
.tile.active .tile-icon { color: #92b9ea; }
@ -255,6 +261,7 @@ class Panel(Gtk.Window):
# the window must not paint, or it paints opaque over the rgba visual
self.set_app_paintable(True)
self.keymap = {}
self.stack = Gtk.Stack()
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
self.stack.set_transition_duration(140)
@ -266,14 +273,14 @@ class Panel(Gtk.Window):
frame = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
frame.get_style_context().add_class("frame")
frame.set_border_width(20) # padding inside the card
for setter in ("set_margin_top", "set_margin_bottom",
"set_margin_start", "set_margin_end"):
getattr(frame, setter)(10) # transparent gutter outside it
getattr(frame, setter)(12) # transparent gutter outside the card
frame.add(self.stack)
self.add(frame)
self.connect("key-press-event", self.on_key)
self.connect("map-event", self.on_map)
self.connect("destroy", Gtk.main_quit)
self.refresh()
@ -304,13 +311,12 @@ class Panel(Gtk.Window):
self.t_vpn = self.tile(ICON["vpn_off"], "VPN", self.toggle_vpn)
self.t_dnd = self.tile(ICON["dnd_off"], "Notify", self.toggle_dnd)
self.t_par = self.tile(ICON["particles"], "Particles", self.toggle_particles)
self.t_wall = self.tile(ICON["wall"], "Wallpaper",
lambda *_: (spawn("sh ~/.config/scripts/set-wallpaper.sh"),
notify("Wallpaper", "Shuffled")))
for i, w in enumerate((self.t_net, self.t_bt, self.t_vpn)):
grid.attach(w, i, 0, 1, 1)
for i, w in enumerate((self.t_dnd, self.t_par, self.t_wall)):
grid.attach(w, i, 1, 1, 1)
self.t_wall = self.tile(ICON["wall"], "Wallpaper", lambda *_: self.shuffle_wall())
tiles = ((self.t_net, "n"), (self.t_bt, "b"), (self.t_vpn, "v"),
(self.t_dnd, "d"), (self.t_par, "p"), (self.t_wall, "w"))
for i, (w, k) in enumerate(tiles):
grid.attach(self.hint(w, k), i % 3, i // 3, 1, 1)
self.keymap[k] = (lambda _w=None, btn=w: btn.emit("clicked"))
box.add(grid)
# sliders
@ -326,41 +332,60 @@ class Panel(Gtk.Window):
# power row
prow = Gtk.Box(spacing=8, homogeneous=True)
for icon, tip, act, danger in (
(ICON["lock"], "Lock", self.do_lock, False),
(ICON["logout"], "Log out", lambda *_: spawn("bspc quit"), False),
(ICON["suspend"], "Suspend", lambda *_: (self.do_lock(), spawn("systemctl suspend")), False),
(ICON["reboot"], "Reboot", lambda *_: spawn("systemctl reboot"), True),
(ICON["power"], "Shut down", lambda *_: spawn("systemctl poweroff"), True),
for icon, tip, act, danger, key in (
(ICON["lock"], "Lock", self.do_lock, False, "l"),
(ICON["logout"], "Log out", lambda *_: spawn("bspc quit"), False, "o"),
(ICON["suspend"], "Suspend",
lambda *_: (self.do_lock(), spawn("systemctl suspend")), False, "z"),
(ICON["reboot"], "Reboot", lambda *_: spawn("systemctl reboot"), True, "r"),
(ICON["power"], "Shut down", lambda *_: spawn("systemctl poweroff"), True, "q"),
):
b = Gtk.Button(label=icon)
b.set_tooltip_text(tip)
b.set_tooltip_text(f"{tip} ({key})")
b.get_style_context().add_class("pw")
if danger:
b.get_style_context().add_class("danger")
b.connect("clicked", act)
prow.add(b)
self.keymap[key] = act
prow.add(self.hint(b, key))
box.add(prow)
extra = Gtk.Box(spacing=8, homogeneous=True)
for icon, tip, cmd in (
(ICON["vol"], "Audio devices & app volumes", "@audio"),
for icon, tip, cmd, key in (
(ICON["vol"], "Audio devices & app volumes", "@audio", "a"),
(ICON["clip"], "Clipboard history",
"sh ~/.config/scripts/rofi-menus/clipboard-manager.sh"),
(ICON["shot"], "Screenshot", "flameshot gui"),
(ICON["bar"], "Toggle bar", "sh ~/.config/scripts/toggle-bar.sh"),
"sh ~/.config/scripts/rofi-menus/clipboard-manager.sh", "c"),
(ICON["shot"], "Screenshot", "flameshot gui", "s"),
(ICON["bar"], "Toggle bar", "sh ~/.config/scripts/toggle-bar.sh", "t"),
):
b = Gtk.Button(label=icon)
b.set_tooltip_text(tip)
b.set_tooltip_text(f"{tip} ({key})")
b.get_style_context().add_class("pw")
if cmd == "@audio":
b.connect("clicked", lambda *_: self.go("audio"))
act = lambda *_: self.go("audio")
else:
b.connect("clicked", lambda _w, c=cmd: (spawn(c), self.close()))
extra.add(b)
act = lambda _w=None, c=cmd: (spawn(c), self.close())
b.connect("clicked", act)
self.keymap[key] = act
extra.add(self.hint(b, key))
box.add(extra)
return box
def hint(self, widget, letter):
"""Wrap a widget so a small key hint sits in its bottom-right."""
ov = Gtk.Overlay()
ov.add(widget)
lab = Gtk.Label(label=letter)
lab.get_style_context().add_class("hint")
lab.set_halign(Gtk.Align.END)
lab.set_valign(Gtk.Align.END)
lab.set_margin_end(7)
lab.set_margin_bottom(5)
ov.add_overlay(lab)
ov.set_overlay_pass_through(lab, True)
ov.btn = widget
return ov
def tile(self, icon, label, cb):
b = Gtk.Button()
b.get_style_context().add_class("tile")
@ -541,6 +566,26 @@ class Panel(Gtk.Window):
self.audio_box.add(card)
self.audio_box.show_all()
def on_map(self, *_):
"""Own the seat the way rofi does: keyboard and pointer both, so the
panel keeps focus wherever the cursor is or whatever gets clicked."""
self.present()
seat = Gdk.Display.get_default().get_default_seat()
gdkwin = self.get_window()
if seat and gdkwin:
seat.grab(gdkwin, Gdk.SeatCapabilities.ALL, True,
None, None, None, None)
return False
def release_grab(self):
seat = Gdk.Display.get_default().get_default_seat()
if seat:
seat.ungrab()
def close(self, *_):
self.release_grab()
super().close()
# -------------------------------------------------------------- actions
def go(self, name):
if name != "main":
@ -554,6 +599,25 @@ class Panel(Gtk.Window):
else:
self.close()
return True
if self.stack.get_visible_child_name() != "main":
return False
name = Gdk.keyval_name(ev.keyval) or ""
if name in ("m", "M"):
self.toggle_mute()
return True
if name in ("i", "I"):
self.toggle_micmute()
return True
if name in ("Left", "Right", "Up", "Down"):
step = -5 if name in ("Left", "Down") else 5
spawn(f"wpctl set-volume @DEFAULT_AUDIO_SINK@ "
f"{abs(step)}%{'-' if step < 0 else '+'} -l 1.3")
GLib.timeout_add(180, lambda: (self.refresh(), False)[1])
return True
act = self.keymap.get(name.lower())
if act:
act()
return True
return False
def on_vol(self, scale):
@ -584,6 +648,10 @@ class Panel(Gtk.Window):
spawn("particles || ~/.local/bin/particles")
GLib.timeout_add(700, lambda: (self.refresh(), False)[1])
def shuffle_wall(self):
spawn("sh ~/.config/scripts/set-wallpaper.sh")
notify("Wallpaper", "Shuffled")
def toggle_vpn(self, *_):
_, _, up = vpn_state()
if up: