This commit is contained in:
Nickolaj Jepsen 2025-02-20 22:50:06 +01:00
parent 2b7b63a18c
commit 638ef7093e
140 changed files with 307 additions and 121 deletions

View file

@ -0,0 +1,91 @@
@use "../variables.scss" as *;
.Bar {
color: $fg;
background-color: $bg;
border-bottom: 2px solid $accent;
font-size: $font-large;
}
.SecondaryBar {
color: $fg;
background-color: $bg;
font-size: $font-large;
transition:
opacity $transition,
border-color $transition;
&.left {
border-left: 2px solid $accent;
border-bottom: 2px solid $accent;
border-bottom-left-radius: $radius;
}
&.right {
border-right: 2px solid $accent;
border-bottom: 2px solid $accent;
border-bottom-right-radius: $radius;
}
&.top {
border-right: 2px solid $accent;
border-top: 2px solid $accent;
border-top-right-radius: $radius;
}
&.bottom {
border-right: 2px solid $accent;
border-bottom: 2px solid $accent;
border-bottom-right-radius: $radius;
}
&.inactive {
opacity: 0.7;
border-color: transparent;
}
}
.Time {
font-weight: bold;
}
.SysTray {
button {
all: unset;
&:hover {
all: unset;
}
}
.menu,
modelbutton {
background-color: $bg;
transition: background-color $transition;
}
modelbutton:hover {
background-color: $bg-alt;
}
}
.FocusedClient {
color: $accent;
padding: 0 5px;
}
.Slider {
highlight {
background-color: $accent;
border-radius: $radius;
}
trough {
background-color: $bg-alt;
border-radius: $radius;
min-height: 5px;
min-width: 80px;
}
}
.Right > * {
border-left: 2px solid $accent;
padding: 4px 15px;
}
.Left > * {
border-right: 2px solid $accent;
padding: 4px 15px;
}

View file

@ -0,0 +1,122 @@
import Hyprland from "gi://AstalHyprland";
import Tray from "gi://AstalTray";
import Pango from "gi://Pango?version=1.0";
import { GLib, Variable, bind } from "astal";
import { App, Astal, type Gdk, Gtk } from "astal/gtk4";
import config from "../config";
import { getHyprlandMonitor } from "../utils/monitors";
import { Calendar } from "../widgets";
import { connectDropdown } from "./sections/Dropdown";
import Media from "./sections/Media";
import { Playback } from "./sections/Playback";
import { Workspaces } from "./sections/Workspace";
function SysTray() {
const tray = Tray.get_default();
const item = bind(tray, "items").as((items) =>
items.filter((item) => config.tray.ignore.every((test) => !test(item.id))),
);
return (
<box
cssClasses={["SysTray"]}
visible={bind(item).as((items) => items.length > 0)}
>
{bind(item).as((items) =>
items.map((item) => (
<menubutton
tooltipMarkup={bind(item, "tooltipMarkup")}
// popover={false}
// actionGroup={bind(item, "actionGroup").as((ag) => ["dbusmenu", ag])}
menuModel={bind(item, "menuModel")}
>
<image gicon={bind(item, "gicon")} />
</menubutton>
)),
)}
</box>
);
}
function FocusedClient() {
const hypr = Hyprland.get_default();
const focused = bind(hypr, "focusedClient");
return (
<box cssClasses={["Focused"]} visible={focused.as(Boolean)}>
{focused.as((client) => {
if (!client) {
return;
}
return (
<label
label={bind(client, "title").as(String)}
ellipsize={Pango.EllipsizeMode.MIDDLE}
maxWidthChars={120}
/>
);
})}
</box>
);
}
function Time(props: { monitor: Gdk.Monitor }) {
const datetime = Variable<GLib.DateTime>(GLib.DateTime.new_now_local()).poll(
1000,
() => {
return GLib.DateTime.new_now_local();
},
);
const date = bind(datetime).as((dt) => dt.format("%Y-%m-%d") ?? "");
const time = bind(datetime).as((dt) => dt.format("%H:%M") ?? "");
return (
<box
cssClasses={["DateTime"]}
spacing={10}
setup={(self) => {
connectDropdown(self, <Calendar showWeekNumbers />, props.monitor);
}}
>
<label label={date} cssClasses={["Date"]} />
<label label={time} cssClasses={["Time"]} />
</box>
);
}
export default function Bar(monitor: Gdk.Monitor) {
const { TOP, LEFT, RIGHT } = Astal.WindowAnchor;
const hyprlandMonitor = getHyprlandMonitor(monitor);
return (
<window
visible
name={"Bar"}
// window lags hard if css classes with padding border ect are used so we apply them to a child instead
// cssClasses={["Bar"]}
gdkmonitor={monitor}
exclusivity={Astal.Exclusivity.EXCLUSIVE}
layer={Astal.Layer.OVERLAY}
anchor={TOP | LEFT | RIGHT}
application={App}
>
<centerbox cssClasses={["Bar"]}>
<box halign={Gtk.Align.START} cssClasses={["Left"]}>
<Time monitor={monitor} />
<Workspaces
monitor={hyprlandMonitor}
selectedWorkspaces={[1, 2, 3, 4, 5]}
/>
</box>
<box halign={Gtk.Align.CENTER}>
<FocusedClient />
</box>
<box halign={Gtk.Align.END} cssClasses={["Right"]}>
<Media monitor={monitor} />
<Playback monitor={monitor} />
<SysTray />
</box>
</centerbox>
</window>
);
}

