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_01XUWCXM4KhjRkwheaA3X7bPfeat/astal-menu
parent
e79d427c2a
commit
abea1bec43
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue