mirror of
https://github.com/nickolaj-jepsen/nixos.git
synced 2026-01-22 16:16:50 +01:00
update
This commit is contained in:
parent
2b7b63a18c
commit
638ef7093e
140 changed files with 307 additions and 121 deletions
91
modules/desktop/astal/src/bar/Bar.scss
Normal file
91
modules/desktop/astal/src/bar/Bar.scss
Normal 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;
|
||||
}
|
||||
122
modules/desktop/astal/src/bar/Bar.tsx
Normal file
122
modules/desktop/astal/src/bar/Bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
modules/desktop/astal/src/bar/SecondaryBar.tsx
Normal file
106
modules/desktop/astal/src/bar/SecondaryBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
modules/desktop/astal/src/bar/sections/Dropdown.scss
Normal file
10
modules/desktop/astal/src/bar/sections/Dropdown.scss
Normal 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;
|
||||
}
|
||||
133
modules/desktop/astal/src/bar/sections/Dropdown.tsx
Normal file
133
modules/desktop/astal/src/bar/sections/Dropdown.tsx
Normal 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);
|
||||
});
|
||||
}
|
||||
37
modules/desktop/astal/src/bar/sections/Media.scss
Normal file
37
modules/desktop/astal/src/bar/sections/Media.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
387
modules/desktop/astal/src/bar/sections/Media.tsx
Normal file
387
modules/desktop/astal/src/bar/sections/Media.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
modules/desktop/astal/src/bar/sections/Playback.scss
Normal file
28
modules/desktop/astal/src/bar/sections/Playback.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
250
modules/desktop/astal/src/bar/sections/Playback.tsx
Normal file
250
modules/desktop/astal/src/bar/sections/Playback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
modules/desktop/astal/src/bar/sections/Workspace.scss
Normal file
62
modules/desktop/astal/src/bar/sections/Workspace.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
102
modules/desktop/astal/src/bar/sections/Workspace.tsx
Normal file
102
modules/desktop/astal/src/bar/sections/Workspace.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue