From abea1bec43da7ea802c06ca769248536b2790c42 Mon Sep 17 00:00:00 2001 From: The_miro Date: Mon, 6 Jul 2026 08:43:13 +0200 Subject: [PATCH] feat(astal-menu): Bluetooth connect status + Forget button - Connect flow now shows real status: a spinner + Cancel while connecting, and a red "failed" tag + Retry if the attempt stops without linking (via notify::connecting going false) or a 25s timeout backstop fires. Previously Connect gave no feedback and could look stuck forever. - Add a Forget button on paired devices (adapter.remove_device) to unpair them. - Track per-device pending/failed state so the row reflects connecting/failed/ connected/paired accurately. Verified the paired row renders Forget + Connect and the connected row renders Forget + Disconnect. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XUWCXM4KhjRkwheaA3X7bP --- .../hyprlua/astal-menu/modules/bluetooth.py | 117 ++++++++++++++---- .../hyprlua/astal-menu/style/style.css | 1 + 2 files changed, 93 insertions(+), 25 deletions(-) diff --git a/desktopenvs/hyprlua/astal-menu/modules/bluetooth.py b/desktopenvs/hyprlua/astal-menu/modules/bluetooth.py index 4519c72..76f77d8 100644 --- a/desktopenvs/hyprlua/astal-menu/modules/bluetooth.py +++ b/desktopenvs/hyprlua/astal-menu/modules/bluetooth.py @@ -13,7 +13,7 @@ import time import gi gi.require_version("Gtk", "4.0") -from gi.repository import Gtk # noqa: E402 +from gi.repository import GLib, Gtk # noqa: E402 from lib.proc import run_text from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec @@ -44,7 +44,9 @@ class _BluetoothView(Gtk.Box): self.full = full self.bt = ctx.services.bluetooth self._recorded: set[str] = set() - self._hooked: set[str] = set() # devices whose state signals we've wired + self._hooked: set[str] = set() # devices whose state signals we've wired + self._pending: set[str] = set() # addresses with an in-flight connect + self._failed: set[str] = set() # addresses whose last connect failed self.append(self._build_header()) @@ -129,57 +131,122 @@ class _BluetoothView(Gtk.Box): self._list.append(self._history_section()) def _hook_device(self, dev) -> None: - # Rebuild the list when a device's connection state changes, so a freshly - # connected device flips its button to "Disconnect" (adapter-level - # notify::devices doesn't fire for per-device state changes). Hook once each. + # React to a device's own state changes (adapter-level notify::devices doesn't + # fire for per-device connect/disconnect). Hook once each. key = dev.get_address() or "" if key in self._hooked: return self._hooked.add(key) for sig in ("notify::connected", "notify::connecting", "notify::paired"): - dev.connect(sig, lambda *_a: self._refresh()) + dev.connect(sig, lambda *_a, d=dev: self._on_device_state(d)) + + def _on_device_state(self, dev) -> None: + addr = dev.get_address() or "" + if dev.get_connected(): + self._pending.discard(addr) + self._failed.discard(addr) + if self.ctx.feature("history", True): + self._maybe_record(dev, force=True) + elif addr in self._pending and not dev.get_connecting(): + # bluez stopped trying without establishing a link → the connect failed + self._pending.discard(addr) + self._failed.add(addr) + self._refresh() def _device_row(self, dev) -> Gtk.Widget: row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) row.add_css_class("bt-row") + addr = dev.get_address() or "" icon = Gtk.Image.new_from_icon_name((dev.get_icon() or "bluetooth") + "-symbolic") row.append(icon) - name = dev.get_name() or dev.get_address() or "Unknown" - status = "connected" if dev.get_connected() else ( - "connecting…" if dev.get_connecting() else - ("paired" if dev.get_paired() else "")) + name = dev.get_name() or addr or "Unknown" + + connected = dev.get_connected() + connecting = dev.get_connecting() or addr in self._pending + failed = addr in self._failed + lbl = Gtk.Label(label=name, xalign=0.0, hexpand=True) - if status: - lbl.set_tooltip_text(status) row.append(lbl) + + status = ("connected" if connected else "connecting…" if connecting else + "failed" if failed else "paired" if dev.get_paired() else "") if status: tag = Gtk.Label(label=status) tag.add_css_class("bt-status") + if failed: + tag.add_css_class("bt-failed") row.append(tag) - btn = Gtk.Button() - btn.add_css_class("quad-action") - if dev.get_connected(): - btn.set_label("Disconnect") - btn.connect("clicked", lambda *_: dev.disconnect_device(None, self._noop)) + actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + if connecting: + spinner = Gtk.Spinner(valign=Gtk.Align.CENTER) + spinner.start() + actions.append(spinner) + actions.append(self._act("Cancel", lambda: self._cancel(dev))) else: - btn.set_label("Connect") - btn.connect("clicked", lambda *_, d=dev: self._connect(d)) - row.append(btn) + if dev.get_paired(): + actions.append(self._act("Forget", lambda: self._forget(dev))) + if connected: + actions.append(self._act("Disconnect", + lambda: dev.disconnect_device(None, self._noop))) + else: + actions.append(self._act("Retry" if failed else "Connect", + lambda: self._start_connect(dev))) + row.append(actions) - if dev.get_connected() and self.ctx.feature("history", True): + if connected and self.ctx.feature("history", True): self._maybe_record(dev) return row - def _connect(self, dev) -> None: + @staticmethod + def _act(label: str, cb) -> Gtk.Button: + b = Gtk.Button(label=label, valign=Gtk.Align.CENTER) + b.add_css_class("quad-action") + b.connect("clicked", lambda *_a: cb()) + return b + + # -- connect / cancel / forget ---------------------------------------- + def _start_connect(self, dev) -> None: + addr = dev.get_address() or "" + self._failed.discard(addr) + self._pending.add(addr) + def done(d, res): try: d.connect_device_finish(res) - if self.ctx.feature("history", True): - self._maybe_record(d, force=True) except Exception: - pass + a = d.get_address() or "" + if not d.get_connected(): + self._pending.discard(a) + self._failed.add(a) + self._refresh() dev.connect_device(None, done) + # Backstop: some failures never resolve the async call, so time out. + GLib.timeout_add_seconds(25, lambda: self._connect_timeout(addr)) + self._refresh() + + def _connect_timeout(self, addr: str) -> bool: + if addr in self._pending: + self._pending.discard(addr) + self._failed.add(addr) + self._refresh() + return GLib.SOURCE_REMOVE + + def _cancel(self, dev) -> None: + addr = dev.get_address() or "" + self._pending.discard(addr) + dev.disconnect_device(None, self._noop) # abort the in-flight attempt + self._refresh() + + def _forget(self, dev) -> None: + ad = self._adapter() + addr = dev.get_address() or "" + self._pending.discard(addr) + self._failed.discard(addr) + self._hooked.discard(addr) + if ad: + ad.remove_device(dev) # fires notify::devices → refresh + self._refresh() def _maybe_record(self, dev, force: bool = False) -> None: addr = dev.get_address() or "" diff --git a/desktopenvs/hyprlua/astal-menu/style/style.css b/desktopenvs/hyprlua/astal-menu/style/style.css index c925b18..da655d8 100644 --- a/desktopenvs/hyprlua/astal-menu/style/style.css +++ b/desktopenvs/hyprlua/astal-menu/style/style.css @@ -192,6 +192,7 @@ button:checked.quad-action { background: @accent; color: @bg; border-color: @acc .bt-row:hover, .net-row:hover { background: alpha(@violet, 0.15); } .bt-status { color: @accent; font-size: 10pt; } +.bt-status.bt-failed { color: @danger; } .bt-history label, .net-ip { color: @text; opacity: 0.85; font-size: 11pt; }