diff --git a/.config/rice/README.md b/.config/rice/README.md index e07f7a6..51bb331 100644 --- a/.config/rice/README.md +++ b/.config/rice/README.md @@ -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 | | diff --git a/.config/scripts/control-panel.py b/.config/scripts/control-panel.py index ac4cf15..ced5b59 100755 --- a/.config/scripts/control-panel.py +++ b/.config/scripts/control-panel.py @@ -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():