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
|
import gi
|
||||||
|
|
||||||
gi.require_version("Gtk", "4.0")
|
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 lib.proc import run_text
|
||||||
from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec
|
from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec
|
||||||
|
|
@ -45,6 +45,8 @@ class _BluetoothView(Gtk.Box):
|
||||||
self.bt = ctx.services.bluetooth
|
self.bt = ctx.services.bluetooth
|
||||||
self._recorded: set[str] = set()
|
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())
|
self.append(self._build_header())
|
||||||
|
|
||||||
|
|
@ -129,57 +131,122 @@ class _BluetoothView(Gtk.Box):
|
||||||
self._list.append(self._history_section())
|
self._list.append(self._history_section())
|
||||||
|
|
||||||
def _hook_device(self, dev) -> None:
|
def _hook_device(self, dev) -> None:
|
||||||
# Rebuild the list when a device's connection state changes, so a freshly
|
# React to a device's own state changes (adapter-level notify::devices doesn't
|
||||||
# connected device flips its button to "Disconnect" (adapter-level
|
# fire for per-device connect/disconnect). Hook once each.
|
||||||
# notify::devices doesn't fire for per-device state changes). Hook once each.
|
|
||||||
key = dev.get_address() or ""
|
key = dev.get_address() or ""
|
||||||
if key in self._hooked:
|
if key in self._hooked:
|
||||||
return
|
return
|
||||||
self._hooked.add(key)
|
self._hooked.add(key)
|
||||||
for sig in ("notify::connected", "notify::connecting", "notify::paired"):
|
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:
|
def _device_row(self, dev) -> Gtk.Widget:
|
||||||
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||||
row.add_css_class("bt-row")
|
row.add_css_class("bt-row")
|
||||||
|
addr = dev.get_address() or ""
|
||||||
icon = Gtk.Image.new_from_icon_name((dev.get_icon() or "bluetooth") + "-symbolic")
|
icon = Gtk.Image.new_from_icon_name((dev.get_icon() or "bluetooth") + "-symbolic")
|
||||||
row.append(icon)
|
row.append(icon)
|
||||||
name = dev.get_name() or dev.get_address() or "Unknown"
|
name = dev.get_name() or addr or "Unknown"
|
||||||
status = "connected" if dev.get_connected() else (
|
|
||||||
"connecting…" if dev.get_connecting() else
|
connected = dev.get_connected()
|
||||||
("paired" if dev.get_paired() else ""))
|
connecting = dev.get_connecting() or addr in self._pending
|
||||||
|
failed = addr in self._failed
|
||||||
|
|
||||||
lbl = Gtk.Label(label=name, xalign=0.0, hexpand=True)
|
lbl = Gtk.Label(label=name, xalign=0.0, hexpand=True)
|
||||||
if status:
|
|
||||||
lbl.set_tooltip_text(status)
|
|
||||||
row.append(lbl)
|
row.append(lbl)
|
||||||
|
|
||||||
|
status = ("connected" if connected else "connecting…" if connecting else
|
||||||
|
"failed" if failed else "paired" if dev.get_paired() else "")
|
||||||
if status:
|
if status:
|
||||||
tag = Gtk.Label(label=status)
|
tag = Gtk.Label(label=status)
|
||||||
tag.add_css_class("bt-status")
|
tag.add_css_class("bt-status")
|
||||||
|
if failed:
|
||||||
|
tag.add_css_class("bt-failed")
|
||||||
row.append(tag)
|
row.append(tag)
|
||||||
|
|
||||||
btn = Gtk.Button()
|
actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||||
btn.add_css_class("quad-action")
|
if connecting:
|
||||||
if dev.get_connected():
|
spinner = Gtk.Spinner(valign=Gtk.Align.CENTER)
|
||||||
btn.set_label("Disconnect")
|
spinner.start()
|
||||||
btn.connect("clicked", lambda *_: dev.disconnect_device(None, self._noop))
|
actions.append(spinner)
|
||||||
|
actions.append(self._act("Cancel", lambda: self._cancel(dev)))
|
||||||
else:
|
else:
|
||||||
btn.set_label("Connect")
|
if dev.get_paired():
|
||||||
btn.connect("clicked", lambda *_, d=dev: self._connect(d))
|
actions.append(self._act("Forget", lambda: self._forget(dev)))
|
||||||
row.append(btn)
|
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)
|
self._maybe_record(dev)
|
||||||
return row
|
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):
|
def done(d, res):
|
||||||
try:
|
try:
|
||||||
d.connect_device_finish(res)
|
d.connect_device_finish(res)
|
||||||
if self.ctx.feature("history", True):
|
|
||||||
self._maybe_record(d, force=True)
|
|
||||||
except Exception:
|
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)
|
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:
|
def _maybe_record(self, dev, force: bool = False) -> None:
|
||||||
addr = dev.get_address() or ""
|
addr = dev.get_address() or ""
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,7 @@ button:checked.quad-action { background: @accent; color: @bg; border-color: @acc
|
||||||
.bt-row:hover,
|
.bt-row:hover,
|
||||||
.net-row:hover { background: alpha(@violet, 0.15); }
|
.net-row:hover { background: alpha(@violet, 0.15); }
|
||||||
.bt-status { color: @accent; font-size: 10pt; }
|
.bt-status { color: @accent; font-size: 10pt; }
|
||||||
|
.bt-status.bt-failed { color: @danger; }
|
||||||
.bt-history label,
|
.bt-history label,
|
||||||
.net-ip { color: @text; opacity: 0.85; font-size: 11pt; }
|
.net-ip { color: @text; opacity: 0.85; font-size: 11pt; }
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue