Add QuickSettings popup with power actions and volume/mic sliders

This commit is contained in:
Joshua Yuen 2026-09-12 15:38:54 -04:00
parent 8c174c857f
commit dbf37349fb
Signed by: josh
GPG Key ID: 530DA0331EFE99F5
6 changed files with 174 additions and 164 deletions

View File

@ -91,26 +91,16 @@ $module-bg: transparentize($base02, 0.4);
color: $base00;
}
.volume {
border-radius: 6px;
margin: 0 2px;
padding: 0 6px;
}
.ws:hover,
.volume:hover,
.wifi:hover,
.battery:hover,
.power:hover {
.battery:hover {
background: transparentize($base05, 0.88);
}
.volume icon,
.mute icon,
.quicksettings icon,
.wifi icon,
.wifi-header icon,
.battery icon,
.power icon {
.battery icon {
font-size: 16px;
}
@ -121,10 +111,39 @@ $module-bg: transparentize($base02, 0.4);
color: $base05;
}
.mute {
.power-header {
margin-bottom: 10px;
}
.power-action {
padding: 8px 0;
border-radius: 8px;
icon {
font-size: 16px;
}
&:hover {
background: transparentize($base05, 0.88);
}
}
.qs-row {
margin: 4px 0;
}
.qs-toggle {
margin-right: 8px;
padding: 0 6px;
padding: 0 8px;
border-radius: 6px;
icon {
font-size: 16px;
}
&:hover {
background: transparentize($base05, 0.88);
}
}
.vol-slider {
@ -231,24 +250,6 @@ $module-bg: transparentize($base02, 0.4);
opacity: 0.7;
}
.power-row {
padding: 4px 6px;
border-radius: 6px;
margin: 1px 0;
icon {
font-size: 16px;
}
label {
margin: 0 6px;
}
}
.power-row:hover {
background: transparentize($base05, 0.88);
}
.notifications {
.notif {
background: $base01;

View File

@ -2,10 +2,9 @@ import { App, Astal, Gdk, Gtk } from "astal/gtk3"
import Workspaces from "./Workspaces"
import Logo from "./Logo"
import Clock from "./Clock"
import { VolumeWidget } from "./Volume"
import { WifiWidget } from "./Wifi"
import { BatteryWidget } from "./Battery"
import { PowerWidget } from "./Power"
import QuickSettings from "./QuickSettings"
const RADIUS = 12
@ -56,22 +55,19 @@ function Corner({ side }: { side: "left" | "right" }) {
export default function Bar(gdkmonitor: Gdk.Monitor) {
const { TOP, LEFT, RIGHT } = Astal.WindowAnchor
const volume = VolumeWidget(gdkmonitor)
const wifi = WifiWidget(gdkmonitor)
const battery = BatteryWidget(gdkmonitor)
const power = PowerWidget(gdkmonitor)
const quicksettings = QuickSettings(gdkmonitor)
const right: Array<JSX.Element> = []
if (wifi.button) right.push(wifi.button)
right.push(volume.button)
if (battery.button) right.push(battery.button)
right.push(power.button)
right.push(quicksettings.button)
const popups: Array<JSX.Element> = []
if (wifi.popup) popups.push(wifi.popup)
popups.push(volume.popup)
if (battery.popup) popups.push(battery.popup)
popups.push(power.popup)
popups.push(quicksettings.popup)
return [
(

View File

@ -1,6 +1,9 @@
import { App, Astal, Gdk, Gtk } from "astal/gtk3"
import GLib from "gi://GLib"
import { Variable, bind } from "astal"
const DURATION = 180
interface DropdownProps {
gdkmonitor: Gdk.Monitor
target: Gtk.Widget
@ -48,8 +51,26 @@ export default function Dropdown({
child.marginTop = Math.round(y)
}
// the window outlives the animation: it hides once the slide-out
// revealer finished, and hiding the window resolves to open=false
const winVisible = Variable(false)
let closeTimer: number | undefined
open.subscribe((v) => {
if (v) reposition()
if (closeTimer !== undefined) {
GLib.source_remove(closeTimer)
closeTimer = undefined
}
if (v) {
reposition()
winVisible.set(true)
} else if (winVisible.get()) {
closeTimer = GLib.timeout_add(GLib.PRIORITY_DEFAULT, DURATION + 20, () => {
winVisible.set(false)
closeTimer = undefined
return GLib.SOURCE_REMOVE
})
}
})
return (
@ -62,9 +83,9 @@ export default function Dropdown({
keymode={Astal.Keymode.EXCLUSIVE}
anchor={TOP | BOTTOM | LEFT | RIGHT}
exclusivity={Astal.Exclusivity.IGNORE}
visible={bind(open)}
visible={bind(winVisible)}
onNotifyVisible={(self) => {
if (!self.visible) open.set(false)
if (!self.visible && open.get()) open.set(false)
}}
onButtonPressEvent={(self, e) => {
const event = e as unknown as Gdk.Event
@ -73,14 +94,20 @@ export default function Dropdown({
const xOut = px < x || px > x + w
const yOut = py < y || py > y + h
if (xOut || yOut) self.visible = false
if (xOut || yOut) open.set(false)
}}
onKeyPressEvent={(self, e) => {
onKeyPressEvent={(_, e) => {
const event = e as unknown as Gdk.Event
if (event.get_keyval()[1] === Gdk.KEY_Escape) self.visible = false
if (event.get_keyval()[1] === Gdk.KEY_Escape) open.set(false)
}}
>
<revealer
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={DURATION}
revealChild={bind(open)}
>
{child}
</revealer>
</window>
)
}

View File

@ -1,62 +0,0 @@
import Gtk from "gi://Gtk?version=3.0"
import { execAsync, Variable } from "astal"
import Dropdown from "./Dropdown"
const actions = [
{ label: "lock", icon: "system-lock-screen-symbolic", cmd: ["hyprlock"] },
{
label: "suspend",
icon: "weather-clear-night-symbolic",
cmd: ["systemctl", "suspend"],
},
{
label: "log out",
icon: "system-log-out-symbolic",
cmd: ["hyprctl", "dispatch", "exit"],
},
{
label: "reboot",
icon: "system-reboot-symbolic",
cmd: ["systemctl", "reboot"],
},
{
label: "shut down",
icon: "system-shutdown-symbolic",
cmd: ["systemctl", "poweroff"],
},
]
function PowerRow(action: (typeof actions)[number]) {
return (
<button
className="power-row"
onClicked={() => execAsync(action.cmd)}
>
<box>
<icon icon={action.icon} />
<label label={action.label} halign={Gtk.Align.START} hexpand />
</box>
</button>
)
}
export function PowerWidget(gdkmonitor: Gdk.Monitor) {
const open = Variable(false)
const button = (
<button
className="power"
onClicked={() => open.set(!open.get())}
>
<icon icon="system-shutdown-symbolic" />
</button>
)
const popup = (
<Dropdown gdkmonitor={gdkmonitor} target={button} open={open} width={180}>
{actions.map((action) => PowerRow(action))}
</Dropdown>
)
return { button, popup }
}

View File

@ -0,0 +1,102 @@
import AstalWp from "gi://AstalWp"
import { execAsync, Variable, bind } from "astal"
import Dropdown from "./Dropdown"
const speaker = AstalWp.get_default().audio.default_speaker!
const mic = AstalWp.get_default().audio.default_microphone
const speakerIcon = Variable.derive(
[bind(speaker, "volume"), bind(speaker, "mute")],
(volume, mute) => {
if (mute || volume <= 0.01) return "audio-volume-muted-symbolic"
if (volume < 0.33) return "audio-volume-low-symbolic"
if (volume < 0.66) return "audio-volume-medium-symbolic"
return "audio-volume-high-symbolic"
},
)
const micIcon = mic
? Variable.derive([bind(mic, "volume"), bind(mic, "mute")], (volume, mute) =>
mute || volume <= 0.01
? "microphone-sensitivity-muted-symbolic"
: "microphone-sensitivity-high-symbolic",
)
: null
const actions = [
{ icon: "system-lock-screen-symbolic", cmd: ["hyprlock"] },
{ icon: "weather-clear-night-symbolic", cmd: ["systemctl", "suspend"] },
{ icon: "system-log-out-symbolic", cmd: ["hyprctl", "dispatch", "exit"] },
{ icon: "system-reboot-symbolic", cmd: ["systemctl", "reboot"] },
{ icon: "system-shutdown-symbolic", cmd: ["systemctl", "poweroff"] },
]
export default function QuickSettings(gdkmonitor: Gdk.Monitor) {
const open = Variable(false)
const button = (
<button
className="quicksettings end"
onClicked={() => open.set(!open.get())}
onScroll={(_, e) => {
const volume = Math.min(
1,
Math.max(0, speaker.volume - e.delta_y * 0.02),
)
speaker.volume = volume
}}
>
<box spacing={6}>
<icon icon={speakerIcon()} />
{micIcon && <icon icon={micIcon()} />}
</box>
</button>
)
const popup = (
<Dropdown gdkmonitor={gdkmonitor} target={button} open={open} width={280}>
<box className="power-header" homogeneous>
{actions.map((action) => (
<button
className="power-action"
onClicked={() => execAsync(action.cmd)}
>
<icon icon={action.icon} />
</button>
))}
</box>
<box className="qs-row">
<button
className="qs-toggle"
onClicked={() => (speaker.mute = !speaker.mute)}
>
<icon icon={speakerIcon()} />
</button>
<slider
className="vol-slider"
hexpand
value={bind(speaker, "volume")}
onDragged={(self) => (speaker.volume = self.value)}
/>
</box>
{mic && micIcon && (
<box className="qs-row">
<button
className="qs-toggle"
onClicked={() => (mic.mute = !mic.mute)}
>
<icon icon={micIcon()} />
</button>
<slider
className="vol-slider"
hexpand
value={bind(mic, "volume")}
onDragged={(self) => (mic.volume = self.value)}
/>
</box>
)}
</Dropdown>
)
return { button, popup }
}

View File

@ -1,54 +0,0 @@
import AstalWp from "gi://AstalWp"
import { Variable, bind } from "astal"
import Dropdown from "./Dropdown"
const speaker = AstalWp.get_default().audio.default_speaker!
const icon = Variable.derive(
[bind(speaker, "volume"), bind(speaker, "mute")],
(volume, mute) => {
if (mute || volume <= 0.01) return "audio-volume-muted-symbolic"
if (volume < 0.33) return "audio-volume-low-symbolic"
if (volume < 0.66) return "audio-volume-medium-symbolic"
return "audio-volume-high-symbolic"
}
)
export function VolumeWidget(gdkmonitor: Gdk.Monitor) {
const open = Variable(false)
const button = (
<button
className="volume"
onClicked={() => open.set(!open.get())}
onScroll={(_, e) => {
const volume = Math.min(1, Math.max(0, speaker.volume - e.delta_y * 0.02))
speaker.volume = volume
}}
>
<icon icon={icon()} />
</button>
)
const popup = (
<Dropdown
gdkmonitor={gdkmonitor}
target={button}
open={open}
width={240}
vertical={false}
>
<button className="mute" onClicked={() => (speaker.mute = !speaker.mute)}>
<icon icon={icon()} />
</button>
<slider
className="vol-slider"
hexpand
value={bind(speaker, "volume")}
onDragged={(self) => (speaker.volume = self.value)}
/>
</Dropdown>
)
return { button, popup }
}