View file

@ -0,0 +1,106 @@
import Hyprland from "gi://AstalHyprland";
import { App } from "astal/gtk4";
import { Astal, type Gdk, Gtk } from "astal/gtk4";
import { getHyprlandMonitor } from "../utils/monitors";
import { Workspaces } from "./sections/Workspace";
import { bind, type Binding, Variable } from "astal";
const hypr = Hyprland.get_default();
interface AddWorkspaceButtonProps {
show: Binding<boolean>;
cssClasses: string[];
}
const AddWorkspaceButton = ({ show, cssClasses }: AddWorkspaceButtonProps) => {
return (
<revealer
revealChild={show}
transitionType={Gtk.RevealerTransitionType.SLIDE_RIGHT}
transitionDuration={500}
>
<button
cssClasses={["workspace", ...cssClasses]}
visible={show}
onClicked={() => {
hypr.dispatch("workspace", "emptynm");
}}
valign={Gtk.Align.CENTER}
>
<image iconName="plus-symbolic" pixelSize={18} />
</button>
</revealer>
);
};
export default function SecondaryBar(
monitor: Gdk.Monitor,
relation: "top" | "bottom" | "left" | "right",
) {
const { TOP, LEFT, RIGHT, BOTTOM } = Astal.WindowAnchor;
const hyprlandMonitor = getHyprlandMonitor(monitor);
const anchor = {
top: BOTTOM | LEFT,
left: TOP | RIGHT,
right: TOP | LEFT,
bottom: TOP | LEFT,
}[relation];
const cssClasses = {
top: ["SecondaryBar", "top"],
left: ["SecondaryBar", "left"],
right: ["SecondaryBar", "right"],
bottom: ["SecondaryBar", "bottom"],
}[relation];
const alignment = {
top: Gtk.Align.START,
left: Gtk.Align.END,
right: Gtk.Align.START,
bottom: Gtk.Align.START,
}[relation];
const showAddWorkspaceButton = Variable(false);
const monitorFocused = bind(hypr, "focusedMonitor").as(
(fm) => fm === hyprlandMonitor,
);
return (
<window
visible
gdkmonitor={monitor}
exclusivity={Astal.Exclusivity.EXCLUSIVE}
layer={Astal.Layer.OVERLAY}
anchor={anchor}
application={App}
halign={alignment}
defaultWidth={1} // Ensure the window shinks when content is removed
defaultHeight={26}
onHoverEnter={() => showAddWorkspaceButton.set(true)}
onHoverLeave={() => showAddWorkspaceButton.set(false)}
>
<box
halign={alignment}
spacing={10}
cssClasses={monitorFocused.as((x) =>
x ? cssClasses : [...cssClasses, "inactive"],
)}
>
{relation === "left" ? (
<AddWorkspaceButton
show={bind(showAddWorkspaceButton)}
cssClasses={["add-left"]}
/>
) : null}
<Workspaces monitor={hyprlandMonitor} reverse={relation === "left"} />
{relation !== "left" ? (
<AddWorkspaceButton
show={bind(showAddWorkspaceButton)}
cssClasses={["add-right"]}
/>
) : null}
</box>
</window>
);
}

View file

@ -0,0 +1,10 @@
@use "../../variables.scss" as *;
.Dropdown {
padding: 15px;
background-color: $bg;
border: 2px solid $accent;
border-top: 0;
border-bottom-left-radius: $radius;
border-bottom-right-radius: $radius;
}

View file

@ -0,0 +1,133 @@
import { type Binding, Variable, bind } from "astal";
import { App, Astal, type Gdk, Gtk, hook } from "astal/gtk4";
import { cancelTimeout, cancelableTimeout } from "../../utils/timeout";
const ANIMATION_DURATION = 500;
/**
* Calculate the offset and width of the parent widget
*
* @returns [offset, width]
*/
const calculateParentSize = (widget: Gtk.Widget): [number, number] => {
const [_, x, __] = widget.translate_coordinates(widget.root, 0, 0);
// These properties are apparently deprecated, but I can't find a better way to get them
const padding = widget.get_style_context().get_padding().left;
const margin = widget.get_style_context().get_margin().left;
const borderWidth = widget.get_style_context().get_border().left;
const offset = x - padding - margin - borderWidth;
// Get allocated width doesn't include border width, so we have to add it back
const width = widget.get_allocated_width() + borderWidth;
return [offset, width];
};
interface ConnectDropdownProps {
fullWidth?: boolean;
}
export function connectDropdown(
widget: Gtk.Widget,
child: JSX.Element | Binding<JSX.Element | null> | null,
gdkmonitor: Gdk.Monitor,
options: ConnectDropdownProps = {},
) {
const hoverTrigger = Variable(false);
const hoverOverlay = Variable(false);
const offsetX = Variable(0);
const width = Variable(-1);
const isHovering = Variable.derive(
[hoverTrigger, hoverOverlay],
// (trigger, overlay) => trigger || overlay,
(trigger, overlay) => trigger || overlay,
);
const box = (
<box
widthRequest={bind(width)}
marginStart={bind(offsetX)}
cssClasses={["Dropdown"]}
onHoverEnter={() => hoverOverlay.set(true)}
onHoverLeave={() => hoverOverlay.set(false)}
>
{child}
</box>
);
const dropdown = (
<window
cssClasses={["DropdownWindow"]}
gdkmonitor={gdkmonitor}
layer={Astal.Layer.OVERLAY}
anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT}
application={App}
>
<revealer
transitionDuration={ANIMATION_DURATION}
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
valign={Gtk.Align.START}
setup={(self) => {
bind(self, "child_revealed").subscribe((is_revealed) => {
if (!is_revealed) {
dropdown.hide();
}
});
}}
>
{box}
</revealer>
</window>
) as Gtk.Window;
isHovering.subscribe((hovering) => {
if (hovering) {
dropdown.show();
(dropdown.get_first_child() as Gtk.Revealer).set_reveal_child(true);
} else {
(dropdown.get_first_child() as Gtk.Revealer).set_reveal_child(false);
}
});
const hoverController = new Gtk.EventControllerMotion();
widget.add_controller(hoverController);
hoverController.connect("enter", () => {
cancelableTimeout(
() => {
const [offset, parentWidth] = calculateParentSize(widget);
if (options.fullWidth) {
width.set(parentWidth);
}
const dropdownWidth =
(box.get_preferred_size()[1]?.width ?? 0) - offsetX.get();
const centerOffset = dropdownWidth / 2 - parentWidth / 2;
const totalOffset = offset - centerOffset;
// Ensure the dropdown doesn't go off the screen
const maxOffset = gdkmonitor.get_geometry().width - dropdownWidth;
offsetX.set(Math.max(Math.min(totalOffset, maxOffset), 0));
hoverTrigger.set(true);
},
"showDropdown",
100,
);
});
hoverController.connect("leave", () => {
cancelTimeout("showDropdown");
hoverTrigger.set(false);
});
widget.connect("destroy", () => {
isHovering.drop();
hoverOverlay.drop();
hoverTrigger.drop();
offsetX.drop();
widget.remove_controller(hoverController);
});
}

View file

@ -0,0 +1,37 @@
@use "../../variables.scss" as *;
.MediaDropdown {
.MediaCover {
border-radius: $radius;
margin-bottom: 5px;
}
.MediaArtist {
color: $fg-alt;
}
.MediaAlbum {
color: $fg-alt;
font-size: 12px;
}
.Slider {
margin-top: 10px;
margin-bottom: 10px;
}
separator {
margin-top: 10px;
margin-bottom: 10px;
}
.MediaOther {
margin-top: 10px;
button.active {
background-color: $accent;
border-color: $accent;
color: $bg;
}
}
}

View file

@ -0,0 +1,387 @@
import Mpris from "gi://AstalMpris";
import Pango from "gi://Pango?version=1.0";
import { type Binding, Variable, bind } from "astal";
import type { Subscribable } from "astal/binding";
import { type Gdk, Gtk } from "astal/gtk4";
import { hasIcon } from "../../utils/gtk";
import { Expander, Separator } from "../../widgets";
import { connectDropdown } from "./Dropdown";
const mpris = Mpris.get_default();
const MARQUEE_LENGTH = 30;
interface MprisStatus {
status: Mpris.PlaybackStatus;
lastPlayed: number;
canControl: boolean;
}
class ActiveMediaDetector implements Subscribable {
#userOverride: string | undefined;
#players: { [busName: string]: MprisStatus } = {};
#listenerSignal = new Map<Mpris.Player, number>();
#active = Variable<Mpris.Player | undefined>(undefined);
#updateActive() {
const busName = Object.entries<MprisStatus>(this.#players)
.filter(([, status]) => {
// Don't consider players that are stopped or can't be controlled
if (status.status === Mpris.PlaybackStatus.STOPPED) {
return false;
}
return status.canControl;
})
.sort(([aName, a], [bName, b]) => {
if (aName === this.#userOverride) {
return -1;
}
if (bName === this.#userOverride) {
return 1;
}
if (
a.status === Mpris.PlaybackStatus.PLAYING &&
b.status !== Mpris.PlaybackStatus.PLAYING
) {
return -1;
}
if (
b.status === Mpris.PlaybackStatus.PLAYING &&
a.status !== Mpris.PlaybackStatus.PLAYING
) {
return 1;
}
return b.lastPlayed - a.lastPlayed;
})[0]?.[0];
const player = busName
? mpris.get_players().find((player) => player.bus_name === busName)
: undefined;
this.#active.set(player);
}
#handleUpdate(player: Mpris.Player) {
const lastStatus = this.#players[player.bus_name]?.status;
let lastPlayed = this.#players[player.bus_name]?.lastPlayed ?? -1;
// If the player is playing (or was just playing), update the last played time
if (
player.playback_status === Mpris.PlaybackStatus.PLAYING ||
lastStatus === Mpris.PlaybackStatus.PLAYING
) {
lastPlayed = Date.now();
}
this.#players[player.bus_name] = {
status: player.playback_status,
lastPlayed: lastPlayed,
canControl: player.can_control,
};
this.#updateActive();
}
#connect(player: Mpris.Player) {
const signal = player.connect("notify::playback-status", () => {
this.#handleUpdate(player);
});
this.#listenerSignal.set(player, signal);
}
#disconnect(player: Mpris.Player) {
const signal = this.#listenerSignal.get(player);
if (signal) {
player.disconnect(signal);
this.#listenerSignal.delete(player);
}
}
constructor() {
for (const player of mpris.players) {
this.#handleUpdate(player);
this.#connect(player);
}
mpris.connect("player-added", (_, player) => {
this.#handleUpdate(player);
this.#connect(player);
});
mpris.connect("player-closed", (_, player) => {
delete this.#players[player.bus_name];
this.#disconnect(player);
});
}
get override() {
return this.#userOverride;
}
set override(busName: string | undefined) {
this.#userOverride = busName;
this.#updateActive();
}
get(): Mpris.Player | undefined {
return this.#active.get();
}
subscribe(callback: (value: Mpris.Player | undefined) => void): () => void {
return this.#active.subscribe(callback);
}
}
const formatTime = (time: number) => {
const hours = Math.floor(time / 3600);
const minutes = Math.floor((time % 3600) / 60)
.toString()
.padStart(2, "0");
const seconds = Math.floor(time % 60)
.toString()
.padStart(2, "0");
return `${hours > 0 ? `${hours}:` : ""}${minutes}:${seconds}`;
};
interface MediaDropdownProps {
activePlayer: Binding<Mpris.Player | undefined>;
onOverride: (busName: string) => void;
}
function MediaDropdown({ activePlayer, onOverride }: MediaDropdownProps) {
const allPlayers = bind(mpris, "players");
return (
<box cssClasses={["MediaDropdown"]} vertical spacing={5}>
{activePlayer.as((player) => {
if (!player) {
return null;
}
return (
<>
<image
file={bind(player, "coverArt")}
visible={Boolean(bind(player, "coverArt"))}
cssClasses={["MediaCover"]}
pixelSize={220}
/>
<label
label={bind(player, "title")}
ellipsize={Pango.EllipsizeMode.END}
maxWidthChars={30}
justify={Gtk.Justification.CENTER}
lines={2}
visible={Boolean(bind(player, "title"))}
cssClasses={["MediaTitle"]}
/>
<label
label={bind(player, "artist")}
ellipsize={Pango.EllipsizeMode.END}
justify={Gtk.Justification.CENTER}
maxWidthChars={30}
visible={Boolean(bind(player, "artist"))}
cssClasses={["MediaArtist"]}
/>
<label
label={bind(player, "album")}
ellipsize={Pango.EllipsizeMode.END}
justify={Gtk.Justification.CENTER}
maxWidthChars={30}
wrap={true}
visible={Boolean(bind(player, "album"))}
cssClasses={["MediaAlbum"]}
/>
<slider
cssClasses={["Slider"]}
hexpand
min={0}
max={bind(player, "length")}
onChangeValue={({ value }) => {
player.position = value;
}}
value={bind(player, "position")}
/>
<centerbox hexpand>
<label
halign={Gtk.Align.START}
label={bind(player, "position").as(formatTime)}
/>
<box
cssClasses={["MediaControls"]}
spacing={10}
halign={Gtk.Align.CENTER}
>
<button onClicked={() => player.previous()}>
<image iconName="media-skip-backward-symbolic" />
</button>
<button onClicked={() => player.play_pause()}>
<image
iconName={bind(player, "playbackStatus").as((s) =>
s === Mpris.PlaybackStatus.PLAYING
? "media-playback-pause-symbolic"
: "media-playback-start-symbolic",
)}
/>
</button>
<button onClicked={() => player.next()}>
<image iconName="media-skip-forward-symbolic" />
</button>
</box>
<label
halign={Gtk.Align.END}
label={bind(player, "length").as(formatTime)}
/>
</centerbox>
</>
);
})}
<Separator visible={allPlayers.as((players) => players.length > 1)} />
<Expander
label={"Other media players"}
visible={allPlayers.as((players) => players.length > 1)}
>
<box vertical spacing={10} cssClasses={["MediaOther"]}>
{allPlayers.as((players) => {
return players.map((p) => (
<button
onClicked={() => onOverride(p.bus_name)}
cssClasses={activePlayer.as((player) => {
return p.bus_name === player?.bus_name ? ["active"] : [];
})}
>
<label label={p.identity} />
</button>
));
})}
</box>
</Expander>
</box>
);
}
interface MediaProps {
monitor: Gdk.Monitor;
}
export default function Media({ monitor }: MediaProps) {
const activeMedia = new ActiveMediaDetector();
const activePlayer = bind(activeMedia);
return (
<box
cssClasses={["Media"]}
spacing={10}
setup={(self) =>
connectDropdown(
self,
<MediaDropdown
activePlayer={activePlayer}
onOverride={(busName) => {
if (activeMedia.override === busName) {
activeMedia.override = undefined;
} else {
activeMedia.override = busName;
}
}}
/>,
monitor,
{ fullWidth: true },
)
}
visible={activePlayer.as(Boolean)}
>
{activePlayer.as((player) => {
if (!player) {
return;
}
const icon = bind(player, "entry").as((e) =>
hasIcon(e) ? e : "audio-x-generic-symbolic",
);
const marqueeOffset = Variable(0).poll(100, (offset) => {
return offset + 1;
});
// show marquee for the first and last 10 seconds of a song
const showMarquee = Variable.derive(
[
bind(player, "length"),
bind(player, "position"),
bind(player, "playbackStatus"),
],
(length, position, status) => {
if (status !== Mpris.PlaybackStatus.PLAYING) {
return false;
}
return position < 10 || length - position < 10;
},
);
showMarquee.subscribe((show) => {
if (show) {
marqueeOffset.poll(100, (offset) => {
return offset + 1;
});
} else {
marqueeOffset.stopPoll();
}
});
bind(player, "title").subscribe(() => marqueeOffset.set(0));
const marquee = Variable.derive(
[bind(player, "title"), bind(player, "artist"), bind(marqueeOffset)],
(title, artist, mo) => {
const line = `${title} - ${artist} `;
if (line.length <= MARQUEE_LENGTH) {
// center the text
return line
.padStart(20 + line.length / 2, " ")
.padEnd(MARQUEE_LENGTH, " ");
}
const offset = mo % line.length;
return (line + line).slice(offset, offset + MARQUEE_LENGTH);
},
);
return (
<>
<image iconName={icon} />
<stack
visibleChildName={bind(showMarquee).as((show) =>
show ? "marquee" : "progress",
)}
transitionType={Gtk.StackTransitionType.CROSSFADE}
transitionDuration={200}
>
<box name={"progress"} spacing={10}>
<label label={bind(player, "position").as(formatTime)} />
<slider
cssClasses={["Slider"]}
hexpand
min={0}
max={bind(player, "length")}
onChangeValue={({ value }) => {
const player = activePlayer.get();
if (player) {
player.position = value;
}
}}
value={bind(player, "position")}
/>
<label label={bind(player, "length").as(formatTime)} />
</box>
<label
name={"marquee"}
label={bind(marquee)}
ellipsize={Pango.EllipsizeMode.END}
widthChars={MARQUEE_LENGTH}
maxWidthChars={MARQUEE_LENGTH}
/>
</stack>
</>
);
})}
</box>
);
}

View file

@ -0,0 +1,28 @@
@use "../../variables.scss" as *;
@keyframes pulse {
0% {
background-color: $accent;
}
50% {
background-color: $bg-alt;
}
100% {
background-color: $accent;
}
}
.PlaybackDropdown {
.no-streams {
color: $muted;
font-size: 14px;
}
}
.Playback {
.recording {
background-color: $accent;
border-radius: $radius;
animation: pulse 1s 10;
}
}

View file

@ -0,0 +1,250 @@
import Wp from "gi://AstalWp";
import { Variable, bind } from "astal";
import type { Binding, Subscribable } from "astal/binding";
import { Gtk, type Gdk } from "astal/gtk4";
import { hasIcon } from "../../utils/gtk";
import { Expander, FlowBox, Separator } from "../../widgets";
import { connectDropdown } from "./Dropdown";
import Pango from "gi://Pango?version=1.0";
import { Box } from "astal/gtk4/widget";
interface PlaybackEndpointProps {
endpoint: Wp.Endpoint;
visible?: Binding<boolean>;
}
function PlaybackEndpoint({ endpoint, visible }: PlaybackEndpointProps) {
const name = Variable.derive(
[bind(endpoint, "description"), bind(endpoint, "name")],
(description, name) => name || description || "Unknown",
);
const defaultable = Variable.derive(
[bind(endpoint, "is_default"), bind(endpoint, "media_class")],
(isDefault, mediaClass) =>
!isDefault &&
[Wp.MediaClass.AUDIO_MICROPHONE, Wp.MediaClass.AUDIO_SPEAKER].includes(
mediaClass,
),
);
return (
<box
vertical
spacing={5}
hexpand
onDestroy={() => {
name.drop();
defaultable.drop();
}}
visible={visible}
>
<box spacing={10} hexpand>
<button
onButtonPressed={() => {
endpoint.set_mute(!endpoint.mute);
}}
>
<image iconName={bind(endpoint, "volumeIcon")} pixelSize={16} />
</button>
<label
label={bind(name)}
maxWidthChars={bind(defaultable).as((x) => (x ? 23 : 28))}
ellipsize={Pango.EllipsizeMode.END}
halign={Gtk.Align.START}
hexpand
/>
<button
visible={bind(defaultable)}
onButtonPressed={() => {
endpoint.set_is_default(true);
}}
>
<image iconName="star-filled" pixelSize={20} />
</button>
</box>
<box spacing={5} hexpand>
<slider
cssClasses={["Slider"]}
hexpand
value={bind(endpoint, "volume")}
onChangeValue={({ value }) => {
endpoint.set_volume(value);
}}
/>
<label
label={bind(endpoint, "volume").as((v) =>
`${Math.floor(v * 100)}%`.padStart(4, " "),
)}
/>
</box>
</box>
);
}
function PlaybackDropdown({ audioDevices }: { audioDevices: Wp.Audio }) {
return (
<box
spacing={10}
vertical
widthRequest={300}
cssClasses={["PlaybackDropdown"]}
>
<label label="Default Speaker" halign={Gtk.Align.START} />
{bind(audioDevices, "default_speaker").as((speaker) => {
return <PlaybackEndpoint endpoint={speaker} />;
})}
<Expander
label={"All Speakers"}
visible={bind(audioDevices, "speakers").as(
(speakers) => speakers.length > 1,
)}
>
<Box spacing={5} vertical marginTop={10}>
{bind(audioDevices, "speakers").as((speakers) => {
return speakers.map((speaker) => (
<PlaybackEndpoint
endpoint={speaker}
visible={bind(speaker, "is_default").as((x) => !x)}
/>
));
})}
</Box>
</Expander>
<Separator />
<label label="Playback streams" halign={Gtk.Align.START} />
{bind(audioDevices, "streams").as((streams) => {
if (streams.length === 0) {
return (
<label
label="No playback streams"
halign={Gtk.Align.START}
cssClasses={["no-streams"]}
/>
);
}
return streams.map((stream) => <PlaybackEndpoint endpoint={stream} />);
})}
<Separator />
<label label="Default Microphone" halign={Gtk.Align.START} />
{bind(audioDevices, "default_microphone").as((microphone) => {
return <PlaybackEndpoint endpoint={microphone} />;
})}
<Expander
label={"All Microphones"}
visible={bind(audioDevices, "microphones").as(
(microphones) => microphones.length > 1,
)}
>
<Box spacing={5} vertical marginTop={10}>
{bind(audioDevices, "microphones").as((microphones) => {
return microphones.map((microphone) => (
<PlaybackEndpoint
endpoint={microphone}
visible={bind(microphone, "is_default").as((x) => !x)}
/>
));
})}
</Box>
</Expander>
<Separator />
<label label="Recording streams" halign={Gtk.Align.START} />
{bind(audioDevices, "recorders").as((streams) => {
if (streams.length === 0) {
return (
<label
label="No recording streams"
halign={Gtk.Align.START}
cssClasses={["no-streams"]}
/>
);
}
return streams.map((stream) => <PlaybackEndpoint endpoint={stream} />);
})}
</box>
);
}
export function Playback({ monitor }: { monitor: Gdk.Monitor }) {
const audioDevices = Wp.get_default()?.get_audio?.();
if (!audioDevices) {
return <label label="No WirePlumber" visible={false} />;
}
// const endpoints = new PlaybackEndpoints(WirePlumber);
// biome-ignore lint/style/noNonNullAssertion: <explanation>
const speaker = Wp.get_default()?.audio.defaultSpeaker!;
const volume = Variable.derive(
[bind(speaker, "volume"), bind(speaker, "mute")],
(v, m) => {
return m ? 0 : v;
},
);
const recording = bind(audioDevices, "recorders").as(
(recorders) => recorders.length > 0,
);
return (
<box
cssClasses={["Playback"]}
spacing={15}
onDestroy={() => {
volume.drop();
}}
setup={(self) => {
connectDropdown(
self,
<PlaybackDropdown audioDevices={audioDevices} />,
monitor,
);
}}
>
{bind(audioDevices, "default_speaker").as((speaker) => {
const volume = Variable.derive(
[bind(speaker, "volume"), bind(speaker, "mute")],
(v, m) => {
return m ? 0 : v;
},
);
return (
<>
<slider
cssClasses={["Slider"]}
inverted
hexpand
onChangeValue={({ value }) => {
speaker.volume = value;
speaker.mute = false;
}}
value={bind(volume)}
/>
<box
spacing={10}
onButtonPressed={() => {
speaker.mute = !speaker.mute;
}}
>
<image iconName={bind(speaker, "volumeIcon")} pixelSize={12} />
<label
label={bind(volume).as((v) =>
`${Math.floor(v * 100)}%`.padStart(4, " "),
)}
/>
<image
iconName={"microphone-custom"}
cssClasses={["recording"]}
pixelSize={16}
widthRequest={18}
hexpand
visible={bind(recording)}
/>
</box>
</>
);
})}
</box>
);
}

View file

@ -0,0 +1,62 @@
@use "../../variables.scss" as *;
.workspace {
all: unset; // unset default button styles
&:hover {
all: unset;
}
transition: opacity $transition;
opacity: 0.2;
&.focused {
opacity: 1;
}
&.add-left {
padding-left: 10px;
}
&.add-right {
padding-right: 10px;
}
}
calendar {
header {
padding-bottom: 10px;
border-bottom: 1px solid $bg-alt;
}
button {
margin-left: 2px;
margin-right: 2px;
padding: 3px;
}
grid {
padding-top: 10px;
label {
padding: 3px;
}
.today {
background-color: $accent;
color: $bg;
border-radius: 3px;
padding: 0;
margin: 3px;
}
.other-month {
color: $muted;
}
.day-name {
border-bottom: 1px solid $bg-alt;
}
.week-number {
border-right: 1px solid $bg-alt;
}
}
}

View file

@ -0,0 +1,102 @@
import Hyprland from "gi://AstalHyprland";
import { bind } from "astal";
import { Gdk, Gtk } from "astal/gtk4";
type WorkspacesProps = {
monitor: Hyprland.Monitor;
reverse?: boolean;
selectedWorkspaces?: number[];
};
const hypr = Hyprland.get_default();
const ICON_MAP = {
terminal: ["kitty", "com.mitchellh.ghostty"],
"firefox-custom": ["firefox", "firefox-developer-edition"],
"chrome-custom": ["google-chrome", "chromium"],
python: ["jetbrains-pycharm"],
"vscode-custom": ["Code", "code-oss"],
"git-symbolic": ["smerge", "sublime_merge"],
};
function Workspace(workspace: Hyprland.Workspace) {
const focused = bind(hypr, "focusedWorkspace").as((fw) => fw === workspace);
const icon = bind(workspace, "clients").as((clients) => {
if (clients.length === 0) {
return "circle";
}
const icons = clients
.map((client) => {
for (const [name, classes] of Object.entries(ICON_MAP)) {
if (classes.includes(client.get_class())) {
return name;
}
}
})
.filter(Boolean) as string[];
const count = icons.reduce(
(acc, cur) => {
acc[cur] = (acc[cur] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
// Don't return on a tie
if (
Object.values(count).filter((x) => x === count[Object.keys(count)[0]])
.length > 1
) {
return "circle-filled";
}
return Object.keys(count)[0] ?? "circle-filled";
});
return (
<button
cssClasses={focused.as((focused) =>
focused ? ["workspace", "focused"] : ["workspace"],
)}
cursor={Gdk.Cursor.new_from_name("pointer", null)}
onClicked={() => workspace.focus()}
valign={Gtk.Align.CENTER}
>
<image
iconName={bind(icon).as((icon) => `${icon}-symbolic`)}
pixelSize={18}
/>
</button>
);
}
export function Workspaces({
reverse,
monitor,
selectedWorkspaces,
}: WorkspacesProps) {
const workspaces = bind(hypr, "workspaces")
.as((workspaces) => workspaces.filter((ws) => ws.monitor === monitor))
.as((workspaces) => {
if (!selectedWorkspaces) {
return workspaces;
}
return selectedWorkspaces.map((id) => {
return (
workspaces.find((ws) => ws.get_id() === id) ??
Hyprland.Workspace.dummy(id, monitor)
);
});
})
.as((x) => (reverse ? x.reverse() : x));
return (
<box cssClasses={["Workspaces"]} valign={Gtk.Align.CENTER} spacing={10}>
{workspaces.as((ws) => ws.map(Workspace))}
</box>
);
}