complete rework

This commit is contained in:
Nickolaj Jepsen 2025-01-31 15:13:22 +01:00
parent d68c699a7a
commit 16813aeef9
89 changed files with 2888 additions and 1658 deletions

46
modules/astal/default.nix Normal file
View file

@ -0,0 +1,46 @@
{
inputs,
pkgs,
...
}: let
packageName = "astal";
package = inputs.ags.lib.bundle {
inherit pkgs;
src = ./src;
name = packageName;
gtk4 = true;
entry = "app.ts";
extraPackages = with inputs.ags.packages.${pkgs.system}; [
battery
bluetooth
hyprland
network
tray
notifd
mpris
wireplumber
];
};
in {
user.home-manager = {
systemd.user.services.astal = {
Unit = {
Description = "Astal";
Documentation = "https://github.com/Aylur/astal";
After = ["graphical-session.target"];
};
Service = {
ExecStart = "${package}/bin/${packageName}";
Restart = "on-failure";
KillMode = "mixed";
Slice = "app-graphical.slice";
};
Install = {
WantedBy = ["graphical-session.target"];
};
};
};
}

3
modules/astal/src/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
@girs/
.idea/

11
modules/astal/src/app.ts Normal file
View file

@ -0,0 +1,11 @@
import { App } from "astal/gtk4";
import main from "./src/main";
import css from "./src/main.scss";
App.start({
css,
icons: "./icons",
main: () => {
main();
},
});

View file

@ -0,0 +1,37 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": ["@girs/**"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"a11y": {
"all": false
},
"correctness": {
"useJsxKeyInIterable": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
}
}

21
modules/astal/src/env.d.ts vendored Normal file
View file

@ -0,0 +1,21 @@
declare const SRC: string;
declare module "inline:*" {
const content: string;
export default content;
}
declare module "*.scss" {
const content: string;
export default content;
}
declare module "*.blp" {
const content: string;
export default content;
}
declare module "*.css" {
const content: string;
export default content;
}

View file

@ -0,0 +1,2 @@
Icons from https://glyphs.fyi with manually set stroke-width @ ~10 and color @ #000000, common iconnames might need to be renamed to avoid conflicts.
```

View file

@ -0,0 +1,6 @@
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24.3806 39.2231C24.7855 30.9472 31.6236 24.3617 40 24.3617H71.3205C65.579 12.8849 53.715 5.00386 40.0099 5H39.9898C33.8575 5.00173 28.0937 6.58059 23.0821 9.35299L21.9742 9.99265C18.2855 12.2133 15.0397 15.0948 12.4008 18.4734L24.3806 39.2231Z" fill="#ffffff" />
<path d="M48.4853 26.8617C52.7897 29.6475 55.6383 34.4911 55.6383 40C55.6383 42.7418 54.9326 45.3188 53.6931 47.5594L37.8866 74.9373L37.8502 75H40C59.3299 75 75 59.33 75 40C75 35.3534 74.0946 30.9184 72.4505 26.8617H48.4853Z" fill="#ffffff" />
<path d="M47.1343 53.9199C44.9952 55.0184 42.57 55.6383 40 55.6383C34.3092 55.6383 29.3283 52.5985 26.5925 48.054L10.7987 20.6985C7.13411 26.2315 5 32.8666 5 40C5 52.7369 11.8035 63.8846 21.9746 70.0075C22.3394 70.2272 22.7085 70.4403 23.0819 70.6469C26.7526 72.6775 30.8267 74.0678 35.1536 74.6672L35.7236 73.6838L47.1343 53.9199Z" fill="#ffffff" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M40 52.6596C46.9917 52.6596 52.6596 46.9917 52.6596 40C52.6596 33.0083 46.9917 27.3404 40 27.3404C33.0084 27.3404 27.3405 33.0083 27.3405 40C27.3405 46.9917 33.0084 52.6596 40 52.6596Z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,3 @@
<svg width="80" height="80" viewBox="0 0 80 80" fill="#ffffff" xmlns="http://www.w3.org/2000/svg">
<path d="M68 40C68 55.464 55.464 68 40 68C24.536 68 12 55.464 12 40C12 24.536 24.536 12 40 12C55.464 12 68 24.536 68 40Z" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="10" />
</svg>

After

Width:  |  Height:  |  Size: 316 B

View file

@ -0,0 +1,3 @@
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M68 40C68 55.464 55.464 68 40 68C24.536 68 12 55.464 12 40C12 24.536 24.536 12 40 12C55.464 12 68 24.536 68 40Z" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="10" />
</svg>

After

Width:  |  Height:  |  Size: 311 B

View file

@ -0,0 +1,4 @@
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M67.9528 18.6014C67.0154 17.3863 65.9979 16.2339 64.9066 15.152C64.0401 14.2448 63.1115 13.3979 62.1273 12.6173C62.6936 13.1056 63.2336 13.6232 63.7448 14.1679C65.6598 16.1914 67.1159 18.5967 68.0158 21.2227C69.8433 26.7052 69.7243 33.5669 66.2345 38.956C64.297 41.9757 61.591 44.4374 58.3886 46.094C55.1861 47.7507 51.5999 48.5439 47.9898 48.3938C47.6741 48.3938 47.3557 48.3938 47.0355 48.3704C33.8997 47.5908 30.5886 34.2503 37.4228 28.4864C35.5794 28.0939 32.1168 28.8632 29.7059 32.4321C27.5424 35.6372 27.6648 40.5835 28.9999 44.0928C28.1468 42.3621 27.5676 40.5119 27.2825 38.6069C25.5531 27.0423 33.3959 17.1799 40.5879 14.7379C36.7081 11.382 26.986 11.6099 19.7502 16.881C15.4945 20.0311 12.3956 24.4725 10.9258 29.5285C11.3942 25.4257 12.6279 21.4448 14.5642 17.788C12.1418 19.0294 9.05712 22.9553 7.5349 26.5917C5.31948 32.1395 4.53824 38.1471 5.26209 44.0694C5.31458 44.5181 5.36182 44.966 5.42131 45.4113C6.32843 50.5834 8.4087 55.4843 11.5055 59.7451C14.6023 64.006 18.635 67.5155 23.2998 70.0098C27.9646 72.504 33.1402 73.9178 38.4368 74.1448C43.7333 74.3718 49.0129 73.406 53.8779 71.3201C58.7429 69.2343 63.0667 66.0828 66.5236 62.1027C69.9806 58.1229 72.4808 53.4181 73.8356 48.343C75.1907 43.2678 75.3652 37.9544 74.3462 32.8027C73.3272 27.6511 71.1412 22.7955 67.9528 18.6014Z" fill="#ffffff" />
<path d="M68.0158 21.2227C67.1159 18.5967 65.6598 16.1914 63.7448 14.1679C61.469 11.8197 58.7709 9.91221 55.7891 8.54328C53.2996 7.32819 50.6746 6.40681 47.968 5.79811C43.197 4.764 38.2598 4.73448 33.4764 5.71148C28.5047 6.75098 24.1332 8.88111 21.3679 11.544C23.5729 10.3105 25.9362 9.37805 28.3936 8.77197C33.1688 7.58374 38.1692 7.60361 42.9347 8.82973C47.7002 10.0559 52.0777 12.4489 55.6649 15.7887C57.1188 17.1539 58.3913 18.6966 59.452 20.3797C63.7344 27.2743 63.3293 35.9421 59.99 41.0539C57.5099 44.8516 52.1979 48.4171 47.2419 48.3756C50.9719 48.6542 54.7084 47.9256 58.0532 46.2672C61.398 44.6088 64.2256 42.0829 66.2346 38.9585C69.7243 33.5668 69.8433 26.7052 68.0158 21.2227Z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,3 @@
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.4634 36.467C4.5122 38.4182 4.5122 41.5818 6.4634 43.533L36.467 73.5366C38.4182 75.4878 41.5818 75.4878 43.533 73.5366L73.5366 43.533C75.4878 41.5818 75.4878 38.4182 73.5366 36.467L43.533 6.4634C41.5818 4.5122 38.4182 4.5122 36.467 6.4634L30.3248 12.6056L38.4248 20.7058C38.9224 20.5515 39.4513 20.4685 39.9996 20.4685C42.9336 20.4685 45.3121 22.8469 45.3121 25.781C45.3121 26.3293 45.229 26.8582 45.0748 27.3558L53.131 35.412C53.6459 35.2458 54.195 35.156 54.7652 35.156C57.6992 35.156 60.0777 37.5344 60.0777 40.4685C60.0777 43.4025 57.6992 45.781 54.7652 45.781C51.8312 45.781 49.4527 43.4025 49.4527 40.4685C49.4527 39.6884 49.6208 38.9477 49.9228 38.2805L42.4215 30.7791V50.192C44.1377 51.0726 45.3121 52.8599 45.3121 54.9216C45.3121 57.8556 42.9336 60.2341 39.9996 60.2341C37.0656 60.2341 34.6871 57.8556 34.6871 54.9216C34.6871 52.6476 36.1158 50.7073 38.1246 49.9494V30.7531C36.1158 29.9952 34.6871 28.0549 34.6871 25.781C34.6871 24.98 34.8643 24.2205 35.1817 23.5394L27.2865 15.644L6.4634 36.467Z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,4 @@
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M50 70.5C51.3807 70.5 52.5 69.3807 52.5 68C52.5 66.6193 51.3807 65.5 50 65.5V70.5ZM30 65.5C28.6193 65.5 27.5 66.6193 27.5 68C27.5 69.3807 28.6193 70.5 30 70.5V65.5ZM42.5 55C42.5 53.6193 41.3807 52.5 40 52.5C38.6193 52.5 37.5 53.6193 37.5 55H42.5ZM37.5 68C37.5 69.3807 38.6193 70.5 40 70.5C41.3807 70.5 42.5 69.3807 42.5 68H37.5ZM58.5 31C58.5 29.6193 57.3807 28.5 56 28.5C54.6193 28.5 53.5 29.6193 53.5 31L58.5 31ZM26.5 31C26.5 29.6193 25.3807 28.5 24 28.5C22.6193 28.5 21.5 29.6193 21.5 31H26.5ZM50 65.5H30V70.5H50V65.5ZM37.5 55L37.5 68H42.5L42.5 55H37.5ZM53.5 31V39H58.5V31L53.5 31ZM26.5 39V31H21.5V39H26.5ZM21.5 39C21.5 49.2173 29.7827 57.5 40 57.5V52.5C32.5442 52.5 26.5 46.4558 26.5 39H21.5ZM53.5 39C53.5 46.4558 47.4558 52.5 40 52.5V57.5C50.2173 57.5 58.5 49.2173 58.5 39H53.5Z" fill="#C2CCDE" />
<path d="M31 21C31 16.0294 35.0294 12 40 12C44.9706 12 49 16.0294 49 21V39C49 43.9706 44.9706 48 40 48C35.0294 48 31 43.9706 31 39L31 21Z" fill="#C2CCDE" stroke="#C2CCDE" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,4 @@
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M61.7637 40L17.7637 40" stroke="#ffffff" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />
<path d="M39.7637 62L39.7637 18" stroke="#ffffff" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 340 B

View file

@ -0,0 +1,4 @@
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M32.541 5C27.0483 5 22.5956 9.31133 22.5956 14.6296V21.2963H39.8087V23.1481H14.9453C9.45268 23.1481 5 27.4595 5 32.7778L5 47.2222C5 52.5405 9.45269 56.8518 14.9454 56.8518H20.6831V48.7037C20.6831 43.3854 25.1357 39.0741 30.6284 39.0741H48.989C53.6367 39.0741 57.4043 35.426 57.4043 30.9259V14.6296C57.4043 9.31133 52.9517 5 47.459 5H32.541ZM30.2459 16.8518C32.1472 16.8518 33.6885 15.3595 33.6885 13.5185C33.6885 11.6776 32.1472 10.1852 30.2459 10.1852C28.3446 10.1852 26.8033 11.6776 26.8033 13.5185C26.8033 15.3595 28.3446 16.8518 30.2459 16.8518Z" fill="#ffffff" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M47.4589 75C52.9516 75 57.4043 70.6887 57.4043 65.3704V58.7037H40.1912V56.8519L65.0545 56.8519C70.5472 56.8519 74.9999 52.5406 74.9999 47.2223V32.7778C74.9999 27.4595 70.5472 23.1482 65.0545 23.1482H59.3168V31.2964C59.3168 36.6147 54.8641 40.926 49.3715 40.926L31.0108 40.926C26.3632 40.926 22.5955 44.574 22.5955 49.0741L22.5955 65.3704C22.5955 70.6887 27.0482 75 32.5409 75H47.4589ZM49.754 63.1482C47.8527 63.1482 46.3114 64.6406 46.3114 66.4815C46.3114 68.3225 47.8527 69.8148 49.754 69.8148C51.6553 69.8148 53.1966 68.3225 53.1966 66.4815C53.1966 64.6406 51.6553 63.1482 49.754 63.1482Z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,3 @@
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.1441 12.6217C38.8159 10.9488 41.1841 10.9488 41.8559 12.6217L47.8081 27.4439C48.0942 28.1563 48.7628 28.642 49.5288 28.694L65.4648 29.7745C67.2635 29.8965 67.9953 32.1489 66.6118 33.3047L54.3544 43.5459C53.7653 44.0381 53.5099 44.8241 53.6972 45.5686L57.5941 61.0586C58.0339 62.8069 56.1179 64.199 54.5911 63.2404L41.0634 54.7476C40.4132 54.3394 39.5868 54.3394 38.9366 54.7476L25.4089 63.2404C23.8821 64.199 21.9661 62.8069 22.4059 61.0586L26.3028 45.5686C26.4901 44.8241 26.2347 44.0381 25.6456 43.5459L13.3882 33.3047C12.0047 32.1489 12.7365 29.8965 14.5352 29.7745L30.4712 28.694C31.2372 28.642 31.9058 28.1563 32.1919 27.4439L38.1441 12.6217Z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 784 B

View file

@ -0,0 +1,4 @@
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 24L32.1592 39.2526C32.6067 39.6504 32.6067 40.3496 32.1592 40.7474L15 56" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="7" />
<path d="M41 56H65" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="8" />
</svg>

After

Width:  |  Height:  |  Size: 382 B

View file

@ -0,0 +1,5 @@
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M57.5045 7.79198C57.5045 5.59458 55.0442 5.59455 53.9507 5.86923C55.4815 4.66065 57.2767 4.95367 57.9145 5.3199L72.6979 12.5687C74.1065 13.2594 75.0001 14.6965 75.0001 16.2714V63.9578C75.0001 65.5538 74.0827 67.0062 72.6455 67.6858L58.7346 74.2634C57.7778 74.6754 55.7002 75.6917 53.9507 74.2634C56.1376 74.6754 57.3222 73.1189 57.5045 72.066V7.79198Z" fill="#ffffff" />
<path d="M54.128 5.82976C55.2886 5.60042 57.5045 5.70662 57.5045 7.79198L57.5044 24.2063L12.684 58.1133C11.9005 58.7059 10.798 58.6076 10.1307 57.8856L5.51133 52.8871C4.78766 52.104 4.83829 50.8783 5.62406 50.1582L53.9507 5.86923L54.128 5.82976Z" fill="#ffffff" />
<path d="M57.5044 55.927L12.684 22.02C11.9005 21.4274 10.798 21.5257 10.1307 22.2477L5.51133 27.2463C4.78766 28.0293 4.83829 29.255 5.62406 29.9752L53.9507 74.2634C56.1376 74.6754 57.3222 73.1189 57.5045 72.066L57.5044 55.927Z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 999 B

188
modules/astal/src/package-lock.json generated Normal file
View file

@ -0,0 +1,188 @@
{
"name": "astal-shell",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "astal-shell",
"dependencies": {
"astal": "/usr/share/astal/gjs"
},
"devDependencies": {
"@biomejs/biome": "1.9.4"
}
},
"../../../../usr/share/astal/gjs": {
"name": "astal",
"license": "LGPL-2.1"
},
"node_modules/@biomejs/biome": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz",
"integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==",
"dev": true,
"hasInstallScript": true,
"license": "MIT OR Apache-2.0",
"bin": {
"biome": "bin/biome"
},
"engines": {
"node": ">=14.21.3"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "1.9.4",
"@biomejs/cli-darwin-x64": "1.9.4",
"@biomejs/cli-linux-arm64": "1.9.4",
"@biomejs/cli-linux-arm64-musl": "1.9.4",
"@biomejs/cli-linux-x64": "1.9.4",
"@biomejs/cli-linux-x64-musl": "1.9.4",
"@biomejs/cli-win32-arm64": "1.9.4",
"@biomejs/cli-win32-x64": "1.9.4"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz",
"integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz",
"integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz",
"integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz",
"integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz",
"integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz",
"integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz",
"integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz",
"integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/astal": {
"resolved": "../../../../usr/share/astal/gjs",
"link": true
}
}
}

View file

@ -0,0 +1,9 @@
{
"name": "astal-shell",
"dependencies": {
"astal": "/usr/share/astal/gjs"
},
"devDependencies": {
"@biomejs/biome": "1.9.4"
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,11 @@
export default {
monitor: {
main: "M27Q",
},
notification: {
ignore: [/^Spotify/],
},
tray: {
ignore: [/spotify/],
},
} as const;

View file

@ -0,0 +1,49 @@
@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'); }
}
}

View 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)
// })
}

View 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;
}
}

View 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>
);
}

View 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 re of config.notification.ignore) {
if (re.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>
);
}

View file

@ -0,0 +1,15 @@
import { Gdk, Gtk } from "astal/gtk4";
import { Gio } from "astal";
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);
};

View 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;
};

View file

@ -0,0 +1,52 @@
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_model(),
// First monitor
() => true,
];
const monitors = App.get_monitors();
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");
}

View 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);
}),
);
};

View 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);
}
}

View 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;

View 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);
}
},
},
);

View file

@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"experimentalDecorators": true,
"strict": true,
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"jsxImportSource": "astal/gtk4"
}
}