control panel: unscroll the device lists, add display, drop toggle bar

Bluetooth and Network were still a ScrolledWindow around a ListBox, so short
lists were pinned to a fixed height and scrolled for no reason. They are plain
cards now, sized to content, matching the audio page.

Display is back as its own view: modes from xrandr with the active one ticked,
showing the rate actually in use rather than the highest the mode supports.
Toggle bar is gone from the action row - super+b still does it.

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 09:17:58 +05:30
parent 8cf900b58b
commit 10fca7d9e4
2 changed files with 64 additions and 29 deletions

View file

@ -47,7 +47,7 @@ sliders are live and stepping back between views never redraws the panel:
| Audio | live volume and microphone sliders; click the icon to mute |
| VPN | WireGuard up/down via `systemctl wg-quick@…` |
| Notifications | do-not-disturb toggle |
| Clipboard / Screenshot / Bar | greenclip, flameshot, hide the bar |
| Clipboard / Screenshot / Display | greenclip, flameshot, resolution |
| Wallpaper / Particles | shuffle, toggle the particle layer |
| Power | lock, log out, suspend, reboot, shut down |
@ -62,7 +62,7 @@ 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 |
| `s` screenshot | `e` display | `m` mute | `i` mute mic |
| `l` lock | `o` log out | `z` suspend | `r` reboot |
| `q` shut down | `←/→` volume | `Esc` back / close | |

View file

@ -131,7 +131,7 @@ ICON = {
"dnd_on": "󰂛", "dnd_off": "󰂚", "particles": "󰝨", "wall": "󰸉",
"lock": "󰌾", "logout": "󰍃", "suspend": "󰒲", "reboot": "󰜉", "power": "󰐥",
"back": "󰅁", "refresh": "󰑓", "vpn_on": "󰖂", "vpn_off": "󰕥",
"clip": "󰅍", "shot": "󰹑", "bar": "󰍹",
"clip": "󰅍", "shot": "󰹑", "bar": "󰍹", "screen": "󰍹",
}
@ -315,6 +315,8 @@ class Panel(Gtk.Window):
self.stack.add_named(self.build_list_view("Bluetooth", self.fill_bt), "bt")
self.stack.add_named(self.build_list_view("Network", self.fill_net), "net")
self.stack.add_named(self.build_audio_view(), "audio")
self.stack.add_named(
self.build_list_view("Display", None, rescan=False), "display")
frame = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
frame.get_style_context().add_class("frame")
@ -401,13 +403,13 @@ class Panel(Gtk.Window):
(ICON["clip"], "Clipboard history",
"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"),
(ICON["screen"], "Display resolution", "@display", "e"),
):
b = Gtk.Button(label=icon)
b.set_tooltip_text(f"{tip} ({key})")
b.get_style_context().add_class("pw")
if cmd == "@audio":
act = lambda *_: self.go("audio")
if cmd.startswith("@"):
act = lambda _w=None, v=cmd[1:]: self.go(v)
else:
act = lambda _w=None, c=cmd: (spawn(c), self.close())
b.connect("clicked", act)
@ -466,31 +468,25 @@ class Panel(Gtk.Window):
scale._pct = pct
return row, btn, scale
def build_list_view(self, title, filler):
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
def build_list_view(self, title, filler=None, rescan=True):
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
head = Gtk.Box(spacing=8)
back = Gtk.Button(label=f"{ICON['back']} {title}")
back.get_style_context().add_class("rowbtn")
back.connect("clicked", lambda *_: self.go("main"))
head.pack_start(back, True, True, 0)
rescan = Gtk.Button(label=ICON["refresh"])
rescan.get_style_context().add_class("rowbtn")
rescan.connect("clicked", lambda *_: self.rescan(title))
head.pack_end(rescan, False, False, 0)
if rescan:
rb = Gtk.Button(label=ICON["refresh"])
rb.get_style_context().add_class("rowbtn")
rb.connect("clicked", lambda *_: self.rescan(title))
head.pack_end(rb, False, False, 0)
outer.add(head)
scroller = Gtk.ScrolledWindow()
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scroller.set_min_content_height(120)
scroller.set_max_content_height(520)
scroller.set_propagate_natural_height(True)
listbox = Gtk.ListBox()
listbox.set_selection_mode(Gtk.SelectionMode.NONE)
listbox.get_style_context().add_class("card")
scroller.add(listbox)
outer.add(scroller)
setattr(self, f"list_{title.lower()}", listbox)
setattr(self, f"fill_{title.lower()}", filler)
# a plain card rather than a scrolled ListBox: these lists are short,
# and scrolling made them feel broken
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
box.get_style_context().add_class("card")
outer.add(box)
setattr(self, f"list_{title.lower()}", box)
return outer
def build_audio_view(self):
@ -667,7 +663,8 @@ class Panel(Gtk.Window):
# -------------------------------------------------------------- actions
def go(self, name):
if name != "main":
{"bt": self.fill_bt, "net": self.fill_net, "audio": self.fill_audio}[name]()
{"bt": self.fill_bt, "net": self.fill_net,
"audio": self.fill_audio, "display": self.fill_display}[name]()
self.stack.set_visible_child_name(name)
GLib.idle_add(lambda: (self.resize(1, 1), False)[1])
@ -764,7 +761,6 @@ class Panel(Gtk.Window):
GLib.timeout_add_seconds(3, lambda: (self.fill_net(), False)[1])
def row(self, listbox, icon, label, sub, cb):
r = Gtk.ListBoxRow()
b = Gtk.Button()
b.get_style_context().add_class("rowbtn")
h = Gtk.Box(spacing=12)
@ -778,8 +774,7 @@ class Panel(Gtk.Window):
h.pack_end(s, False, False, 0)
b.add(h)
b.connect("clicked", cb)
r.add(b)
listbox.add(r)
listbox.add(b)
def fill_bt(self):
lb = self.list_bluetooth
@ -812,6 +807,46 @@ class Panel(Gtk.Window):
spawn(f"bluetoothctl trust {mac} && bluetoothctl connect {mac}")
GLib.timeout_add_seconds(3, lambda: (self.fill_bt(), self.refresh(), False)[2])
def fill_display(self):
box = self.list_display
for c in box.get_children():
box.remove(c)
out = sh("xrandr --current")
name = None
for line in out.splitlines():
m = re.match(r"^(\S+) connected", line)
if m:
name = m.group(1)
continue
if name and line.startswith(" "):
parts = line.split()
if not parts:
continue
mode = parts[0]
rates = parts[1:]
cur = any("*" in r for r in rates)
def num(r):
r = r.rstrip("*+")
try:
return float(r)
except ValueError:
return None
vals = [v for v in (num(r) for r in rates) if v]
# show the rate actually in use for the active mode, not the
# highest one the mode happens to support
active = next((num(r) for r in rates if "*" in r), None)
hz = active or (max(vals) if vals else 0)
self.row(box, "󰄬" if cur else "󰝥", mode, f"{hz:.0f} Hz",
lambda _w, o=name, md=mode, r=hz: self.set_mode(o, md, r))
if not box.get_children():
self.row(box, "󰅚", "No outputs found", "", lambda *_: None)
box.show_all()
def set_mode(self, output, mode, rate):
if sh(f"xrandr --output {output} --mode {mode} --rate {rate:.2f}") == "":
notify("Display", f"{output} set to {mode} @ {rate:.0f} Hz")
self.fill_display()
def fill_net(self):
lb = self.list_network
for c in lb.get_children():