From 85313e2d5155ab5d99dfcbc385a45e7d63bce8e4 Mon Sep 17 00:00:00 2001 From: Joshua Yuen Date: Sat, 12 Sep 2026 17:52:51 -0400 Subject: [PATCH] Add bluetooth toggle with device list and discovery --- hosts/laptop/default.nix | 2 + modules/home/ags/app/style.scss | 111 ++++++++++ modules/home/ags/app/widget/Bluetooth.tsx | 193 ++++++++++++++++++ modules/home/ags/app/widget/QuickSettings.tsx | 3 + modules/home/ags/app/widget/ToggleButton.tsx | 113 ++++++++++ modules/home/ags/default.nix | 1 + 6 files changed, 423 insertions(+) create mode 100644 modules/home/ags/app/widget/Bluetooth.tsx create mode 100644 modules/home/ags/app/widget/ToggleButton.tsx diff --git a/hosts/laptop/default.nix b/hosts/laptop/default.nix index 6fea6df..bedddd7 100644 --- a/hosts/laptop/default.nix +++ b/hosts/laptop/default.nix @@ -20,4 +20,6 @@ modules.display.scale = 2; modules.steam.remotePlay.enable = true; + + hardware.bluetooth.enable = true; } diff --git a/modules/home/ags/app/style.scss b/modules/home/ags/app/style.scss index e73755b..5480c16 100644 --- a/modules/home/ags/app/style.scss +++ b/modules/home/ags/app/style.scss @@ -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; } diff --git a/modules/home/ags/app/widget/Bluetooth.tsx b/modules/home/ags/app/widget/Bluetooth.tsx new file mode 100644 index 0000000..0ae087b --- /dev/null +++ b/modules/home/ags/app/widget/Bluetooth.tsx @@ -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(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 ( + + + + ) +} + +export function BluetoothSection({ open }: { open: Variable }) { + if (!bt.adapter) return + + const expanded = Variable(false) + open.subscribe((v) => { + if (!v) { + expanded.set(false) + setDiscovery(false) + } + }) + + return ( + + { + 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) + } + }} + /> + + + + ) +} + +// bar cluster indicator: shown while bluetooth is powered, with a count +// badge when devices are connected +export const bluetoothIndicator: JSX.Element | null = bt.adapter ? ( + + + +) : null diff --git a/modules/home/ags/app/widget/QuickSettings.tsx b/modules/home/ags/app/widget/QuickSettings.tsx index f6162e3..b36530b 100644 --- a/modules/home/ags/app/widget/QuickSettings.tsx +++ b/modules/home/ags/app/widget/QuickSettings.tsx @@ -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) { /> {wifiIcon && } + {bluetoothIndicator} {micIcon && } {batteryIcon && batteryPercent && ( @@ -124,6 +126,7 @@ export default function QuickSettings(gdkmonitor: Gdk.Monitor) { )} + diff --git a/modules/home/ags/app/widget/ToggleButton.tsx b/modules/home/ags/app/widget/ToggleButton.tsx new file mode 100644 index 0000000..4375c32 --- /dev/null +++ b/modules/home/ags/app/widget/ToggleButton.tsx @@ -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 + onToggle: () => void +}) { + let deg = 0 + let timer: number | undefined + const icon = + + 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 ( + + ) +} + +type ArrowToggleButtonProps = { + open: Variable + icon: string | Binding + label: string | Binding + active: Binding + 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 ( + (v ? "toggle-button active" : "toggle-button"))} + > + + + + ) +} + +type MenuProps = { + open: Variable + icon: string + title: string + children: Array +} + +// revealer with a titled card header, shown while the section is open +export function Menu({ open, icon, title, children }: MenuProps) { + return ( + + + + + + + + {children} + + + + ) +} diff --git a/modules/home/ags/default.nix b/modules/home/ags/default.nix index 84738e6..7138852 100644 --- a/modules/home/ags/default.nix +++ b/modules/home/ags/default.nix @@ -53,6 +53,7 @@ let battery wireplumber notifd + bluetooth ] ++ [ pkgs.networkmanager ];