mirror of
https://github.com/nickolaj-jepsen/nixos.git
synced 2026-01-23 00:26:48 +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>
|
||||
);
|
||||
}
|
||||
42
modules/desktop/astal/src/config.ts
Normal file
42
modules/desktop/astal/src/config.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import GLib from "gi://GLib";
|
||||
|
||||
type ignoreFn = (test: string) => boolean;
|
||||
|
||||
interface Config {
|
||||
monitor: {
|
||||
main: string;
|
||||
};
|
||||
notification: {
|
||||
ignore: ignoreFn[];
|
||||
};
|
||||
tray: {
|
||||
ignore: ignoreFn[];
|
||||
};
|
||||
}
|
||||
|
||||
const envArray = (name: string): string[] => {
|
||||
const value = GLib.getenv(name);
|
||||
if (!value) return [];
|
||||
return value.split(",");
|
||||
};
|
||||
|
||||
const envIgnoreArray = (name: string): ignoreFn[] => {
|
||||
return envArray(name).map((r: string) => {
|
||||
if (r.startsWith("/")) {
|
||||
return new RegExp(r.slice(1, -1)).test;
|
||||
}
|
||||
return (test: string) => test === r;
|
||||
});
|
||||
};
|
||||
|
||||
export default {
|
||||
monitor: {
|
||||
main: GLib.getenv("ASTRAL_PRIMARY_MONITOR") || "",
|
||||
},
|
||||
notification: {
|
||||
ignore: envIgnoreArray("ASTRAL_NOTIFICATION_IGNORE"),
|
||||
},
|
||||
tray: {
|
||||
ignore: envIgnoreArray("ASTRAL_TRAY_IGNORE"),
|
||||
},
|
||||
} as Config;
|
||||
51
modules/desktop/astal/src/main.scss
Normal file
51
modules/desktop/astal/src/main.scss
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
@use "./variables" as *;
|
||||
@use "./bar/Bar.scss" as *;
|
||||
@use "./notification/Notification.scss" as *;
|
||||
@use "./bar/sections/Workspace.scss" as *;
|
||||
@use "./bar/sections/Dropdown.scss" as *;
|
||||
@use "./bar/sections/Media.scss" as *;
|
||||
@use "./bar/sections/Playback.scss" as *;
|
||||
|
||||
// Global styles
|
||||
|
||||
* {
|
||||
all: unset; //Unsets everything so you can style everything from scratch
|
||||
font-family: Hack;
|
||||
}
|
||||
|
||||
separator {
|
||||
background-color: $bg-alt;
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
button {
|
||||
transition: border-color $transition;
|
||||
background-color: $bg-alt;
|
||||
border-radius: $radius;
|
||||
border: 2px solid $bg-alt;
|
||||
padding: 5px;
|
||||
|
||||
&.small {
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
&.large {
|
||||
padding: 7px 15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border: 2px solid $accent;
|
||||
}
|
||||
}
|
||||
|
||||
expander-widget {
|
||||
title {
|
||||
color: $fg;
|
||||
}
|
||||
expander {
|
||||
-gtk-icon-source: -gtk-icontheme("pan-end-symbolic");
|
||||
&:checked {
|
||||
-gtk-icon-source: -gtk-icontheme("pan-down-symbolic");
|
||||
}
|
||||
}
|
||||
}
|
||||
32
modules/desktop/astal/src/main.ts
Normal file
32
modules/desktop/astal/src/main.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import Bar from "./bar/Bar";
|
||||
import SecondaryBar from "./bar/SecondaryBar";
|
||||
import NotificationPopups from "./notification/NotificationPopups";
|
||||
import { getMonitors } from "./utils/monitors";
|
||||
|
||||
export default function main() {
|
||||
const { main, secondary } = getMonitors();
|
||||
|
||||
// Notify
|
||||
NotificationPopups(main);
|
||||
|
||||
// Set bars
|
||||
Bar(main);
|
||||
for (const monitor of secondary) {
|
||||
SecondaryBar(monitor, monitor.relation);
|
||||
}
|
||||
// const bars = new Map<Gdk.Monitor, Gtk.Widget>()
|
||||
|
||||
// bars.set(main, Bar(main))
|
||||
// for (const monitor of secondary) {
|
||||
// bars.set(monitor, SecondaryBar(monitor, monitor.relation))
|
||||
// }
|
||||
|
||||
// App.connect("monitor-added", (_, gdkmonitor) => {
|
||||
// bars.set(gdkmonitor, Bar(gdkmonitor))
|
||||
// })
|
||||
|
||||
// App.connect("monitor-removed", (_, gdkmonitor) => {
|
||||
// bars.get(gdkmonitor)?.destroy()
|
||||
// bars.delete(gdkmonitor)
|
||||
// })
|
||||
}
|
||||
47
modules/desktop/astal/src/notification/Notification.scss
Normal file
47
modules/desktop/astal/src/notification/Notification.scss
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
@use "../variables.scss" as *;
|
||||
|
||||
.Notification {
|
||||
border-radius: $radius;
|
||||
background-color: $bg;
|
||||
margin: 10px;
|
||||
padding: 15px;
|
||||
border: 2px solid $accent;
|
||||
|
||||
&.low {
|
||||
border: 2px solid $muted;
|
||||
.app-name {
|
||||
color: $muted;
|
||||
}
|
||||
}
|
||||
|
||||
&.critical {
|
||||
border: 2px solid $error;
|
||||
.app-name {
|
||||
color: $error;
|
||||
}
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.content {
|
||||
.body {
|
||||
color: $fg-alt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.NotificationMenu {
|
||||
border-radius: $radius;
|
||||
background-color: $bg;
|
||||
margin: 10px;
|
||||
padding: 15px;
|
||||
border: 2px solid $accent;
|
||||
|
||||
button.disabled {
|
||||
background-color: $muted;
|
||||
border-color: $muted;
|
||||
color: $fg;
|
||||
}
|
||||
}
|
||||
117
modules/desktop/astal/src/notification/Notification.tsx
Normal file
117
modules/desktop/astal/src/notification/Notification.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import Notifd from "gi://AstalNotifd";
|
||||
import { GLib, timeout } from "astal";
|
||||
import { type Astal, Gtk } from "astal/gtk4";
|
||||
import { hook } from "astal/gtk4";
|
||||
import { Separator } from "../widgets";
|
||||
|
||||
const ANIMATION_DURATION = 500;
|
||||
|
||||
const fileExists = (path: string) => GLib.file_test(path, GLib.FileTest.EXISTS);
|
||||
const time = (time: number, format = "%H:%M") =>
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
GLib.DateTime.new_from_unix_local(time).format(format)!;
|
||||
|
||||
const urgency = (urgency: Notifd.Urgency) => {
|
||||
if (urgency === Notifd.Urgency.LOW) return "low";
|
||||
if (urgency === Notifd.Urgency.CRITICAL) return "critical";
|
||||
return "normal";
|
||||
};
|
||||
|
||||
type Props = {
|
||||
notification: Notifd.Notification;
|
||||
};
|
||||
|
||||
const resolveImageProps = (image?: string) => {
|
||||
if (!image) {
|
||||
return { visible: false };
|
||||
}
|
||||
if (fileExists(image)) {
|
||||
return { file: image };
|
||||
}
|
||||
return { iconName: image };
|
||||
};
|
||||
|
||||
export default function Notification({ notification: n }: Props) {
|
||||
const icon = n.appIcon || n.desktopEntry;
|
||||
return (
|
||||
<box
|
||||
cssClasses={["Notification", urgency(n.urgency)]}
|
||||
onButtonReleased={(_, asdf) => {
|
||||
n.dismiss();
|
||||
}}
|
||||
vertical
|
||||
widthRequest={300}
|
||||
spacing={10}
|
||||
>
|
||||
<box cssClasses={["header"]} spacing={10}>
|
||||
<image cssClasses={["app-icon"]} {...resolveImageProps(icon)} />
|
||||
<label
|
||||
cssClasses={["app-name"]}
|
||||
maxWidthChars={40}
|
||||
wrap
|
||||
label={n.appName || "Unknown"}
|
||||
hexpand
|
||||
halign={Gtk.Align.START}
|
||||
/>
|
||||
<label
|
||||
cssClasses={["time"]}
|
||||
halign={Gtk.Align.END}
|
||||
label={time(n.time)}
|
||||
/>
|
||||
</box>
|
||||
<Separator />
|
||||
<box cssClasses={["content"]} spacing={10}>
|
||||
<image
|
||||
{...resolveImageProps(
|
||||
n.summary === "message"
|
||||
? "/home/nickolaj/Downloads/billigvvs.dk.png"
|
||||
: n.image,
|
||||
)}
|
||||
pixelSize={160}
|
||||
halign={Gtk.Align.START}
|
||||
valign={Gtk.Align.START}
|
||||
/>
|
||||
<box vertical spacing={10}>
|
||||
<label
|
||||
hexpand
|
||||
cssClasses={["summary"]}
|
||||
label={n.summary}
|
||||
wrap
|
||||
halign={Gtk.Align.START}
|
||||
maxWidthChars={n.image ? 40 : 60}
|
||||
/>
|
||||
{n.body && (
|
||||
<label
|
||||
hexpand
|
||||
cssClasses={["body"]}
|
||||
useMarkup
|
||||
label={n.body}
|
||||
halign={Gtk.Align.START}
|
||||
maxWidthChars={n.summary === "message" ? 40 : 60}
|
||||
wrap
|
||||
/>
|
||||
)}
|
||||
{n.get_actions().length > 0 && (
|
||||
<box
|
||||
cssClasses={["actions"]}
|
||||
halign={Gtk.Align.CENTER}
|
||||
spacing={10}
|
||||
vertical={n.get_actions().length > 2}
|
||||
>
|
||||
{n.get_actions().map(({ label, id }) => (
|
||||
<button
|
||||
onClicked={(eve) => {
|
||||
n.invoke(id);
|
||||
}}
|
||||
cssClasses={["large"]}
|
||||
>
|
||||
<label label={label} maxWidthChars={20} wrap />
|
||||
</button>
|
||||
))}
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
109
modules/desktop/astal/src/notification/NotificationPopups.tsx
Normal file
109
modules/desktop/astal/src/notification/NotificationPopups.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import Notifd from "gi://AstalNotifd";
|
||||
import { Variable, bind, interval, timeout } from "astal";
|
||||
import { App, hook } from "astal/gtk4";
|
||||
import { Astal, type Gdk, Gtk } from "astal/gtk4";
|
||||
import config from "../config";
|
||||
import { VarMap } from "../utils/var-map";
|
||||
import Notification from "./Notification";
|
||||
|
||||
class NotificationMap extends VarMap<number, Gtk.Widget> {
|
||||
#notifd = Notifd.get_default();
|
||||
|
||||
get() {
|
||||
return [...this.map.entries()].sort(([a], [b]) => b - a).map(([_, v]) => v);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.#notifd.connect("notified", (_, id) => {
|
||||
const notification = this.#notifd.get_notification(id);
|
||||
if (notification === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore notifications based on the app name
|
||||
for (const test of config.notification.ignore) {
|
||||
if (test(notification.app_name)) {
|
||||
notification.dismiss();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.set(id, Notification({ notification }));
|
||||
});
|
||||
|
||||
// notifications can be closed by the outside before
|
||||
// any user input, which have to be handled too
|
||||
this.#notifd.connect("resolved", (_, id) => {
|
||||
this.delete(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function NotificationPopups(gdkmonitor: Gdk.Monitor) {
|
||||
const { TOP, RIGHT } = Astal.WindowAnchor;
|
||||
const notificationsMap = new NotificationMap();
|
||||
const offset = new Variable(0);
|
||||
const count = bind(notificationsMap).as((map) => map.length);
|
||||
const offsetNotifications = Variable.derive(
|
||||
[notificationsMap, offset],
|
||||
(map, offset) => map.slice(offset),
|
||||
);
|
||||
const offsetLength = bind(offsetNotifications).as((map) => map.length);
|
||||
|
||||
return (
|
||||
<window
|
||||
name={"notifications"}
|
||||
application={App}
|
||||
cssClasses={["NotificationPopups"]}
|
||||
gdkmonitor={gdkmonitor}
|
||||
layer={Astal.Layer.OVERLAY}
|
||||
exclusivity={Astal.Exclusivity.EXCLUSIVE}
|
||||
anchor={TOP | RIGHT}
|
||||
visible={count.as((n) => n > 0)}
|
||||
vexpand={true}
|
||||
valign={Gtk.Align.START}
|
||||
>
|
||||
<box vertical={true} halign={Gtk.Align.END}>
|
||||
{bind(offsetNotifications).as((map) => map.slice(0, 5))}
|
||||
<box
|
||||
cssClasses={["NotificationMenu"]}
|
||||
visible={count.as((n) => n > 2)}
|
||||
halign={Gtk.Align.END}
|
||||
spacing={10}
|
||||
>
|
||||
<box
|
||||
visible={count.as((n) => n > 5)}
|
||||
vertical
|
||||
spacing={10}
|
||||
widthRequest={50}
|
||||
>
|
||||
{bind(offset).as((n) => (
|
||||
<button
|
||||
hexpand
|
||||
onClicked={() => offset.set(Math.max(offset.get() - 5, 0))}
|
||||
cssClasses={n > 0 ? [] : ["disabled"]}
|
||||
label={n > 0 ? `▲ ${n}` : "▲ 0"}
|
||||
/>
|
||||
))}
|
||||
{offsetLength.as((n) => (
|
||||
<button
|
||||
hexpand
|
||||
onClicked={() =>
|
||||
offset.set(Math.min(offset.get() + 5, count.get() - 5))
|
||||
}
|
||||
cssClasses={n > 5 ? [] : ["disabled"]}
|
||||
label={n > 5 ? `▼ ${n - 5}` : "▼ 0"}
|
||||
/>
|
||||
))}
|
||||
</box>
|
||||
<button
|
||||
cssClasses={["large"]}
|
||||
onClicked={() => notificationsMap.clear()}
|
||||
label={count.as((n) => `Dismiss all (${n})`)}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
</window>
|
||||
);
|
||||
}
|
||||
13
modules/desktop/astal/src/utils/gtk.ts
Normal file
13
modules/desktop/astal/src/utils/gtk.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Gdk, Gtk } from "astal/gtk4";
|
||||
|
||||
export const hasIcon = (name: string): boolean => {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
const display = Gdk.Display.get_default();
|
||||
if (!display) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Gtk.IconTheme.get_for_display(display).has_icon(name);
|
||||
};
|
||||
59
modules/desktop/astal/src/utils/io.ts
Normal file
59
modules/desktop/astal/src/utils/io.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { GLib, Gio } from "astal";
|
||||
|
||||
interface WalkDirOptions {
|
||||
pattern: string;
|
||||
}
|
||||
|
||||
export const findFiles = async (
|
||||
path: string | Gio.File,
|
||||
options?: WalkDirOptions,
|
||||
): Promise<string[]> => {
|
||||
const patternSpec = options?.pattern
|
||||
? GLib.PatternSpec.new(options.pattern)
|
||||
: null;
|
||||
|
||||
// const src = Gio.File.new_for_path(path);
|
||||
const src = typeof path === "string" ? Gio.File.new_for_path(path) : path;
|
||||
const root = Gio.File.new_for_path(".");
|
||||
|
||||
const iter = src.enumerate_children(
|
||||
"standard::*",
|
||||
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
|
||||
null,
|
||||
);
|
||||
|
||||
const result = [];
|
||||
while (true) {
|
||||
const [open, info, file] = iter.iterate(null);
|
||||
if (!open || !info) {
|
||||
break; // end of iterator
|
||||
}
|
||||
if (!file) {
|
||||
throw new Error("Failed to get file");
|
||||
}
|
||||
|
||||
const file_type = info.get_file_type();
|
||||
|
||||
if (file_type === Gio.FileType.DIRECTORY) {
|
||||
result.push(...(await findFiles(file, options)));
|
||||
}
|
||||
|
||||
if (info.get_file_type() !== Gio.FileType.REGULAR) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = info.get_name();
|
||||
const path = root.get_relative_path(file);
|
||||
|
||||
if (!path) {
|
||||
throw new Error("Failed to get relative path");
|
||||
}
|
||||
|
||||
if (patternSpec && !patternSpec.match_string(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(`./${path}`);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
53
modules/desktop/astal/src/utils/monitors.ts
Normal file
53
modules/desktop/astal/src/utils/monitors.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import Hyprland from "gi://AstalHyprland";
|
||||
import { App, type Gdk } from "astal/gtk4";
|
||||
import config from "../config";
|
||||
|
||||
export type SecondaryMonitor = Gdk.Monitor & {
|
||||
relation: "left" | "right" | "top" | "bottom";
|
||||
};
|
||||
|
||||
export const getMonitors = (): {
|
||||
main: Gdk.Monitor;
|
||||
secondary: SecondaryMonitor[];
|
||||
} => {
|
||||
const scanFn = [
|
||||
// Monitor in config
|
||||
(monitor: Gdk.Monitor) => config.monitor.main === monitor.get_connector(),
|
||||
// First monitor
|
||||
() => true,
|
||||
];
|
||||
|
||||
const monitors = App.get_monitors();
|
||||
console.log("config.monitor.main", config.monitor.main);
|
||||
const main =
|
||||
scanFn.map((fn) => monitors.find(fn)).find((m) => m) || monitors[0];
|
||||
const secondary = monitors
|
||||
.filter((m) => m !== main)
|
||||
.map((m) => {
|
||||
const monitor = m as SecondaryMonitor;
|
||||
const { x: mx, y: my } = main.get_geometry();
|
||||
const { x, y } = m.get_geometry();
|
||||
|
||||
const verticalDiff = Math.abs(y - my);
|
||||
const horizontalDiff = Math.abs(x - mx);
|
||||
|
||||
if (verticalDiff > horizontalDiff) {
|
||||
monitor.relation = y < my ? "top" : "bottom";
|
||||
} else {
|
||||
monitor.relation = x < mx ? "left" : "right";
|
||||
}
|
||||
|
||||
return monitor;
|
||||
});
|
||||
return { main, secondary };
|
||||
};
|
||||
|
||||
export function getHyprlandMonitor(monitor: Gdk.Monitor): Hyprland.Monitor {
|
||||
const hyprland = Hyprland.get_default();
|
||||
const monitors = hyprland.get_monitors();
|
||||
for (const hyprmonitor of monitors) {
|
||||
if (hyprmonitor.get_name() === monitor.get_connector()) return hyprmonitor;
|
||||
}
|
||||
|
||||
throw new Error("GDK monitor does not map to a Hyprland monitor");
|
||||
}
|
||||
24
modules/desktop/astal/src/utils/timeout.ts
Normal file
24
modules/desktop/astal/src/utils/timeout.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { GLib, type Time, timeout } from "astal";
|
||||
|
||||
const runningTimeouts: Map<string, Time> = new Map();
|
||||
export const cancelTimeout = (id: string) => {
|
||||
const runningTimeout = runningTimeouts.get(id);
|
||||
if (runningTimeout) {
|
||||
runningTimeout.cancel();
|
||||
runningTimeouts.delete(id);
|
||||
}
|
||||
};
|
||||
export const cancelableTimeout = (
|
||||
callback: () => void,
|
||||
id: string,
|
||||
delay: number,
|
||||
) => {
|
||||
cancelTimeout(id);
|
||||
runningTimeouts.set(
|
||||
id,
|
||||
timeout(delay, () => {
|
||||
callback();
|
||||
runningTimeouts.delete(id);
|
||||
}),
|
||||
);
|
||||
};
|
||||
55
modules/desktop/astal/src/utils/var-map.ts
Normal file
55
modules/desktop/astal/src/utils/var-map.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { Subscribable } from "astal/binding";
|
||||
import { Gtk } from "astal/gtk4";
|
||||
|
||||
export class VarMap<K, T = Gtk.Widget> implements Subscribable {
|
||||
#subs = new Set<(v: T[]) => void>();
|
||||
protected map: Map<K, T>;
|
||||
|
||||
#notifiy() {
|
||||
const value = this.get();
|
||||
for (const sub of this.#subs) {
|
||||
sub(value);
|
||||
}
|
||||
}
|
||||
|
||||
#delete(key: K) {
|
||||
const v = this.map.get(key);
|
||||
|
||||
if (v instanceof Gtk.Widget) {
|
||||
v.unparent();
|
||||
}
|
||||
|
||||
this.map.delete(key);
|
||||
}
|
||||
|
||||
constructor(initial?: Iterable<[K, T]>) {
|
||||
this.map = new Map(initial);
|
||||
}
|
||||
|
||||
clear() {
|
||||
for (const key of this.map.keys()) {
|
||||
this.#delete(key);
|
||||
}
|
||||
this.#notifiy();
|
||||
}
|
||||
|
||||
set(key: K, value: T) {
|
||||
this.#delete(key);
|
||||
this.map.set(key, value);
|
||||
this.#notifiy();
|
||||
}
|
||||
|
||||
delete(key: K) {
|
||||
this.#delete(key);
|
||||
this.#notifiy();
|
||||
}
|
||||
|
||||
get() {
|
||||
return [...this.map.values()];
|
||||
}
|
||||
|
||||
subscribe(callback: (v: T[]) => void) {
|
||||
this.#subs.add(callback);
|
||||
return () => this.#subs.delete(callback);
|
||||
}
|
||||
}
|
||||
13
modules/desktop/astal/src/variables.scss
Normal file
13
modules/desktop/astal/src/variables.scss
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
$bg: #1c1b1a;
|
||||
$bg-alt: #282726;
|
||||
$fg: #dad8ce;
|
||||
$fg-alt: #b7b5ac;
|
||||
$muted: #878580;
|
||||
$accent: #cf6a4c;
|
||||
$error: #d14d41;
|
||||
|
||||
$radius: 8px;
|
||||
$transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
$font-large: 14px;
|
||||
$icon-size: 12px;
|
||||
90
modules/desktop/astal/src/widgets.ts
Normal file
90
modules/desktop/astal/src/widgets.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { register } from "astal";
|
||||
import { Gtk, astalify } from "astal/gtk4";
|
||||
|
||||
export const Separator = astalify<
|
||||
Gtk.Separator,
|
||||
Gtk.Separator.ConstructorProps
|
||||
>(Gtk.Separator, {});
|
||||
|
||||
export const Expander = astalify<Gtk.Expander, Gtk.Expander.ConstructorProps>(
|
||||
Gtk.Expander,
|
||||
{
|
||||
getChildren(self) {
|
||||
const child = self.get_child();
|
||||
if (child) return [child];
|
||||
return [];
|
||||
},
|
||||
|
||||
setChildren(self, children) {
|
||||
if (children.length === 0) self.set_child(null);
|
||||
if (children.length > 1) {
|
||||
console.error("Expander can only have one child.");
|
||||
return;
|
||||
}
|
||||
self.set_child(children[0]);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const Calendar = astalify<Gtk.Calendar, Gtk.Calendar.ConstructorProps>(
|
||||
Gtk.Calendar,
|
||||
{
|
||||
getChildren(self) {
|
||||
return [];
|
||||
},
|
||||
|
||||
setChildren(self, children) {},
|
||||
},
|
||||
);
|
||||
|
||||
export const ScrolledWindow = astalify<
|
||||
Gtk.ScrolledWindow,
|
||||
Gtk.ScrolledWindow.ConstructorProps
|
||||
>(Gtk.ScrolledWindow, {
|
||||
getChildren(self) {
|
||||
const child = self.get_child();
|
||||
if (child) return [child];
|
||||
return [];
|
||||
},
|
||||
|
||||
setChildren(self, children) {
|
||||
if (children.length === 0) self.set_child(null);
|
||||
if (children.length > 1) {
|
||||
console.error("ScrolledWindow can only have one child.");
|
||||
return;
|
||||
}
|
||||
self.set_child(children[0]);
|
||||
},
|
||||
});
|
||||
|
||||
export const Viewport = astalify<Gtk.Viewport, Gtk.Viewport.ConstructorProps>(
|
||||
Gtk.Viewport,
|
||||
{
|
||||
getChildren(self) {
|
||||
const child = self.get_child();
|
||||
if (child) return [child];
|
||||
return [];
|
||||
},
|
||||
|
||||
setChildren(self, children) {
|
||||
if (children.length === 0) self.set_child(null);
|
||||
if (children.length > 1) {
|
||||
console.error("Viewport can only have one child.");
|
||||
return;
|
||||
}
|
||||
self.set_child(children[0]);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const FlowBox = astalify<Gtk.FlowBox, Gtk.FlowBox.ConstructorProps>(
|
||||
Gtk.FlowBox,
|
||||
{
|
||||
setChildren(self, children) {
|
||||
self.remove_all();
|
||||
for (const child of children) {
|
||||
self.append(child);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue