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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUWCXM4KhjRkwheaA3X7bP
feat/astal-menu
Amir Alexander Abdelbaki 2026-07-06 08:43:13 +02:00
parent e79d427c2a
commit abea1bec43
2 changed files with 93 additions and 25 deletions

View File

@ -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 ""

View File

@ -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; }