Add bluetooth toggle with device list and discovery
This commit is contained in:
parent
eddfc66be7
commit
61c621851b
|
|
@ -18,4 +18,6 @@
|
|||
modules.opencode.enable = true;
|
||||
|
||||
modules.display.scale = 2;
|
||||
|
||||
hardware.bluetooth.enable = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -53,6 +53,7 @@ let
|
|||
battery
|
||||
wireplumber
|
||||
notifd
|
||||
bluetooth
|
||||
]
|
||||
++ [ pkgs.networkmanager ];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue