diff --git a/.config/bspwm/bspwmrc b/.config/bspwm/bspwmrc index 29cb690..f029471 100755 --- a/.config/bspwm/bspwmrc +++ b/.config/bspwm/bspwmrc @@ -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 diff --git a/.config/rice/README.md b/.config/rice/README.md index cc963cc..e07f7a6 100644 --- a/.config/rice/README.md +++ b/.config/rice/README.md @@ -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 diff --git a/.config/scripts/control-panel.py b/.config/scripts/control-panel.py index 0f61f86..46355f2 100755 --- a/.config/scripts/control-panel.py +++ b/.config/scripts/control-panel.py @@ -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: