Compare commits

...

10 Commits

14 changed files with 943 additions and 455 deletions

View File

@ -1,6 +1,23 @@
@use "palette" as *; @use "palette" as *;
$radius: 12px; // theme vars ported from AlfheimOS, mapped to the stylix palette
$radius: 12px; // keep in sync with RADIUS in Bar.tsx
$spacing: 8px;
$padding: 8px;
$border-width: 1px;
$border-color: $base02;
$border: $border-width solid $border-color;
$transition: 180ms;
$fg: $base05;
$primary-bg: $base0D;
$primary-fg: $base00;
$widget-bg: transparentize($base02, 0.45);
$hover-bg: transparentize($base02, 0.2);
$hover-overlay: transparentize($base05, 0.92);
$active-hover: transparentize($primary-bg, 0.15);
$active-gradient: linear-gradient(to right, $primary-bg, darken($primary-bg, 4%));
$toast-bg: transparentize($base02, 0.15);
$bar-alpha: 0.7; $bar-alpha: 0.7;
$bar-bg: transparentize($base01, 1 - $bar-alpha); $bar-bg: transparentize($base01, 1 - $bar-alpha);
$module-bg: transparentize($base02, 0.4); $module-bg: transparentize($base02, 0.4);
@ -62,7 +79,7 @@ $module-bg: transparentize($base02, 0.4);
.bar .end { .bar .end {
border-radius: ($radius * 0.75) ($radius * 2) ($radius * 0.75) ($radius * 2); border-radius: ($radius * 0.75) ($radius * 2) ($radius * 0.75) ($radius * 2);
margin: 2px; margin: 0 2px 2px;
} }
.bar .last { .bar .last {
@ -92,8 +109,8 @@ $module-bg: transparentize($base02, 0.4);
} }
.ws.active { .ws.active {
background: $base0D; background: $primary-bg;
color: $base00; color: $primary-fg;
} }
.ws:hover { .ws:hover {
@ -104,20 +121,177 @@ $module-bg: transparentize($base02, 0.4);
font-size: 12px; font-size: 12px;
} }
.wifi icon, .quicksettings .notif-count,
.wifi-header icon { .quicksettings .bt-count {
font-size: 16px; font-size: 0.8em;
} }
.toggle-button { .battery-level {
margin: 4px 0; font-size: 0.85em;
}
.toggle-pill { // ---------- quicksettings popup ----------
padding: 6px 8px;
.dropdown {
background: $base01;
border: $border-width solid transparentize($base02, 0.5);
border-radius: 10px;
padding: 10px 12px;
color: $fg;
}
scrollbar {
background: transparent;
border: none;
padding: 0 2px;
slider {
background: $base03;
border-radius: 999px;
min-width: 6px;
min-height: 6px;
margin: 2px;
}
}
.header {
margin-bottom: 6px;
color: transparentize($fg, 0.15);
.avatar {
padding: 7px;
border-radius: 10px;
background-color: $widget-bg;
border: $border;
}
.username {
font-weight: bold;
}
button {
padding: 7px 8px;
border-radius: 8px; border-radius: 8px;
icon {
font-size: 16px;
}
&:hover { &:hover {
background: transparentize($base05, 0.88); background: $hover-bg;
}
}
}
.battery-section {
margin-bottom: 6px;
icon {
font-size: 16px;
}
}
.battery-percent {
font-weight: bold;
margin: 0 6px;
}
.battery-status {
font-size: 0.85em;
opacity: 0.7;
}
// volume, microphone and brightness rows on one widget card
.sliders-box {
background-color: $widget-bg;
border: $border;
border-radius: 10px;
padding: 6px;
.volume,
.microphone,
.brightness {
padding: 2px 4px;
}
button {
padding: 5px 7px;
border-radius: 6px;
icon {
font-size: 16px;
}
&:hover {
background: $hover-bg;
}
}
}
// shared slider look: bordered trough, accent gradient highlight,
// thumb only appears on hover, .muted turns the highlight gray
scale {
trough {
border-radius: 999px;
border: $border;
background-color: $base01;
min-height: 8px;
highlight {
border-radius: 999px;
background-image: $active-gradient;
min-height: 8px;
}
slider {
background-color: transparent;
border: $border-width solid transparent;
border-radius: 999px;
min-width: 10px;
min-height: 10px;
}
}
&:hover {
trough {
background-color: transparentize($base01, 0.4);
}
trough slider {
background-color: $base04;
border-color: $border-color;
}
}
&.muted trough highlight {
background-image: none;
background-color: transparentize($fg, 0.2);
}
}
// icon + label pill with a detached arrow
.toggle-button {
margin: 4px 0;
background-color: $widget-bg;
border: $border;
border-radius: 10px;
font-weight: bold;
button:first-child {
padding: 8px 10px;
border-radius: 10px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
button:last-child {
padding: 4px 8px;
border-radius: 10px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
icon {
font-size: 12px;
} }
} }
@ -126,40 +300,61 @@ $module-bg: transparentize($base02, 0.4);
margin-right: 8px; margin-right: 8px;
} }
.label { button:hover {
font-weight: bold; background: $hover-overlay;
} }
&.active .toggle-pill { &.active {
background: $base0D; background-color: $primary-bg;
color: $base00;
&:hover {
background: transparentize($base0D, 0.2);
}
}
}
.arrow {
padding: 4px 6px;
border-radius: 8px;
label,
icon { icon {
font-size: 12px; color: $primary-fg;
} }
&:hover { button:hover {
background: transparentize($base05, 0.88); background: $active-hover;
}
} }
} }
.bluetooth-section { // arrow-less toggle pill (do not disturb)
margin-top: 10px; .simple-toggle {
border-top: 1px solid $base02; margin: 4px 0;
padding-top: 8px; padding: 9px 10px;
border-radius: 10px;
background-color: $widget-bg;
border: $border;
font-weight: bold;
.icon {
font-size: 16px;
margin-right: 8px;
}
&:hover {
background: $hover-bg;
}
&.active {
background-color: $primary-bg;
label,
icon {
color: $primary-fg;
}
&:hover {
background: $active-hover;
}
}
} }
.menu { .menu {
background-color: $widget-bg;
border: $border;
border-radius: 10px;
padding: 8px;
margin: 4px 0 8px; margin: 4px 0 8px;
.title-box { .title-box {
@ -167,7 +362,7 @@ $module-bg: transparentize($base02, 0.4);
.icon { .icon {
font-size: 14px; font-size: 14px;
margin-right: 6px; margin: 0 4px 0 2px;
} }
.title { .title {
@ -177,14 +372,78 @@ $module-bg: transparentize($base02, 0.4);
} }
.menu-separator { .menu-separator {
background: $base02; background: $border-color;
min-height: 1px; min-height: 1px;
margin: 4px 0; margin: 4px 0;
} }
button {
padding: 5px 7px;
border-radius: 6px;
icon {
font-size: 16px;
}
&:hover {
background: $hover-overlay;
}
}
switch {
background: $base03;
border-radius: 999px;
min-width: 34px;
min-height: 16px;
margin: 1px 0;
slider {
background: $base05;
border-radius: 999px;
min-width: 12px;
min-height: 12px;
margin: 2px;
}
}
switch:checked {
background: $primary-bg;
slider {
background: $primary-fg;
}
}
}
.wifi-status,
.wifi-empty {
font-size: 0.85em;
opacity: 0.7;
margin: 2px 0;
}
.wifi-password {
margin: 4px 0;
padding: 5px 8px;
border-radius: 6px;
background: $base01;
color: $fg;
}
.wifi-row {
margin: 1px 0;
icon {
font-size: 16px;
}
.wifi-row-ssid {
margin: 0 6px;
}
} }
.device-item { .device-item {
padding: 4px 6px; padding: 5px 7px;
border-radius: 6px; border-radius: 6px;
margin: 1px 0; margin: 1px 0;
@ -209,206 +468,90 @@ $module-bg: transparentize($base02, 0.4);
} }
&:hover { &:hover {
background: transparentize($base05, 0.88); background: $hover-overlay;
} }
} }
.dropdown { // media player card
background: $base01; .player {
background-color: $widget-bg;
border: $border;
border-radius: 10px; border-radius: 10px;
padding: 8px 12px; padding: 8px;
color: $base05;
}
.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; margin: 4px 0;
}
.qs-toggle { .cover {
margin-right: 8px; padding: 12px;
padding: 0 8px; border-radius: 10px;
border-radius: 6px; background-color: $hover-bg;
background-size: cover;
icon { background-position: center;
font-size: 16px;
} }
&:hover { .player-title {
background: transparentize($base05, 0.88); font-weight: bold;
} }
}
.vol-slider { .player-artist {
font-size: 0.9em;
color: transparentize($fg, 0.2);
}
scale {
margin: 4px 0;
trough { trough {
background: $base03; border: none;
border-radius: 999px;
min-height: 12px;
highlight {
background: $base0D;
border-radius: 999px;
} }
slider { trough highlight {
min-width: 16px; background-image: linear-gradient($fg, $fg);
min-height: 16px;
background: $base05;
border-radius: 999px;
} }
} }
}
.wifi-section { .player-ctl {
margin-top: 10px;
border-top: 1px solid $base02;
padding-top: 8px;
}
.wifi-toggle {
padding: 4px 6px; padding: 4px 6px;
border-radius: 6px; border-radius: 999px;
label { icon {
font-weight: bold; font-size: 14px;
margin: 0 2px;
} }
&:hover { &:hover {
background: transparentize($base05, 0.88); background: $hover-overlay;
}
}
.wifi-header {
label {
font-weight: bold;
margin: 0 2px;
}
.wifi-rescan {
padding: 0 2px;
margin-right: 6px;
}
}
.wifi-header,
.device-item {
switch {
background: $base03;
border-radius: 999px;
min-width: 34px;
min-height: 16px;
margin: 1px 0;
slider {
background: $base05;
border-radius: 999px;
min-width: 12px;
min-height: 12px;
margin: 2px;
} }
} }
switch:checked { .play-pause {
background: $base0D; background-color: $primary-bg;
slider {
background: $base00;
}
}
}
.wifi-status,
.wifi-empty {
font-size: 0.85em;
opacity: 0.7;
margin: 2px 0;
}
.wifi-password {
margin: 4px 0;
padding: 4px 8px;
border-radius: 6px;
background: $base02;
color: $base05;
}
.wifi-row {
padding: 4px 6px;
border-radius: 6px;
margin: 1px 0;
icon { icon {
font-size: 16px; color: $primary-fg;
} }
.wifi-row-ssid { &:hover {
margin: 0 6px; background: $active-hover;
} }
}
.wifi-row:hover {
background: transparentize($base05, 0.88);
}
.battery-level {
font-size: 0.85em;
}
.battery-section {
margin-top: 10px;
border-top: 1px solid $base02;
padding-top: 8px;
icon {
font-size: 16px;
} }
}
.battery-percent { .player-position,
font-weight: bold; .player-length {
margin: 0 6px;
}
.battery-status {
font-size: 0.85em; font-size: 0.85em;
opacity: 0.7; opacity: 0.7;
}
} }
.notif-section { .notif-section {
margin-top: 10px; margin-top: 10px;
border-top: 1px solid $base02; border-top: 1px solid $border-color;
padding-top: 8px; padding-top: 8px;
} }
.notif-toggle { .notif-header {
padding: 4px 6px;
border-radius: 6px;
label { label {
font-weight: bold; font-weight: bold;
margin: 0 2px; margin: 0 2px;
} }
&:hover {
background: transparentize($base05, 0.88);
}
} }
.notif-clear { .notif-clear {
@ -417,7 +560,7 @@ $module-bg: transparentize($base02, 0.4);
font-size: 0.85em; font-size: 0.85em;
&:hover { &:hover {
background: transparentize($base05, 0.88); background: $hover-bg;
} }
} }
@ -426,31 +569,29 @@ $module-bg: transparentize($base02, 0.4);
opacity: 0.7; opacity: 0.7;
} }
.quicksettings .notif-count {
font-size: 0.8em;
}
.quicksettings .bt-count {
font-size: 0.8em;
}
.notif-section .notif {
background: $base02;
}
.notifications { .notifications {
.notif { .notif {
background: $base01; background-color: $widget-bg;
border: $border;
border-radius: 10px; border-radius: 10px;
padding: 8px 12px; padding: 8px 10px;
color: $base05;
&:hover { &:hover {
background: transparentize($base05, 0.88); background: $hover-bg;
} }
&.critical { &.critical {
border: 1px solid $base08; border-color: $base08;
}
// popup toasts are more opaque than list entries and become
// completely opaque while hovered
&.toast {
background-color: $toast-bg;
&:hover {
background-color: $base02;
}
} }
} }
@ -471,6 +612,10 @@ $module-bg: transparentize($base02, 0.4);
.notif-close { .notif-close {
border-radius: 6px; border-radius: 6px;
padding: 0 2px; padding: 0 2px;
&:hover {
background: $hover-overlay;
}
} }
} }
@ -490,13 +635,13 @@ $module-bg: transparentize($base02, 0.4);
} }
.notif-action { .notif-action {
background: $base02; background: $base01;
border-radius: 6px; border-radius: 6px;
margin-top: 6px; margin-top: 6px;
padding: 2px 8px; padding: 2px 8px;
&:hover { &:hover {
background: $base03; background: $hover-bg;
} }
} }
} }

View File

@ -2,6 +2,7 @@ import { App, Astal, Gdk, Gtk } from "astal/gtk3"
import Workspaces from "./Workspaces" import Workspaces from "./Workspaces"
import Logo from "./Logo" import Logo from "./Logo"
import Clock from "./Clock" import Clock from "./Clock"
import { MediaButton } from "./Media"
import QuickSettings from "./QuickSettings" import QuickSettings from "./QuickSettings"
const RADIUS = 12 const RADIUS = 12
@ -85,7 +86,9 @@ export default function Bar(gdkmonitor: Gdk.Monitor) {
className="bar-center" className="bar-center"
halign={Gtk.Align.CENTER} halign={Gtk.Align.CENTER}
valign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER}
/> >
<MediaButton />
</box>
<box <box
className="bar-right" className="bar-right"
halign={Gtk.Align.END} halign={Gtk.Align.END}

View File

@ -110,7 +110,7 @@ function DeviceItem(device: AstalBluetooth.Device) {
} }
export function BluetoothSection({ open }: { open: Variable<boolean> }) { export function BluetoothSection({ open }: { open: Variable<boolean> }) {
if (!bt.adapter) return <box /> if (!bt.adapter) return null
const expanded = Variable(false) const expanded = Variable(false)
open.subscribe((v) => { open.subscribe((v) => {
@ -120,8 +120,7 @@ export function BluetoothSection({ open }: { open: Variable<boolean> }) {
} }
}) })
return ( const toggle = (
<box className="bluetooth-section" vertical>
<ArrowToggleButton <ArrowToggleButton
open={expanded} open={expanded}
icon={btIcon} icon={btIcon}
@ -147,6 +146,9 @@ export function BluetoothSection({ open }: { open: Variable<boolean> }) {
} }
}} }}
/> />
)
const menu = (
<Menu open={expanded} icon="bluetooth-active-symbolic" title="Bluetooth"> <Menu open={expanded} icon="bluetooth-active-symbolic" title="Bluetooth">
<label <label
className="wifi-empty" className="wifi-empty"
@ -161,22 +163,15 @@ export function BluetoothSection({ open }: { open: Variable<boolean> }) {
(powered, ds) => powered && ds.filter((d) => d.name).length === 0, (powered, ds) => powered && ds.filter((d) => d.name).length === 0,
)()} )()}
/> />
<scrollable <box vertical visible={bind(bt, "is_powered")}>
hscroll={Gtk.PolicyType.NEVER}
vscroll={Gtk.PolicyType.AUTOMATIC}
maxContentHeight={360}
propagateNaturalHeight
visible={bind(bt, "is_powered")}
>
<box vertical>
{devices().as((ds) => {devices().as((ds) =>
ds.filter((d) => d.name).map(DeviceItem), ds.filter((d) => d.name).map(DeviceItem),
)} )}
</box> </box>
</scrollable>
</Menu> </Menu>
</box>
) )
return { toggle, menu }
} }
// bar cluster indicator: shown while bluetooth is powered, with a count // bar cluster indicator: shown while bluetooth is powered, with a count

View File

@ -0,0 +1,30 @@
import { execAsync, Variable, bind } from "astal"
// no astal.brightness lib in nixpkgs: poll brightnessctl for the current
// level as a 0..1 fraction, pushing changes through brightnessctl set
const percent = Variable(0).poll(
2000,
["sh", "-c", "echo $(( $(brightnessctl get) * 100 / $(brightnessctl max) ))"],
(out) => parseInt(out) / 100,
)
export function BrightnessRow() {
return (
<box className="brightness" spacing={6}>
<button>
<icon icon="display-brightness-symbolic" />
</button>
<slider
hexpand
value={bind(percent)}
onDragged={(self) => {
const pct = Math.round(self.value * 100)
percent.set(pct / 100)
execAsync(["brightnessctl", "set", `${pct}%`]).catch(
console.error,
)
}}
/>
</box>
)
}

View File

@ -11,6 +11,8 @@ interface DropdownProps {
width?: number width?: number
vertical?: boolean vertical?: boolean
className?: string className?: string
// cap the popup height to the screen, scrolling any overflow
maxHeight?: number
children: Array<JSX.Element> children: Array<JSX.Element>
} }
@ -21,6 +23,7 @@ export default function Dropdown({
width = 260, width = 260,
vertical = true, vertical = true,
className = "", className = "",
maxHeight,
children, children,
}: DropdownProps) { }: DropdownProps) {
const { TOP, BOTTOM, LEFT, RIGHT } = Astal.WindowAnchor const { TOP, BOTTOM, LEFT, RIGHT } = Astal.WindowAnchor
@ -34,7 +37,19 @@ export default function Dropdown({
halign={Gtk.Align.START} halign={Gtk.Align.START}
valign={Gtk.Align.START} valign={Gtk.Align.START}
> >
{children} {maxHeight !== undefined ? (
<scrollable
hexpand
hscroll={Gtk.PolicyType.NEVER}
vscroll={Gtk.PolicyType.AUTOMATIC}
maxContentHeight={maxHeight}
propagateNaturalHeight
>
<box vertical>{children}</box>
</scrollable>
) : (
children
)}
</box> </box>
) )

View File

@ -3,7 +3,7 @@ import Gtk from "gi://Gtk?version=3.0"
export default function Logo() { export default function Logo() {
return ( return (
<box className="logo first" valign={Gtk.Align.FILL}> <box className="logo first" valign={Gtk.Align.FILL}>
<icon icon="nix-snowflake-white" pixelSize={20} /> <icon icon="nix-snowflake" pixelSize={20} />
</box> </box>
) )
} }

View File

@ -0,0 +1,196 @@
import Gtk from "gi://Gtk?version=3.0"
import GLib from "gi://GLib"
import Pango from "gi://Pango"
import AstalMpris from "gi://AstalMpris"
import { Variable, bind } from "astal"
const mpris = AstalMpris.get_default()
const PREV = "media-skip-backward-symbolic"
const PLAY = "media-playback-start-symbolic"
const PAUSE = "media-playback-pause-symbolic"
const NEXT = "media-skip-forward-symbolic"
const NO_ART = "audio-x-generic-symbolic"
function fmtTime(seconds: number) {
if (!seconds || seconds < 0) return "0:00"
const min = Math.floor(seconds / 60)
const sec = Math.floor(seconds % 60)
return `${min}:${sec < 10 ? "0" : ""}${sec}`
}
function playerIcon(entry: string) {
const name = entry ? `${entry.toLowerCase()}-symbolic` : ""
const theme = Gtk.IconTheme.get_default()!
return name !== "" && theme.has_icon(name) ? name : NO_ART
}
function PlayerCard(player: AstalMpris.Player) {
// position only changes on the bus when seeking, so poll every second;
// the ticker stops itself when the player disconnects so we never
// touch a disposed GObject
const position = Variable(-1)
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1000, () => {
if (!mpris.players.includes(player)) return GLib.SOURCE_REMOVE
position.set(player.available ? player.position : -1)
return GLib.SOURCE_CONTINUE
})
return (
<box className="player" spacing={8}>
<box
className="cover"
css={bind(player, "cover_art").as((c) =>
c !== "" ? `background-image: url('${encodeURI(`file://${c}`)}')` : "",
)}
>
{/* kept allocated (opacity, not visible) so the padded box
stays 48px and the icon sits dead center with no art */}
<icon
pixelSize={24}
icon={NO_ART}
opacity={bind(player, "cover_art").as((c) => (c !== "" ? 0 : 1))}
/>
</box>
<box vertical spacing={4}>
<box spacing={6}>
<label
className="player-title"
hexpand
halign={Gtk.Align.START}
label={bind(player, "title").as((t) => t || "")}
maxWidthChars={20}
ellipsize={Pango.EllipsizeMode.END}
/>
<icon
className="player-icon"
icon={bind(player, "entry").as(playerIcon)}
tooltipText={bind(player, "identity")}
/>
</box>
<label
className="player-artist"
halign={Gtk.Align.START}
label={bind(player, "artist").as((a) => a || "")}
maxWidthChars={24}
ellipsize={Pango.EllipsizeMode.END}
visible={bind(player, "artist").as((a) => (a || "") !== "")}
/>
<slider
value={position().as((p) =>
player.length > 0
? Math.min(1, Math.max(0, p / player.length))
: 0,
)}
onDragged={(self) => {
if (player.length > 0) player.position = self.value * player.length
}}
visible={bind(player, "length").as((l) => l > 0)}
/>
<box className="player-footer" visible={bind(player, "length").as((l) => l > 0)}>
<label
className="player-position"
label={position().as((p) => fmtTime(p))}
/>
<box hexpand />
<button
className="player-ctl"
onClicked={() => player.previous()}
visible={bind(player, "can_go_previous")}
>
<icon icon={PREV} />
</button>
<button
className="player-ctl play-pause"
onClicked={() => player.play_pause()}
visible={bind(player, "can_play")}
>
<icon
icon={bind(player, "playback_status").as((s) =>
s === AstalMpris.PlaybackStatus.PLAYING ? PAUSE : PLAY,
)}
/>
</button>
<button
className="player-ctl"
onClicked={() => player.next()}
visible={bind(player, "can_go_next")}
>
<icon icon={NEXT} />
</button>
<box hexpand />
<label
className="player-length"
label={bind(player, "length").as((l) => fmtTime(l))}
/>
</box>
</box>
</box>
)
}
export function MediaSection() {
const players = bind(mpris, "players")
return (
<box className="media-section" vertical visible={players.as((ps) => ps.length > 0)}>
{players.as((ps) => ps.map((p) => PlayerCard(p)))}
</box>
)
}
// the players property only notifies on connect/disconnect, so the bar
// button tracks the first player directly to follow track changes
let player: AstalMpris.Player | null = null
let hookIds: Array<number> = []
const hasPlayer = Variable(false)
const title = Variable("")
const icon = Variable(NO_ART)
function sync() {
const current = mpris.players[0] ?? null
if (current !== player) {
hookIds.forEach((id) => player?.disconnect(id))
hookIds = []
player = current
if (player) {
hookIds = [
player.connect("notify::title", sync),
player.connect("notify::entry", sync),
]
}
}
hasPlayer.set(player !== null)
title.set(player?.title || "")
icon.set(player ? playerIcon(player.entry) : "")
}
mpris.connect("notify::players", sync)
sync()
export function MediaButton() {
return (
<button
className="media center"
visible={bind(hasPlayer)}
onClicked={() => {
if (player && player.can_play) player.play_pause()
}}
onScroll={(_, e) => {
if (!player) return
if (e.delta_y < 0) player.next()
else player.previous()
}}
>
<box spacing={6}>
<icon className="media-icon" icon={bind(icon)} />
<label
className="media-title"
label={bind(title)}
maxWidthChars={30}
ellipsize={Pango.EllipsizeMode.END}
/>
</box>
</button>
)
}

View File

@ -1,61 +1,168 @@
import GLib from "gi://GLib" import GLib from "gi://GLib"
import { App, Astal, Gdk, Gtk } from "astal/gtk3" import { App, Astal, Gdk, Gtk } from "astal/gtk3"
import Notifd from "gi://AstalNotifd" import Notifd from "gi://AstalNotifd"
import { bind } from "astal" import { Variable, bind } from "astal"
import { MAX_TIMEOUT } from "../notif-config" import { MAX_TIMEOUT } from "../notif-config"
const notifd = Notifd.get_default() const notifd = Notifd.get_default()
notifd.default_timeout = MAX_TIMEOUT export const dndIcon = Variable.derive([bind(notifd, "dontDisturb")], (dnd) =>
dnd
? "notifications-disabled-symbolic"
: "preferences-system-notifications-symbolic",
)
// every notification received this session is kept until it is explicitly
// cleared, so popups that time out remain in the dropdown's list. only
// notifications dismissed directly from their popup skip the list, and
// transient status toasts never enter it at all.
export const history = Variable<Notifd.Notification[]>([])
export const notifCount = bind(history).as((ns) => ns.length)
// apps whose notifications are transient status updates: they surface as
// toasts only and never enter the dropdown history or the bar count
const EPHEMERAL_APPS = new Set(["dictation"])
// irregular ids the generic capitalization rule can't fix up
const FRIENDLY_NAMES: Record<string, string> = {
"notify-send": "Notification",
}
// per-app icons for notifications that don't supply one
const APP_ICONS: Record<string, string> = {
dictation: "audio-input-microphone-symbolic",
}
// apps like discord report a bare binary id; make words of those while
// names with any uppercase ("Spotify") pass through untouched
function displayName(n: Notifd.Notification) {
const raw = n.app_name || "Notification"
const mapped = FRIENDLY_NAMES[raw.toLowerCase()]
if (mapped) return mapped
if (raw === raw.toLowerCase() && /[a-z]/.test(raw)) {
return raw
.split(/[-_ ]+/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")
}
return raw
}
export function DndToggle() {
return (
<button
className={bind(notifd, "dontDisturb").as((v) =>
v ? "simple-toggle active" : "simple-toggle",
)}
onClicked={() => (notifd.dontDisturb = !notifd.dontDisturb)}
>
<box spacing={8}>
<icon className="icon" icon={dndIcon()} />
<label label="Do Not Disturb" />
</box>
</button>
)
}
// popups are timed out entirely client-side: the daemon never resolves them
// on its own, so a popup under the pointer can be kept alive indefinitely
notifd.ignore_timeout = true
notifd.default_timeout = -1
// notifications restored from the daemon's cache on startup are stale // notifications restored from the daemon's cache on startup are stale
for (const n of notifd.notifications) { for (const n of notifd.notifications) {
n.dismiss() n.dismiss()
} }
// the daemon honors app-requested timeouts, so enforce our own cap for
// notifications that ask for more (or none at all). critical notifications
// are exempt and stay until dismissed.
notifd.connect("notified", (_, id: number) => { notifd.connect("notified", (_, id: number) => {
const n = notifd.get_notification(id) const n = notifd.get_notification(id)
if (!n || n.urgency === Notifd.Urgency.CRITICAL) return if (!n) return
if (n.expire_timeout <= 0 || n.expire_timeout > MAX_TIMEOUT) { if (EPHEMERAL_APPS.has(n.app_name.toLowerCase())) return
GLib.timeout_add(GLib.PRIORITY_DEFAULT, MAX_TIMEOUT, () => {
if (notifd.notifications.includes(n)) n.dismiss() // re-notified ids update the existing entry instead of adding a duplicate
return GLib.SOURCE_REMOVE if (!history.get().some((m) => m.id === id)) {
}) history.set([...history.get(), n])
} }
}) })
// ids of notifications whose popup is currently under the pointer
const hovered = new Set<number>()
// enforce our own cap for popups, honoring shorter app-requested timeouts.
// critical notifications are exempt and stay until dismissed, as are popups
// being hovered.
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => {
const now = GLib.DateTime.new_now_local().to_unix()
for (const n of [...notifd.notifications]) {
if (n.urgency === Notifd.Urgency.CRITICAL || hovered.has(n.id)) continue
const timeout =
n.expire_timeout > 0
? Math.min(n.expire_timeout, MAX_TIMEOUT)
: MAX_TIMEOUT
if (now - n.time >= timeout / 1000) n.dismiss()
}
return GLib.SOURCE_CONTINUE
})
// drop a notification from the list and withdraw its popup; used when a
// notification is dismissed directly instead of timing out
export function dismissNotification(n: Notifd.Notification) {
hovered.delete(n.id)
history.set(history.get().filter((m) => m.id !== n.id))
notifd.get_notification(n.id)?.dismiss()
}
export function clearNotifications() {
for (const n of [...notifd.notifications]) {
dismissNotification(n)
}
history.set([])
}
function formatTime(n: Notifd.Notification) { function formatTime(n: Notifd.Notification) {
return GLib.DateTime.new_from_unix_local(n.time).format("%H:%M")! return GLib.DateTime.new_from_unix_local(n.time).format("%H:%M")!
} }
export function NotificationCard(n: Notifd.Notification) { export function NotificationCard(n: Notifd.Notification, toast = false) {
const critical = n.urgency === Notifd.Urgency.CRITICAL const critical = n.urgency === Notifd.Urgency.CRITICAL
const appName = displayName(n)
const appIcon =
n.app_icon ||
APP_ICONS[appName.toLowerCase()] ||
"preferences-system-notifications-symbolic"
return ( return (
<button <button
className={`notif${critical ? " critical" : ""}`} className={`notif${critical ? " critical" : ""}${toast ? " toast" : ""}`}
onClicked={() => n.dismiss()} onClicked={() => dismissNotification(n)}
{...(toast
? {
onHover: () => hovered.add(n.id),
onHoverLost: () => hovered.delete(n.id),
}
: {})}
> >
<box vertical> <box vertical>
<box className="notif-header"> <box className="notif-header">
<icon <icon pixelSize={16} icon={appIcon} />
pixelSize={16}
icon={n.app_icon || "preferences-system-notifications-symbolic"}
/>
<label <label
className="notif-app-name" className="notif-app-name"
label={n.app_name || "Notification"} label={appName}
halign={Gtk.Align.START} halign={Gtk.Align.START}
hexpand hexpand
/> />
<label className="notif-time" label={formatTime(n)} /> <label className="notif-time" label={formatTime(n)} />
<button <button
className="notif-close" className="notif-close"
onClicked={() => n.dismiss()} onClicked={() => dismissNotification(n)}
> >
<icon icon="window-close-symbolic" pixelSize={12} /> <icon icon="window-close-symbolic" pixelSize={12} />
</button> </button>
@ -115,12 +222,15 @@ export default function Notifications(gdkmonitor: Gdk.Monitor) {
marginTop={54} marginTop={54}
marginRight={8} marginRight={8}
visible={bind(notifd, "notifications").as((ns) => ns.length > 0)} visible={bind(notifd, "notifications").as((ns) => ns.length > 0)}
onNotifyVisible={(self) => {
if (!self.visible) hovered.clear()
}}
> >
<box className="notifications" vertical spacing={8} widthRequest={360}> <box className="notifications" vertical spacing={8} widthRequest={360}>
{bind(notifd, "notifications").as((ns) => {bind(notifd, "notifications").as((ns) =>
[...ns] [...ns]
.sort((a, b) => b.time - a.time) .sort((a, b) => b.time - a.time)
.map((n) => NotificationCard(n)), .map((n) => NotificationCard(n, true)),
)} )}
</box> </box>
</window> </window>

View File

@ -1,18 +1,24 @@
import Gtk from "gi://Gtk?version=3.0" import Gtk from "gi://Gtk?version=3.0"
import GLib from "gi://GLib"
import AstalWp from "gi://AstalWp" import AstalWp from "gi://AstalWp"
import Notifd from "gi://AstalNotifd"
import { execAsync, Variable, bind } from "astal" import { execAsync, Variable, bind } from "astal"
import Dropdown from "./Dropdown" import Dropdown from "./Dropdown"
import { wifiIcon, WifiSection } from "./Wifi" import { wifiIcon, WifiSection } from "./Wifi"
import { bluetoothIndicator, BluetoothSection } from "./Bluetooth" import { bluetoothIndicator, BluetoothSection } from "./Bluetooth"
import { BrightnessRow } from "./Brightness"
import { MediaSection } from "./Media"
import { batteryIcon, batteryPercent, BatterySection } from "./Battery" import { batteryIcon, batteryPercent, BatterySection } from "./Battery"
import { NotificationCard } from "./Notifications" import {
clearNotifications,
dndIcon,
DndToggle,
history,
notifCount,
NotificationCard,
} from "./Notifications"
const speaker = AstalWp.get_default().audio.default_speaker! const speaker = AstalWp.get_default().audio.default_speaker!
const mic = AstalWp.get_default().audio.default_microphone const mic = AstalWp.get_default().audio.default_microphone
const notifd = Notifd.get_default()
const notifCount = bind(notifd, "notifications").as((ns) => ns.length)
const speakerIcon = Variable.derive( const speakerIcon = Variable.derive(
[bind(speaker, "volume"), bind(speaker, "mute")], [bind(speaker, "volume"), bind(speaker, "mute")],
@ -32,21 +38,42 @@ const micIcon = mic
) )
: null : null
const actions = [ const user = GLib.get_user_name()
const actions: { icon: string; cmd?: string[] }[] = [
{ icon: "emblem-system-symbolic" },
{ icon: "system-lock-screen-symbolic", cmd: ["hyprlock"] }, { icon: "system-lock-screen-symbolic", cmd: ["hyprlock"] },
{ icon: "weather-clear-night-symbolic", cmd: ["systemctl", "suspend"] }, { 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-reboot-symbolic", cmd: ["systemctl", "reboot"] },
{ icon: "system-log-out-symbolic", cmd: ["hyprctl", "dispatch", "exit"] },
{ icon: "system-shutdown-symbolic", cmd: ["systemctl", "poweroff"] }, { icon: "system-shutdown-symbolic", cmd: ["systemctl", "poweroff"] },
] ]
function Header() {
return (
<box className="header" spacing={8}>
<box className="avatar">
<icon icon="nix-snowflake-white" pixelSize={16} />
</box>
<label className="username" label={user} valign={Gtk.Align.CENTER} />
<box hexpand />
{actions.map((action) => (
<button onClicked={() => action.cmd && execAsync(action.cmd)}>
<icon icon={action.icon} />
</button>
))}
</box>
)
}
export default function QuickSettings(gdkmonitor: Gdk.Monitor) { export default function QuickSettings(gdkmonitor: Gdk.Monitor) {
const open = Variable(false) const open = Variable(false)
const notifOpen = Variable(false)
open.subscribe((v) => { const wifiSection = WifiSection({ open })
if (!v) notifOpen.set(false) const btSection = BluetoothSection({ open })
}) const toggles = [wifiSection?.toggle, btSection?.toggle].filter(
(t) => t !== undefined,
)
const button = ( const button = (
<button <button
@ -62,7 +89,7 @@ export default function QuickSettings(gdkmonitor: Gdk.Monitor) {
> >
<box spacing={6}> <box spacing={6}>
<box className="notif-indicator" spacing={3}> <box className="notif-indicator" spacing={3}>
<icon icon="preferences-system-notifications-symbolic" /> <icon icon={dndIcon()} />
<label <label
className="notif-count" className="notif-count"
label={notifCount.as((n) => `${n}`)} label={notifCount.as((n) => `${n}`)}
@ -84,93 +111,77 @@ export default function QuickSettings(gdkmonitor: Gdk.Monitor) {
) )
const popup = ( const popup = (
<Dropdown gdkmonitor={gdkmonitor} target={button} open={open} width={280}> <Dropdown
<box className="power-header" homogeneous> gdkmonitor={gdkmonitor}
{actions.map((action) => ( target={button}
<button open={open}
className="power-action" width={280}
onClicked={() => execAsync(action.cmd)} maxHeight={gdkmonitor.get_geometry().height - 200}
>
<icon icon={action.icon} />
</button>
))}
</box>
<box className="qs-row">
<button
className="qs-toggle"
onClicked={() => (speaker.mute = !speaker.mute)}
> >
<Header />
<BatterySection />
<box className="sliders-box" vertical spacing={2}>
<box className="volume" spacing={6}>
<button onClicked={() => (speaker.mute = !speaker.mute)}>
<icon icon={speakerIcon()} /> <icon icon={speakerIcon()} />
</button> </button>
<slider <slider
className="vol-slider"
hexpand hexpand
className={bind(speaker, "mute").as((m) => (m ? "muted" : ""))}
value={bind(speaker, "volume")} value={bind(speaker, "volume")}
onDragged={(self) => (speaker.volume = self.value)} onDragged={(self) => (speaker.volume = self.value)}
/> />
</box> </box>
{mic && micIcon && ( {mic && micIcon && (
<box className="qs-row"> <box className="microphone" spacing={6}>
<button <button onClicked={() => (mic.mute = !mic.mute)}>
className="qs-toggle"
onClicked={() => (mic.mute = !mic.mute)}
>
<icon icon={micIcon()} /> <icon icon={micIcon()} />
</button> </button>
<slider <slider
className="vol-slider"
hexpand hexpand
className={bind(mic, "mute").as((m) => (m ? "muted" : ""))}
value={bind(mic, "volume")} value={bind(mic, "volume")}
onDragged={(self) => (mic.volume = self.value)} onDragged={(self) => (mic.volume = self.value)}
/> />
</box> </box>
)} )}
<WifiSection open={open} /> <BrightnessRow />
<BluetoothSection open={open} /> </box>
<BatterySection /> <DndToggle />
{toggles.length > 0 && (
<box homogeneous spacing={6}>
{toggles}
</box>
)}
{wifiSection?.menu}
{btSection?.menu}
<MediaSection />
<box className="notif-section" vertical spacing={6}> <box className="notif-section" vertical spacing={6}>
<box className="notif-header-row" spacing={6}> <box className="notif-header-row" spacing={6}>
<button <box className="notif-header" spacing={6}>
className="notif-toggle"
hexpand
halign={Gtk.Align.START}
onClicked={() => notifOpen.set(!notifOpen.get())}
>
<box spacing={6}>
<icon icon="preferences-system-notifications-symbolic" /> <icon icon="preferences-system-notifications-symbolic" />
<label label="Notifications" /> <label label="Notifications" />
<icon
icon={notifOpen().as((v) =>
v ? "pan-down-symbolic" : "pan-end-symbolic",
)}
/>
</box> </box>
</button> <box hexpand />
<button <button
className="notif-clear" className="notif-clear"
onClicked={() => [...notifd.notifications].forEach((n) => n.dismiss())} onClicked={clearNotifications}
> >
<label label="Clear" /> <label label="Clear" />
</button> </button>
</box> </box>
<revealer
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={180}
revealChild={bind(notifOpen)}
>
<box className="notifications" vertical> <box className="notifications" vertical>
<label <label
className="notif-empty" className="notif-empty"
label="no notifications" label="no notifications"
visible={notifCount.as((n) => n === 0)} visible={notifCount.as((n) => n === 0)}
/> />
{bind(notifd, "notifications").as((ns) => {bind(history).as((ns) =>
[...ns] [...ns]
.sort((a, b) => b.time - a.time) .sort((a, b) => b.time - a.time)
.map((n) => NotificationCard(n)), .map((n) => NotificationCard(n)),
)} )}
</box> </box>
</revealer>
</box> </box>
</Dropdown> </Dropdown>
) )

View File

@ -66,6 +66,7 @@ export function ArrowToggleButton({
return ( return (
<box <box
className={active.as((v) => (v ? "toggle-button active" : "toggle-button"))} className={active.as((v) => (v ? "toggle-button active" : "toggle-button"))}
hexpand
> >
<button className="toggle-pill" hexpand onClicked={onMainClick}> <button className="toggle-pill" hexpand onClicked={onMainClick}>
<box hexpand> <box hexpand>
@ -85,7 +86,7 @@ export function ArrowToggleButton({
type MenuProps = { type MenuProps = {
open: Variable<boolean> open: Variable<boolean>
icon: string icon: string | Binding<string>
title: string title: string
children: Array<JSX.Element> children: Array<JSX.Element>
} }

View File

@ -1,6 +1,7 @@
import Gtk from "gi://Gtk?version=3.0" import Gtk from "gi://Gtk?version=3.0"
import AstalNetwork from "gi://AstalNetwork" import AstalNetwork from "gi://AstalNetwork"
import { type Binding, type Variable, Variable, bind } from "astal" import { type Binding, type Variable, Variable, bind } from "astal"
import { ArrowToggleButton, Menu } from "./ToggleButton"
const CONNECTING = [40, 50, 60, 70, 80, 90] const CONNECTING = [40, 50, 60, 70, 80, 90]
@ -49,7 +50,7 @@ function WifiRow(
} }
export function WifiSection({ open }: { open: Variable<boolean> }) { export function WifiSection({ open }: { open: Variable<boolean> }) {
if (!wifi) return <box /> if (!wifi) return null
const expanded = Variable(false) const expanded = Variable(false)
const pendingAP = Variable<AstalNetwork.AccessPoint | null>(null) const pendingAP = Variable<AstalNetwork.AccessPoint | null>(null)
@ -111,40 +112,22 @@ export function WifiSection({ open }: { open: Variable<boolean> }) {
} }
}) })
return ( const toggle = (
<box className="wifi-section" vertical> <ArrowToggleButton
<box className="wifi-header"> open={expanded}
<button icon={bind(wifi, "icon-name")}
className="wifi-toggle" label={bind(wifi, "ssid").as((ssid) => ssid || "Not Connected")}
onClicked={() => expanded.set(!expanded.get())}
>
<box spacing={6}>
<icon icon={bind(wifi, "icon-name")} />
<label label="Wi-Fi" />
<icon
icon={expanded().as((v) =>
v ? "pan-down-symbolic" : "pan-end-symbolic",
)}
/>
</box>
</button>
<button className="wifi-rescan" onClicked={() => wifi.scan()}>
<icon icon="view-refresh-symbolic" />
</button>
<switch
active={bind(wifi, "enabled")} active={bind(wifi, "enabled")}
onStateSet={(_, state) => { onMainClick={() => {
wifi.enabled = state wifi.enabled = !wifi.enabled
return true if (wifi.enabled) wifi.scan()
}} }}
onArrowClick={() => expanded.set(!expanded.get())}
/> />
</box> )
<revealer
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN} const menu = (
transitionDuration={180} <Menu open={expanded} icon={bind(wifi, "icon-name")} title="Wi-Fi">
revealChild={bind(expanded)}
>
<box vertical>
<label <label
className="wifi-status" className="wifi-status"
label={status()} label={status()}
@ -156,23 +139,22 @@ export function WifiSection({ open }: { open: Variable<boolean> }) {
label="wi-fi is off" label="wi-fi is off"
visible={bind(wifi, "enabled").as((enabled) => !enabled)} visible={bind(wifi, "enabled").as((enabled) => !enabled)}
/> />
<scrollable <box vertical visible={bind(wifi, "enabled")}>
hscroll={Gtk.PolicyType.NEVER}
vscroll={Gtk.PolicyType.AUTOMATIC}
maxContentHeight={360}
propagateNaturalHeight
visible={bind(wifi, "enabled")}
>
<box vertical>
{bind(wifi, "access-points").as((aps) => {bind(wifi, "access-points").as((aps) =>
strongest(aps).map((ap) => strongest(aps).map((ap) =>
WifiRow(ap, activeSsid, connect) WifiRow(ap, activeSsid, connect)
), ),
)} )}
</box> </box>
</scrollable> <box className="menu-separator" />
</box> <button className="wifi-rescan" onClicked={() => wifi.scan()}>
</revealer> <box spacing={6}>
<icon icon="view-refresh-symbolic" />
<label label="Rescan" />
</box> </box>
</button>
</Menu>
) )
return { toggle, menu }
} }

View File

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

View File

@ -45,7 +45,9 @@ let
wav="$dir/clip.wav" wav="$dir/clip.wav"
notify() { notify() {
notify-send -t 2500 "Dictation" "$1" 2>/dev/null || true # -a sets the app name shown by the notification daemon; the bar
# treats "Dictation" as transient (toast only, never listed)
notify-send -a "Dictation" -t 2500 "$1" 2>/dev/null || true
} }
case "''${1:-}" in case "''${1:-}" in

View File

@ -5,10 +5,7 @@
with lib; with lib;
{ {
options.modules.ags = { options.modules.ags = {
enable = mkEnableOption { enable = mkEnableOption "the AGS status bar";
default = false;
description = "Enable the AGS status bar";
};
timeout = mkOption { timeout = mkOption {
type = types.ints.positive; type = types.ints.positive;