Add bluetooth toggle with device list and discovery

This commit is contained in:
Joshua Yuen 2026-09-12 17:52:51 -04:00
parent 9cca79aa4a
commit ef896285e4
Signed by: josh
GPG Key ID: 530DA0331EFE99F5
6 changed files with 423 additions and 0 deletions

View File

@ -18,4 +18,6 @@
modules.opencode.enable = true;
modules.display.scale = 2;
hardware.bluetooth.enable = true;
}

View File

@ -109,6 +109,110 @@ $module-bg: transparentize($base02, 0.4);
font-size: 16px;
}
.toggle-button {
margin: 4px 0;
.toggle-pill {
padding: 6px 8px;
border-radius: 8px;
&:hover {
background: transparentize($base05, 0.88);
}
}
.icon {
font-size: 16px;
margin-right: 8px;
}
.label {
font-weight: bold;
}
&.active .toggle-pill {
background: $base0D;
color: $base00;
&:hover {
background: transparentize($base0D, 0.2);
}
}
}
.arrow {
padding: 4px 6px;
border-radius: 8px;
icon {
font-size: 12px;
}
&:hover {
background: transparentize($base05, 0.88);
}
}
.bluetooth-section {
margin-top: 10px;
border-top: 1px solid $base02;
padding-top: 8px;
}
.menu {
margin: 4px 0 8px;
.title-box {
margin: 2px 0 4px;
.icon {
font-size: 14px;
margin-right: 6px;
}
.title {
font-weight: bold;
opacity: 0.85;
}
}
.menu-separator {
background: $base02;
min-height: 1px;
margin: 4px 0;
}
}
.device-item {
padding: 4px 6px;
border-radius: 6px;
margin: 1px 0;
icon {
font-size: 16px;
}
.device-name {
margin: 0 6px;
}
.device-battery {
font-size: 0.85em;
opacity: 0.7;
margin-right: 6px;
}
.device-connecting {
font-size: 14px;
opacity: 0.7;
margin-right: 2px;
}
&:hover {
background: transparentize($base05, 0.88);
}
}
.dropdown {
background: $base01;
border-radius: 10px;
@ -201,7 +305,10 @@ $module-bg: transparentize($base02, 0.4);
padding: 0 2px;
margin-right: 6px;
}
}
.wifi-header,
.device-item {
switch {
background: $base03;
border-radius: 999px;
@ -323,6 +430,10 @@ $module-bg: transparentize($base02, 0.4);
font-size: 0.8em;
}
.quicksettings .bt-count {
font-size: 0.8em;
}
.notif-section .notif {
background: $base02;
}

View File

@ -0,0 +1,193 @@
import Gtk from "gi://Gtk?version=3.0"
import Gio from "gi://Gio"
import AstalBluetooth from "gi://AstalBluetooth"
import { bind, Variable } from "astal"
import { ArrowToggleButton, Menu } from "./ToggleButton"
const bt = AstalBluetooth.get_default()
function power(on: boolean) {
if (bt.adapter) bt.adapter.powered = on
}
function setDiscovery(on: boolean) {
if (!bt.adapter) return
try {
if (on) bt.adapter.start_discovery()
else bt.adapter.stop_discovery()
} catch {
// expected when already in the requested state: bluez stops
// discovery itself on connect, auto-starts it, etc.
}
}
// bluez only registers devices that are paired or seen during discovery;
// the list is mirrored into a Variable refreshed on device add/remove
const devices = Variable<AstalBluetooth.Device[]>(bt.get_devices())
const refreshDevices = () => devices.set(bt.get_devices())
bt.connect("notify::devices", refreshDevices)
bt.connect("device_added", refreshDevices)
bt.connect("device_removed", refreshDevices)
const btIcon = bind(bt, "is_powered").as((p) =>
p ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic",
)
const connectedCount = devices().as(
(ds) => ds.filter((d) => d.connected).length,
)
const pillLabel = Variable.derive(
[bind(bt, "is_powered"), devices],
(powered, ds) => {
if (!powered) return "Disabled"
const connected = ds.filter((d) => d.connected)
if (connected.length === 1) return connected[0].alias
return `${connected.length} Connected`
},
)
// ported from AlfheimOS: icon, name, battery, busy icon while connecting,
// switch reflecting the bluez connected state
function DeviceItem(device: AstalBluetooth.Device) {
const battery = bind(device, "battery_percentage")
const connecting = bind(device, "connecting")
// bluez reports br-connection-busy when attempts pile up, so debounce
let lastAttempt = 0
return (
<box className="device-item">
<icon
icon={
device.icon
? `${device.icon}-symbolic`
: "bluetooth-active-symbolic"
}
/>
<label
className="device-name"
label={device.alias ?? device.address}
halign={Gtk.Align.START}
hexpand
/>
<label
className="device-battery"
visible={battery.as((p) => p > 0)}
label={battery.as((p) => `${Math.round(p * 100)}%`)}
/>
<icon
className="device-connecting"
icon="view-refresh-symbolic"
visible={connecting}
/>
<switch
visible={connecting.as((c) => !c)}
active={bind(device, "connected")}
onStateSet={(_, state) => {
// bluez rejects connects while the controller is busy scanning
if (state) setDiscovery(false)
// skip no-ops and retries within the debounce window
const now = Date.now()
if (state === device.connected || now - lastAttempt < 1500) {
return true
}
lastAttempt = now
// vala async: begin with a callback, finish() throws
const done = (_source: AstalBluetooth.Device, res: Gio.AsyncResult) => {
try {
if (state) device.connect_device_finish(res)
else device.disconnect_device_finish(res)
} catch (e) {
console.error(e)
}
}
if (state) device.connect_device(done)
else device.disconnect_device(done)
return true
}}
/>
</box>
)
}
export function BluetoothSection({ open }: { open: Variable<boolean> }) {
if (!bt.adapter) return <box />
const expanded = Variable(false)
open.subscribe((v) => {
if (!v) {
expanded.set(false)
setDiscovery(false)
}
})
return (
<box className="bluetooth-section" vertical>
<ArrowToggleButton
open={expanded}
icon={btIcon}
label={pillLabel()}
active={bind(bt, "is_powered")}
onMainClick={() => {
if (bt.is_powered) {
power(false)
expanded.set(false)
setDiscovery(false)
} else {
power(true)
}
}}
onArrowClick={() => {
const next = !expanded.get()
expanded.set(next)
if (next) {
if (!bt.is_powered) power(true)
setDiscovery(true)
} else {
setDiscovery(false)
}
}}
/>
<Menu open={expanded} icon="bluetooth-active-symbolic" title="Bluetooth">
<label
className="wifi-empty"
label="bluetooth is off"
visible={bind(bt, "is_powered").as((p) => !p)}
/>
<label
className="wifi-empty"
label="searching for devices…"
visible={Variable.derive(
[bind(bt, "is_powered"), devices],
(powered, ds) => powered && ds.filter((d) => d.name).length === 0,
)()}
/>
<scrollable
hscroll={Gtk.PolicyType.NEVER}
vscroll={Gtk.PolicyType.AUTOMATIC}
maxContentHeight={360}
propagateNaturalHeight
visible={bind(bt, "is_powered")}
>
<box vertical>
{devices().as((ds) =>
ds.filter((d) => d.name).map(DeviceItem),
)}
</box>
</scrollable>
</Menu>
</box>
)
}
// bar cluster indicator: shown while bluetooth is powered, with a count
// badge when devices are connected
export const bluetoothIndicator: JSX.Element | null = bt.adapter ? (
<box className="bt-indicator" spacing={3} visible={bind(bt, "is_powered")}>
<icon icon="bluetooth-active-symbolic" />
<label
className="bt-count"
visible={connectedCount.as((n) => n > 0)}
label={connectedCount.as((n) => `${n}`)}
/>
</box>
) : null

View File

@ -4,6 +4,7 @@ import Notifd from "gi://AstalNotifd"
import { execAsync, Variable, bind } from "astal"
import Dropdown from "./Dropdown"
import { wifiIcon, WifiSection } from "./Wifi"
import { bluetoothIndicator, BluetoothSection } from "./Bluetooth"
import { batteryIcon, batteryPercent, BatterySection } from "./Battery"
import { NotificationCard } from "./Notifications"
@ -69,6 +70,7 @@ export default function QuickSettings(gdkmonitor: Gdk.Monitor) {
/>
</box>
{wifiIcon && <icon icon={wifiIcon} />}
{bluetoothIndicator}
<icon icon={speakerIcon()} />
{micIcon && <icon icon={micIcon()} />}
{batteryIcon && batteryPercent && (
@ -124,6 +126,7 @@ export default function QuickSettings(gdkmonitor: Gdk.Monitor) {
</box>
)}
<WifiSection open={open} />
<BluetoothSection open={open} />
<BatterySection />
<box className="notif-section" vertical spacing={6}>
<box className="notif-header-row" spacing={6}>

View File

@ -0,0 +1,113 @@
import GLib from "gi://GLib"
import Pango from "gi://Pango"
import Gtk from "gi://Gtk?version=3.0"
import { bind, type Binding, type Variable } from "astal"
// rotating arrow button: icon turns 90deg in small steps while the section
// opens, ported from AlfheimOS; a single self-clearing chained timer drives
// the animation so cancellation only ever touches provably live sources
export function Arrow({
open,
onToggle,
}: {
open: Variable<boolean>
onToggle: () => void
}) {
let deg = 0
let timer: number | undefined
const icon = <icon icon="pan-end-symbolic" />
const animate = (target: number) => {
if (timer !== undefined) {
GLib.source_remove(timer)
timer = undefined
}
const tick = () => {
deg += Math.sign(target - deg) * 10
icon.css = `-gtk-icon-transform: rotate(${deg}deg);`
if (deg !== target) {
timer = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 15, tick)
} else {
timer = undefined
}
return GLib.SOURCE_REMOVE
}
if (deg !== target) tick()
}
open.subscribe((v) => animate(v ? 90 : 0))
return (
<button className="arrow" onClicked={onToggle}>
{icon}
</button>
)
}
type ArrowToggleButtonProps = {
open: Variable<boolean>
icon: string | Binding<string>
label: string | Binding<string>
active: Binding<boolean>
onMainClick: () => void
onArrowClick: () => void
}
// icon + label pill with a detached arrow: the pill toggles the feature,
// the arrow rotates open its device menu
export function ArrowToggleButton({
open,
icon,
label,
active,
onMainClick,
onArrowClick,
}: ArrowToggleButtonProps) {
return (
<box
className={active.as((v) => (v ? "toggle-button active" : "toggle-button"))}
>
<button className="toggle-pill" hexpand onClicked={onMainClick}>
<box hexpand>
<icon className="icon" icon={icon} />
<label
className="label"
label={label}
maxWidthChars={10}
ellipsize={Pango.EllipsizeMode.END}
/>
</box>
</button>
<Arrow open={open} onToggle={onArrowClick} />
</box>
)
}
type MenuProps = {
open: Variable<boolean>
icon: string
title: string
children: Array<JSX.Element>
}
// revealer with a titled card header, shown while the section is open
export function Menu({ open, icon, title, children }: MenuProps) {
return (
<revealer
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={180}
revealChild={bind(open)}
>
<box className="menu" vertical>
<box className="title-box">
<icon className="icon" icon={icon} />
<label className="title" label={title} ellipsize={Pango.EllipsizeMode.END} />
</box>
<box className="menu-separator" />
<box className="content" vertical>
{children}
</box>
</box>
</revealer>
)
}

View File

@ -53,6 +53,7 @@ let
battery
wireplumber
notifd
bluetooth
]
++ [ pkgs.networkmanager ];