diff --git a/.gitignore b/.gitignore index e08b5c7..06e7200 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ oneshot/style.css oneshot/**/build/ debug/results/ + +JSX* diff --git a/README.md b/README.md index 770b9de..fb87e0f 100644 --- a/README.md +++ b/README.md @@ -120,12 +120,23 @@ pnpm check pnpm build ``` -Running `pnpm run tsc` will throw errors in the following 2 files: +The minimum checks to run before creating a commit are: + +```sh +pnpm check +pnpm build +git diff --check +``` + +`pnpm check` runs Prettier's check mode, ESLint, Knip, and the project type check. +Use `pnpm format` to apply formatting before running the checks again. + +`pnpm check` ignores the following 2 known upstream type errors: > `../../../usr/share/ags/js/lib/gtk4/app.ts:288` > `../../../usr/share/ags/js/node_modules/gnim/dist/jsx/state.ts:715` -You can safely ignore these errors. +Use `pnpm run tsc` when you need the raw TypeScript output, including those upstream errors. ## Why AGS was chosen and its memory consumption diff --git a/knip.json b/knip.json index fe44e95..80199ce 100644 --- a/knip.json +++ b/knip.json @@ -1,7 +1,7 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", "tags": ["-lintignore"], - "entry": ["src/app.ts"], - "ignoreDependencies": ["gi", "system"], + "entry": ["src/app.ts", "scripts/build-js.mjs"], + "ignoreDependencies": ["cairo", "gi", "system"], "ignoreBinaries": ["ags"] } diff --git a/package.json b/package.json index 9d645b3..8af5b2a 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@eslint/js": "^10.0.1", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/node": "^26.2.0", + "esbuild": "0.25.10", "eslint": "^10.5.0", "eslint-config-prettier": "^10.1.8", "knip": "^6.32.0", @@ -18,14 +19,17 @@ "generate-type": "ags types -d .", "lint": "eslint .", "format": "prettier --write \"./**/*.{js,ts,tsx,json,css,scss,md,html}\" \"./*.{js,ts,tsx,json}\"", + "format:check": "prettier --check \"./**/*.{js,ts,tsx,json,css,scss,md,html}\" \"./*.{js,ts,tsx,json}\"", "tsc": "tsc --noEmit", + "typecheck": "bash scripts/typecheck.sh", "knip": "knip", - "check": "pnpm lint && pnpm knip && pnpm tsc", + "check": "pnpm format:check && pnpm lint && pnpm knip && pnpm typecheck", "build": "./scripts/build.sh", "dev": "./scripts/dev.sh", "deploy:user": "./scripts/deploy-user.sh" }, "name": "rystal-shell", + "packageManager": "pnpm@11.9.0", "license": "GPL-3.0-or-later", "private": true } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 736713a..0513671 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@types/node': specifier: ^26.2.0 version: 26.2.0 + esbuild: + specifier: 0.25.10 + version: 0.25.10 eslint: specifier: ^10.5.0 version: 10.5.0(jiti@2.7.0) @@ -91,6 +94,162 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.25.10': + resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.10': + resolution: {integrity: sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.10': + resolution: {integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.10': + resolution: {integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.10': + resolution: {integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.10': + resolution: {integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.10': + resolution: {integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.10': + resolution: {integrity: sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.10': + resolution: {integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.10': + resolution: {integrity: sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.10': + resolution: {integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.10': + resolution: {integrity: sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.10': + resolution: {integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.10': + resolution: {integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.10': + resolution: {integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.10': + resolution: {integrity: sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.10': + resolution: {integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.10': + resolution: {integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.10': + resolution: {integrity: sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.10': + resolution: {integrity: sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.10': + resolution: {integrity: sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.10': + resolution: {integrity: sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.10': + resolution: {integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.10': + resolution: {integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.10': + resolution: {integrity: sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.10': + resolution: {integrity: sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -539,6 +698,11 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + esbuild@0.25.10: + resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==} + engines: {node: '>=18'} + hasBin: true + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -929,6 +1093,84 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.10': + optional: true + + '@esbuild/android-arm64@0.25.10': + optional: true + + '@esbuild/android-arm@0.25.10': + optional: true + + '@esbuild/android-x64@0.25.10': + optional: true + + '@esbuild/darwin-arm64@0.25.10': + optional: true + + '@esbuild/darwin-x64@0.25.10': + optional: true + + '@esbuild/freebsd-arm64@0.25.10': + optional: true + + '@esbuild/freebsd-x64@0.25.10': + optional: true + + '@esbuild/linux-arm64@0.25.10': + optional: true + + '@esbuild/linux-arm@0.25.10': + optional: true + + '@esbuild/linux-ia32@0.25.10': + optional: true + + '@esbuild/linux-loong64@0.25.10': + optional: true + + '@esbuild/linux-mips64el@0.25.10': + optional: true + + '@esbuild/linux-ppc64@0.25.10': + optional: true + + '@esbuild/linux-riscv64@0.25.10': + optional: true + + '@esbuild/linux-s390x@0.25.10': + optional: true + + '@esbuild/linux-x64@0.25.10': + optional: true + + '@esbuild/netbsd-arm64@0.25.10': + optional: true + + '@esbuild/netbsd-x64@0.25.10': + optional: true + + '@esbuild/openbsd-arm64@0.25.10': + optional: true + + '@esbuild/openbsd-x64@0.25.10': + optional: true + + '@esbuild/openharmony-arm64@0.25.10': + optional: true + + '@esbuild/sunos-x64@0.25.10': + optional: true + + '@esbuild/win32-arm64@0.25.10': + optional: true + + '@esbuild/win32-ia32@0.25.10': + optional: true + + '@esbuild/win32-x64@0.25.10': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@2.7.0))': dependencies: eslint: 10.5.0(jiti@2.7.0) @@ -1284,6 +1526,35 @@ snapshots: deep-is@0.1.4: {} + esbuild@0.25.10: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.10 + '@esbuild/android-arm': 0.25.10 + '@esbuild/android-arm64': 0.25.10 + '@esbuild/android-x64': 0.25.10 + '@esbuild/darwin-arm64': 0.25.10 + '@esbuild/darwin-x64': 0.25.10 + '@esbuild/freebsd-arm64': 0.25.10 + '@esbuild/freebsd-x64': 0.25.10 + '@esbuild/linux-arm': 0.25.10 + '@esbuild/linux-arm64': 0.25.10 + '@esbuild/linux-ia32': 0.25.10 + '@esbuild/linux-loong64': 0.25.10 + '@esbuild/linux-mips64el': 0.25.10 + '@esbuild/linux-ppc64': 0.25.10 + '@esbuild/linux-riscv64': 0.25.10 + '@esbuild/linux-s390x': 0.25.10 + '@esbuild/linux-x64': 0.25.10 + '@esbuild/netbsd-arm64': 0.25.10 + '@esbuild/netbsd-x64': 0.25.10 + '@esbuild/openbsd-arm64': 0.25.10 + '@esbuild/openbsd-x64': 0.25.10 + '@esbuild/openharmony-arm64': 0.25.10 + '@esbuild/sunos-x64': 0.25.10 + '@esbuild/win32-arm64': 0.25.10 + '@esbuild/win32-ia32': 0.25.10 + '@esbuild/win32-x64': 0.25.10 + escape-string-regexp@4.0.0: {} eslint-config-prettier@10.1.8(eslint@10.5.0(jiti@2.7.0)): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/scripts/build-js.mjs b/scripts/build-js.mjs new file mode 100644 index 0000000..6c3c11a --- /dev/null +++ b/scripts/build-js.mjs @@ -0,0 +1,15 @@ +import { build } from 'esbuild'; + +await build({ + entryPoints: ['src/app.ts'], + outfile: 'dist/app.js', + bundle: true, + external: ['gi://*', 'resource://*', 'cairo', 'console', 'system'], + format: 'esm', + keepNames: false, + minify: true, + platform: 'neutral', + sourcemap: false, + target: 'es2022', + tsconfig: 'tsconfig.json', +}); diff --git a/scripts/build.sh b/scripts/build.sh index 4a13f05..ff1de6d 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -6,5 +6,6 @@ repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$repo_dir" mkdir -p dist -ags bundle src/app.ts dist/start-ags --root "$repo_dir" -sass --load-path styles/default styles/style.scss dist/default.css +node scripts/build-js.mjs +install -Dm755 scripts/start-ags.sh dist/start-ags +sass --style=expanded --no-source-map --load-path styles/default styles/style.scss dist/default.css diff --git a/scripts/deploy-user.sh b/scripts/deploy-user.sh index 82e255f..957445d 100755 --- a/scripts/deploy-user.sh +++ b/scripts/deploy-user.sh @@ -23,6 +23,7 @@ stage="$(mktemp -d "$data_home/.rystal-shell.stage.XXXXXX")" trap 'rm -rf -- "$stage"' EXIT install -Dm755 dist/start-ags "$stage/start-ags" +install -Dm644 dist/app.js "$stage/app.js" install -Dm644 dist/default.css "$stage/styles/default.css" cp -a assets "$stage/assets" cp -a styles/. "$stage/styles/" diff --git a/scripts/start-ags.sh b/scripts/start-ags.sh new file mode 100644 index 0000000..2b1afc9 --- /dev/null +++ b/scripts/start-ags.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +exec env LD_PRELOAD="/usr/lib/libgtk4-layer-shell.so" \ + /usr/bin/gjs -m "$script_dir/app.js" "$@" diff --git a/scripts/typecheck.sh b/scripts/typecheck.sh new file mode 100644 index 0000000..2aafd26 --- /dev/null +++ b/scripts/typecheck.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +set -euo pipefail + +output_file="$(mktemp)" +trap 'rm -f "$output_file"' EXIT + +if pnpm exec tsc --noEmit --pretty false >"$output_file" 2>&1; then + cat "$output_file" + exit 0 +fi + +known_error_paths=( + '../../../usr/share/ags/js/lib/gtk4/app.ts(288,17)' + '../../../usr/share/ags/js/node_modules/gnim/dist/jsx/state.ts(715,47)' +) + +error_lines="$(grep 'error TS' "$output_file" || true)" +unexpected_errors="$error_lines" +for path in "${known_error_paths[@]}"; do + unexpected_errors="$(printf '%s\n' "$unexpected_errors" | grep -vF "$path" || true)" +done + +if [[ -n "$unexpected_errors" ]]; then + cat "$output_file" + exit 1 +fi + +printf 'TypeScript passed; ignoring known upstream errors:\n' +printf '%s\n' "$error_lines" diff --git a/src/app.ts b/src/app.ts index fa25b40..63a01f0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,8 +2,9 @@ import { Gdk, Gtk } from 'ags/gtk4'; import app from 'ags/gtk4/app'; import { requestHandler } from './ipc'; -import { initCss } from './lib/css'; import { rystalShellDataDir, rystalShellInstance } from './lib/paths'; +import { cleanupRecording } from './stores/capture/recording'; +import { initCss } from './stores/shell/style'; import AppLauncher from './widget/app-launcher'; import Bar from './widget/bar'; import ControlCenter from './widget/control-center'; @@ -17,6 +18,9 @@ app.start({ instanceName: rystalShellInstance, requestHandler, main() { + app.connect('shutdown', () => { + cleanupRecording(); + }); initCss(); // Add lucide symbolic icons to GTK Icon Theme search path @@ -26,14 +30,14 @@ app.start({ } app.get_monitors().forEach((m) => { - Bar(m); - ControlCenter(m); - WifiPasswordDialog(m); - DateWeatherPopup(m); - NotificationPopups(m); - AppLauncher(m); - WallpaperSelector(m); - PowerMenu(m); + Bar({ monitor: m }); + ControlCenter({ monitor: m }); + WifiPasswordDialog({ monitor: m }); + DateWeatherPopup({ monitor: m }); + NotificationPopups({ monitor: m }); + AppLauncher({ monitor: m }); + WallpaperSelector({ monitor: m }); + PowerMenu({ monitor: m }); }); }, }); diff --git a/src/ipc.ts b/src/ipc.ts deleted file mode 100644 index f40d851..0000000 --- a/src/ipc.ts +++ /dev/null @@ -1,164 +0,0 @@ -import app from 'ags/gtk4/app'; - -import Hyprland from 'gi://AstalHyprland'; -import Notifd from 'gi://AstalNotifd'; - -import { compileAndReloadCss } from './lib/css'; -import { - brightnessStep, - changeBrightness, - getBrightnessBackend, - refreshBrightness, - refreshBrightnessBackend, -} from './stores/brightness'; -import { clearNotifications } from './stores/notification'; -import { getPowerProfile, setPowerProfile } from './stores/powerProfile'; -import { isRecording, startRecord, stopRecord } from './stores/recording'; -import { - toggleAppLauncher, - toggleControlCenter, - toggleDateWeather, - togglePowerMenu, - toggleWallpaperSelector, -} from './stores/windowManager'; - -type ResponseCallback = (response: string) => void; - -function handlePowerProfile(args: string[], res: ResponseCallback) { - const power = getPowerProfile(); - if (!power) { - res('Error: AstalPowerProfiles not available'); - return; - } - if (args[0] === 'get' || !args[0]) { - res(`Current mode: ${power.activeProfile}`); - } else if (args[0] === 'set' && args[1]) { - const result = setPowerProfile(args[1]); - res(result); - } else { - res('Usage: ags request "power-profile [get | set ]"'); - } -} - -function handleRecord(args: string[], res: ResponseCallback) { - if (args[0] === 'stop') { - stopRecord(); - res('Recording stopped'); - } else if (args[0] === 'start') { - const mode = args[1] === 'slurp' ? 'slurp' : 'monitor'; - startRecord(mode); - res(`Started recording in ${mode} mode`); - } else if (args[0] === 'toggle' || !args[0]) { - if (isRecording()) { - stopRecord(); - res('Recording stopped'); - } else { - const mode = args[1] === 'slurp' ? 'slurp' : 'monitor'; - startRecord(mode); - res(`Started recording in ${mode} mode`); - } - } else { - res('Usage: ags request "record [start|stop|toggle] [monitor|slurp]"'); - } -} - -function handleBrightness(args: string[], res: ResponseCallback) { - const action = args[0] ?? 'get'; - if (action === 'up' || action === 'down') { - const delta = action === 'up' ? brightnessStep : -brightnessStep; - changeBrightness(delta) - .then((value) => res(`Brightness: ${value}%`)) - .catch((err) => res(`Error: ${String(err)}`)); - return; - } - if (action === 'get') { - Promise.all([refreshBrightness(), getBrightnessBackend()]) - .then(([value, backend]) => res(`Brightness: ${value}% (${backend})`)) - .catch((err) => res(`Error: ${String(err)}`)); - return; - } - if (action === 'refresh') { - refreshBrightnessBackend() - .then((value) => res(`Brightness backend refreshed: ${value}%`)) - .catch((err) => res(`Error: ${String(err)}`)); - return; - } - res('Usage: ags request "brightness [get|up|down|refresh]"'); -} - -export function requestHandler(request: string[], res: ResponseCallback) { - const [command, ...args] = request; - - switch (command) { - case 'reload-css': - compileAndReloadCss() - .then(() => res('CSS Reloaded Successfully')) - .catch((err) => res(`Error: ${String(err)}`)); - break; - - case 'toggle-dnd': { - const notifd = Notifd.get_default(); - notifd.dontDisturb = !notifd.dontDisturb; - res(`DND toggled to ${notifd.dontDisturb}`); - break; - } - - case 'toggle-notif': - toggleDateWeather(); - res('Toggled Notification Center'); - break; - - case 'toggle-cc': - toggleControlCenter(); - res('Toggled Control Center'); - break; - - case 'toggle-launcher': - toggleAppLauncher(); - res('Toggled App Launcher'); - break; - - case 'toggle-wallpaper': - toggleWallpaperSelector(); - res('Toggled Wallpaper Selector'); - break; - - case 'toggle-power-menu': - togglePowerMenu(); - res('Toggled Power Menu'); - break; - - case 'clear-notifications': - clearNotifications(); - res('Cleared Notifications'); - break; - - case 'list-windows': { - const focusedMonitor = Hyprland.get_default().get_focused_monitor().name; - const dw = app.get_window(`date-weather-popup-${focusedMonitor}`); - res( - `Focused: ${focusedMonitor} | dw visible: ${dw?.get_visible()} | Windows: ` + - app - .get_windows() - .map((w) => w.name) - .join(', '), - ); - break; - } - - case 'power-profile': - handlePowerProfile(args, res); - break; - - case 'record': - handleRecord(args, res); - break; - - case 'brightness': - handleBrightness(args, res); - break; - - default: - res(`Unknown command: ${request.join(' ')}`); - } -} diff --git a/src/ipc/brightness.ts b/src/ipc/brightness.ts new file mode 100644 index 0000000..86e0ff2 --- /dev/null +++ b/src/ipc/brightness.ts @@ -0,0 +1,32 @@ +import { + brightnessStep, + changeBrightness, + getBrightnessBackend, + refreshBrightness, + refreshBrightnessBackend, +} from '../stores/system/brightness'; +import { type IpcCommandHandler } from './types'; + +export const handleBrightness: IpcCommandHandler = (args, response) => { + const action = args[0] ?? 'get'; + if (action === 'up' || action === 'down') { + const delta = action === 'up' ? brightnessStep : -brightnessStep; + changeBrightness(delta) + .then((value) => response(`Brightness: ${value}%`)) + .catch((error) => response(`Error: ${String(error)}`)); + return; + } + if (action === 'get') { + Promise.all([refreshBrightness(), getBrightnessBackend()]) + .then(([value, backend]) => response(`Brightness: ${value}% (${backend})`)) + .catch((error) => response(`Error: ${String(error)}`)); + return; + } + if (action === 'refresh') { + refreshBrightnessBackend() + .then((value) => response(`Brightness backend refreshed: ${value}%`)) + .catch((error) => response(`Error: ${String(error)}`)); + return; + } + response('Usage: ags request "brightness [get|up|down|refresh]"'); +}; diff --git a/src/ipc/css.ts b/src/ipc/css.ts new file mode 100644 index 0000000..699a72f --- /dev/null +++ b/src/ipc/css.ts @@ -0,0 +1,12 @@ +import { reloadLauncherBackground } from '../stores/application/launcherBackground'; +import { compileAndReloadCss } from '../stores/shell/style'; +import { type IpcCommandHandler } from './types'; + +export const handleReloadCss: IpcCommandHandler = (_args, response) => { + compileAndReloadCss() + .then((reloaded) => { + if (reloaded) reloadLauncherBackground(); + response('CSS Reloaded Successfully'); + }) + .catch((error) => response(`Error: ${String(error)}`)); +}; diff --git a/src/ipc/index.ts b/src/ipc/index.ts new file mode 100644 index 0000000..5a27e93 --- /dev/null +++ b/src/ipc/index.ts @@ -0,0 +1,28 @@ +import { handleBrightness } from './brightness'; +import { handleReloadCss } from './css'; +import { notificationCommandHandlers } from './notifications'; +import { panelCommandHandlers } from './panels'; +import { handlePowerProfile } from './powerProfile'; +import { handleRecord } from './recording'; +import { type IpcCommandHandler, type ResponseCallback } from './types'; + +const commandHandlers: ReadonlyMap = new Map([ + ...panelCommandHandlers, + ...notificationCommandHandlers, + ['reload-css', handleReloadCss], + ['power-profile', handlePowerProfile], + ['record', handleRecord], + ['brightness', handleBrightness], +]); + +export function requestHandler(request: string[], response: ResponseCallback) { + const [command, ...args] = request; + const handler = commandHandlers.get(command ?? ''); + + if (handler) { + handler(args, response); + return; + } + + response(`Unknown command: ${request.join(' ')}`); +} diff --git a/src/ipc/notifications.ts b/src/ipc/notifications.ts new file mode 100644 index 0000000..90fc345 --- /dev/null +++ b/src/ipc/notifications.ts @@ -0,0 +1,21 @@ +import { clearNotifications, toggleDoNotDisturb } from '../stores/notification/notification'; +import { type IpcCommandHandler } from './types'; + +export const notificationCommandHandlers: ReadonlyMap = new Map< + string, + IpcCommandHandler +>([ + [ + 'toggle-dnd', + (_args, response) => { + response(`DND toggled to ${toggleDoNotDisturb()}`); + }, + ], + [ + 'clear-notifications', + (_args, response) => { + clearNotifications(); + response('Cleared Notifications'); + }, + ], +]); diff --git a/src/ipc/panels.ts b/src/ipc/panels.ts new file mode 100644 index 0000000..f3b0be0 --- /dev/null +++ b/src/ipc/panels.ts @@ -0,0 +1,67 @@ +import app from 'ags/gtk4/app'; + +import Hyprland from 'gi://AstalHyprland'; + +import { + toggleAppLauncher, + toggleControlCenter, + toggleDateWeather, + togglePowerMenu, + toggleWallpaperSelector, +} from '../stores/shell/windowManager'; +import { type IpcCommandHandler } from './types'; + +export const panelCommandHandlers: ReadonlyMap = new Map< + string, + IpcCommandHandler +>([ + [ + 'toggle-notif', + (_args, response) => { + toggleDateWeather(); + response('Toggled Notification Center'); + }, + ], + [ + 'toggle-cc', + (_args, response) => { + toggleControlCenter(); + response('Toggled Control Center'); + }, + ], + [ + 'toggle-launcher', + (_args, response) => { + toggleAppLauncher(); + response('Toggled App Launcher'); + }, + ], + [ + 'toggle-wallpaper', + (_args, response) => { + toggleWallpaperSelector(); + response('Toggled Wallpaper Selector'); + }, + ], + [ + 'toggle-power-menu', + (_args, response) => { + togglePowerMenu(); + response('Toggled Power Menu'); + }, + ], + [ + 'list-windows', + (_args, response) => { + const focusedMonitor = Hyprland.get_default().get_focused_monitor().name; + const dateWeather = app.get_window(`date-weather-popup-${focusedMonitor}`); + const windows = app + .get_windows() + .map((window) => window.name) + .join(', '); + response( + `Focused: ${focusedMonitor} | dw visible: ${dateWeather?.get_visible()} | Windows: ${windows}`, + ); + }, + ], +]); diff --git a/src/ipc/powerProfile.ts b/src/ipc/powerProfile.ts new file mode 100644 index 0000000..e4a85ad --- /dev/null +++ b/src/ipc/powerProfile.ts @@ -0,0 +1,17 @@ +import { getPowerProfile, setPowerProfile } from '../stores/system/powerProfile'; +import { type IpcCommandHandler } from './types'; + +export const handlePowerProfile: IpcCommandHandler = (args, response) => { + const power = getPowerProfile(); + if (!power) { + response('Error: AstalPowerProfiles not available'); + return; + } + if (args[0] === 'get' || !args[0]) { + response(`Current mode: ${power.activeProfile}`); + } else if (args[0] === 'set' && args[1]) { + response(setPowerProfile(args[1])); + } else { + response('Usage: ags request "power-profile [get | set ]"'); + } +}; diff --git a/src/ipc/recording.ts b/src/ipc/recording.ts new file mode 100644 index 0000000..69474ae --- /dev/null +++ b/src/ipc/recording.ts @@ -0,0 +1,54 @@ +import { isRecording, startRecord, stopRecord } from '../stores/capture/recording'; +import { type IpcCommandHandler, type ResponseCallback } from './types'; + +async function start(mode: 'monitor' | 'slurp', response: ResponseCallback) { + const result = await startRecord(mode); + switch (result.status) { + case 'started': + response(`Started recording in ${mode} mode: ${result.path}`); + break; + case 'already-active': + response('Recording is already active or starting'); + break; + case 'cancelled': + response('Recording cancelled'); + break; + case 'failed': + response(`Failed to start recording: ${result.error}`); + break; + } +} + +function stop(response: ResponseCallback) { + const result = stopRecord(); + switch (result.status) { + case 'stopping': + response('Stopping recording'); + break; + case 'not-recording': + response('No recording is active'); + break; + case 'already-stopping': + response('Recording is already stopping'); + break; + case 'failed': + response(`Failed to stop recording: ${result.error}`); + break; + } +} + +export const handleRecord: IpcCommandHandler = (args, response) => { + const action = args[0] ?? 'toggle'; + const mode = args[1] === 'slurp' ? 'slurp' : 'monitor'; + + if (action === 'stop') { + stop(response); + } else if (action === 'start') { + void start(mode, response); + } else if (action === 'toggle') { + if (isRecording()) stop(response); + else void start(mode, response); + } else { + response('Usage: ags request "record [start|stop|toggle] [monitor|slurp]"'); + } +}; diff --git a/src/ipc/types.ts b/src/ipc/types.ts new file mode 100644 index 0000000..933e2c1 --- /dev/null +++ b/src/ipc/types.ts @@ -0,0 +1,3 @@ +export type ResponseCallback = (response: string) => void; + +export type IpcCommandHandler = (args: string[], response: ResponseCallback) => void; diff --git a/src/lib/audio.ts b/src/lib/audio.ts new file mode 100644 index 0000000..bbf207f --- /dev/null +++ b/src/lib/audio.ts @@ -0,0 +1,7 @@ +export function getVolumeIcon(iconName: string) { + if (iconName.includes('muted')) return 'volume-x'; + if (iconName.includes('high')) return 'volume-2'; + if (iconName.includes('medium')) return 'volume-1'; + if (iconName.includes('low')) return 'volume'; + return 'volume-x'; +} diff --git a/src/lib/image.ts b/src/lib/image.ts index 0ed792b..6209535 100644 --- a/src/lib/image.ts +++ b/src/lib/image.ts @@ -2,25 +2,10 @@ import Gdk from 'gi://Gdk'; import GdkPixbuf from 'gi://GdkPixbuf?version=2.0'; import Gio from 'gi://Gio'; -const textureCache = new Map(); - -export function loadTextureFromUri( - uri: string, - maxWidth: number, - maxHeight: number, - cache = false, -) { - const cacheKey = `${uri}:${maxWidth}x${maxHeight}`; - if (cache) { - const cached = textureCache.get(cacheKey); - if (cached) return cached; - } - +export function loadTextureFromUri(uri: string, maxWidth: number, maxHeight: number) { const path = Gio.File.new_for_uri(uri).get_path(); if (!path) throw new Error(`Image URI is not a local file: ${uri}`); const pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, maxWidth, maxHeight, true); - const texture = Gdk.Texture.new_for_pixbuf(pixbuf); - if (cache) textureCache.set(cacheKey, texture); - return texture; + return Gdk.Texture.new_for_pixbuf(pixbuf); } diff --git a/src/stores/application.ts b/src/stores/application.ts deleted file mode 100644 index dcdcb30..0000000 --- a/src/stores/application.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { execAsync } from 'ags/process'; - -import Apps from 'gi://AstalApps'; -import GLib from 'gi://GLib'; -import Gio from 'gi://Gio'; - -import { ryprlandStateDir } from '../lib/paths'; - -const STATE_DIR = `${ryprlandStateDir}/rystal-shell`; -const HISTORY_FILE = `${STATE_DIR}/app-history.json`; -const LEGACY_HISTORY_FILE = `${GLib.get_user_cache_dir()}/ags/app_history.json`; -const MAX_APP_RESULTS = 30; -const MAX_HISTORY_ENTRIES = 100; -const apps = new Apps.Apps(); - -interface HistoryStore { - version: 2; - scores: Record; -} - -let appHistory: Record = {}; - -function getAppHistoryKey(app: Apps.Application) { - return app.entry || app.executable || app.name; -} - -function getRawAppList() { - return apps.get_list(); -} - -function trimAppHistory() { - const entries = Object.entries(appHistory) - .filter(([, score]) => Number.isFinite(score) && score > 0) - .sort(([, scoreA], [, scoreB]) => scoreB - scoreA) - .slice(0, MAX_HISTORY_ENTRIES); - appHistory = Object.fromEntries(entries); -} - -function loadAppHistory() { - try { - const currentFile = Gio.File.new_for_path(HISTORY_FILE); - const usingLegacyFile = !currentFile.query_exists(null); - let shouldSave = usingLegacyFile; - const file = usingLegacyFile ? Gio.File.new_for_path(LEGACY_HISTORY_FILE) : currentFile; - if (file.query_exists(null)) { - const [ok, contents] = file.load_contents(null); - if (ok) { - const parsed: unknown = JSON.parse(new TextDecoder().decode(contents)); - if ( - typeof parsed === 'object' && - parsed !== null && - 'version' in parsed && - parsed.version === 2 && - 'scores' in parsed && - typeof parsed.scores === 'object' && - parsed.scores !== null - ) { - appHistory = parsed.scores as Record; - } else if (typeof parsed === 'object' && parsed !== null) { - const legacy = parsed as Record; - const migrated: Record = {}; - for (const [legacyKey, score] of Object.entries(legacy)) { - const matches = getRawAppList().filter( - (app) => app.executable === legacyKey || app.name === legacyKey, - ); - if (matches.length === 1 && Number.isFinite(score)) { - const key = getAppHistoryKey(matches[0]); - migrated[key] = (migrated[key] ?? 0) + score; - } - } - appHistory = migrated; - shouldSave = true; - } - trimAppHistory(); - if (shouldSave) saveAppHistory(); - } - } - } catch (e) { - console.error('Failed to load app history', e); - } -} - -function saveAppHistory() { - try { - const file = Gio.File.new_for_path(HISTORY_FILE); - const parent = file.get_parent(); - if (parent && !parent.query_exists(null)) { - parent.make_directory_with_parents(null); - } - const contents = new TextEncoder().encode( - JSON.stringify({ version: 2, scores: appHistory } satisfies HistoryStore), - ); - file.replace_contents(contents, null, false, Gio.FileCreateFlags.NONE, null); - } catch (e) { - console.error('Failed to save app history', e); - } -} - -export function recordAppLaunch(app: Apps.Application) { - const key = getAppHistoryKey(app); - if (!key) return; - for (const k in appHistory) { - appHistory[k] *= 0.99; - } - appHistory[key] = (appHistory[key] || 0) + 1; - trimAppHistory(); - saveAppHistory(); -} - -// Load initially -loadAppHistory(); - -function getAppList() { - return getRawAppList().sort((a, b) => { - const keyA = getAppHistoryKey(a); - const keyB = getAppHistoryKey(b); - const scoreA = appHistory[keyA] || 0; - const scoreB = appHistory[keyB] || 0; - if (scoreB !== scoreA) return scoreB - scoreA; - return (a.name || '').localeCompare(b.name || ''); - }); -} - -export function searchApps(q: string) { - const allApps = getAppList(); - if (q === '') return allApps.slice(0, MAX_APP_RESULTS); - const keywords = q.split(/\s+/); - - const results = allApps - .map((appItem) => { - const name = (appItem.name || '').toLowerCase(); - const desc = (appItem.description || '').toLowerCase(); - const exec = (appItem.executable || '').toLowerCase(); - const searchString = name + ' ' + desc + ' ' + exec; - - let score = 0; - if (name.startsWith(q)) score += 100; - else if (name.includes(q)) score += 50; - else if (exec.includes(q)) score += 30; - else if (desc.includes(q)) score += 10; - - const matchesAll = keywords.every((kw) => searchString.includes(kw)); - if (!matchesAll) score = 0; - - const key = getAppHistoryKey(appItem); - const historyScore = appHistory[key] || 0; - if (score > 0) { - score += historyScore * 10; - } - - return { app: appItem, score }; - }) - .filter((x) => x.score > 0); - - results.sort((a, b) => b.score - a.score); - return results.map((x) => x.app).slice(0, MAX_APP_RESULTS); -} - -function searchWeb(query: string) { - execAsync(['xdg-open', `https://google.com/search?q=${encodeURIComponent(query)}`]).catch( - () => {}, - ); -} - -const DOMAIN_PATTERN = - /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d{1,5})?(?:[/?#][^\s]*)?$/i; -const HTTP_URL_PATTERN = /^https?:\/\/[^\s]+$/i; - -export function getDirectUrl(value: string) { - const query = value.trim(); - if (!query || /\s/.test(query)) return null; - - if (HTTP_URL_PATTERN.test(query)) return query; - return DOMAIN_PATTERN.test(query) ? `https://${query}` : null; -} - -export function openQuery(value: string) { - const url = getDirectUrl(value); - if (url) { - execAsync(['xdg-open', url]).catch(console.error); - return 'url' as const; - } - searchWeb(value); - return 'search' as const; -} diff --git a/src/stores/application/appLauncher.ts b/src/stores/application/appLauncher.ts new file mode 100644 index 0000000..ff8370a --- /dev/null +++ b/src/stores/application/appLauncher.ts @@ -0,0 +1,27 @@ +import { type Accessor, type Setter, createComputed, createState } from 'ags'; + +import Apps from 'gi://AstalApps'; + +import { searchApps } from './applicationCatalog'; + +export interface AppLauncherState { + text: Accessor; + setText: Setter; + selectedIndex: Accessor; + setSelectedIndex: Setter; + results: Accessor; +} + +export function createAppLauncherState(): AppLauncherState { + const [text, setText] = createState(''); + const [selectedIndex, setSelectedIndex] = createState(0); + const results = createComputed(() => searchApps(text().trim().toLowerCase())); + + return { + text, + setText, + selectedIndex, + setSelectedIndex, + results, + }; +} diff --git a/src/stores/application/applicationCatalog.ts b/src/stores/application/applicationCatalog.ts new file mode 100644 index 0000000..aec6250 --- /dev/null +++ b/src/stores/application/applicationCatalog.ts @@ -0,0 +1,69 @@ +import Apps from 'gi://AstalApps'; + +import { ApplicationHistory } from './applicationHistory'; + +const MAX_APP_RESULTS = 30; +const applications = new Apps.Apps(); +const history = new ApplicationHistory(applications); + +function getResultKey(application: Apps.Application) { + return application.name + (application.description || '') + (application.iconName || ''); +} + +function getUniqueResults(applicationList: Apps.Application[]) { + const results: Apps.Application[] = []; + const seen = new Set(); + + for (const application of applicationList) { + const key = getResultKey(application); + if (seen.has(key)) continue; + + seen.add(key); + results.push(application); + if (results.length === MAX_APP_RESULTS) break; + } + + return results; +} + +function getApplicationList() { + return applications.get_list().sort((applicationA, applicationB) => { + const scoreA = history.getScore(applicationA); + const scoreB = history.getScore(applicationB); + if (scoreB !== scoreA) return scoreB - scoreA; + return (applicationA.name || '').localeCompare(applicationB.name || ''); + }); +} + +export function recordAppLaunch(application: Apps.Application) { + history.recordLaunch(application); +} + +export function searchApps(query: string) { + const allApplications = getApplicationList(); + if (query === '') return getUniqueResults(allApplications); + const keywords = query.split(/\s+/); + + const results = allApplications + .map((application) => { + const name = (application.name || '').toLowerCase(); + const description = (application.description || '').toLowerCase(); + const executable = (application.executable || '').toLowerCase(); + const searchString = `${name} ${description} ${executable}`; + + let score = 0; + if (name.startsWith(query)) score += 100; + else if (name.includes(query)) score += 50; + else if (executable.includes(query)) score += 30; + else if (description.includes(query)) score += 10; + + if (!keywords.every((keyword) => searchString.includes(keyword))) score = 0; + if (score > 0) score += history.getScore(application) * 10; + + return { application, score }; + }) + .filter(({ score }) => score > 0); + + results.sort((resultA, resultB) => resultB.score - resultA.score); + return getUniqueResults(results.map(({ application }) => application)); +} diff --git a/src/stores/application/applicationHistory.ts b/src/stores/application/applicationHistory.ts new file mode 100644 index 0000000..8448b65 --- /dev/null +++ b/src/stores/application/applicationHistory.ts @@ -0,0 +1,117 @@ +import Apps from 'gi://AstalApps'; +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; + +import { ryprlandStateDir } from '../../lib/paths'; + +const STATE_DIR = `${ryprlandStateDir}/rystal-shell`; +const HISTORY_FILE = `${STATE_DIR}/app-history.json`; +const LEGACY_HISTORY_FILE = `${GLib.get_user_cache_dir()}/ags/app_history.json`; +const MAX_HISTORY_ENTRIES = 100; + +interface HistoryStore { + version: 2; + scores: Record; +} + +function getApplicationKey(application: Apps.Application) { + return application.entry || application.executable || application.name; +} + +export class ApplicationHistory { + private scores: Record = {}; + + constructor(private readonly applications: Apps.Apps) { + this.load(); + } + + getScore(application: Apps.Application) { + return this.scores[getApplicationKey(application)] || 0; + } + + recordLaunch(application: Apps.Application) { + const key = getApplicationKey(application); + if (!key) return; + + for (const historyKey in this.scores) { + this.scores[historyKey] *= 0.99; + } + this.scores[key] = (this.scores[key] || 0) + 1; + this.trim(); + this.save(); + } + + private trim() { + const entries = Object.entries(this.scores) + .filter(([, score]) => Number.isFinite(score) && score > 0) + .sort(([, scoreA], [, scoreB]) => scoreB - scoreA) + .slice(0, MAX_HISTORY_ENTRIES); + this.scores = Object.fromEntries(entries); + } + + private migrateLegacyScores(legacy: Record) { + const migrated: Record = {}; + for (const [legacyKey, score] of Object.entries(legacy)) { + const matches = this.applications + .get_list() + .filter( + (application) => application.executable === legacyKey || application.name === legacyKey, + ); + if (matches.length === 1 && Number.isFinite(score)) { + const key = getApplicationKey(matches[0]); + migrated[key] = (migrated[key] ?? 0) + score; + } + } + return migrated; + } + + private load() { + try { + const currentFile = Gio.File.new_for_path(HISTORY_FILE); + const usingLegacyFile = !currentFile.query_exists(null); + let shouldSave = usingLegacyFile; + const file = usingLegacyFile ? Gio.File.new_for_path(LEGACY_HISTORY_FILE) : currentFile; + if (!file.query_exists(null)) return; + + const [success, contents] = file.load_contents(null); + if (!success) return; + + const parsed: unknown = JSON.parse(new TextDecoder().decode(contents)); + if ( + typeof parsed === 'object' && + parsed !== null && + 'version' in parsed && + parsed.version === 2 && + 'scores' in parsed && + typeof parsed.scores === 'object' && + parsed.scores !== null + ) { + this.scores = parsed.scores as Record; + } else if (typeof parsed === 'object' && parsed !== null) { + this.scores = this.migrateLegacyScores(parsed as Record); + shouldSave = true; + } + + this.trim(); + if (shouldSave) this.save(); + } catch (error) { + console.error('Failed to load app history', error); + } + } + + private save() { + try { + const file = Gio.File.new_for_path(HISTORY_FILE); + const parent = file.get_parent(); + if (parent && !parent.query_exists(null)) { + parent.make_directory_with_parents(null); + } + const contents = new TextEncoder().encode( + JSON.stringify({ version: 2, scores: this.scores } satisfies HistoryStore), + ); + file.replace_contents(contents, null, false, Gio.FileCreateFlags.NONE, null); + } catch (error) { + console.error('Failed to save app history', error); + } + } +} diff --git a/src/stores/launcherImage.ts b/src/stores/application/launcherBackground.ts similarity index 91% rename from src/stores/launcherImage.ts rename to src/stores/application/launcherBackground.ts index bbe7bb4..1a5b844 100644 --- a/src/stores/launcherImage.ts +++ b/src/stores/application/launcherBackground.ts @@ -2,8 +2,8 @@ import { Gdk, Gtk } from 'ags/gtk4'; import GLib from 'gi://GLib'; -import { loadTextureFromUri } from '../lib/image'; -import { rystalShellConfigDir, rystalShellDataDir } from '../lib/paths'; +import { loadTextureFromUri } from '../../lib/image'; +import { rystalShellConfigDir, rystalShellDataDir } from '../../lib/paths'; const configuredBackgroundPath = `${rystalShellConfigDir}/assets/launcher_bg.png`; const defaultBackgroundPath = `${rystalShellDataDir}/assets/icon.png`; diff --git a/src/stores/application/query.ts b/src/stores/application/query.ts new file mode 100644 index 0000000..9746f78 --- /dev/null +++ b/src/stores/application/query.ts @@ -0,0 +1,29 @@ +import { execAsync } from 'ags/process'; + +const DOMAIN_PATTERN = + /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d{1,5})?(?:[/?#][^\s]*)?$/i; +const HTTP_URL_PATTERN = /^https?:\/\/[^\s]+$/i; + +function searchWeb(query: string) { + execAsync(['xdg-open', `https://google.com/search?q=${encodeURIComponent(query)}`]).catch( + () => {}, + ); +} + +export function getDirectUrl(value: string) { + const query = value.trim(); + if (!query || /\s/.test(query)) return null; + + if (HTTP_URL_PATTERN.test(query)) return query; + return DOMAIN_PATTERN.test(query) ? `https://${query}` : null; +} + +export function openQuery(value: string) { + const url = getDirectUrl(value); + if (url) { + execAsync(['xdg-open', url]).catch(console.error); + return 'url' as const; + } + searchWeb(value); + return 'search' as const; +} diff --git a/src/stores/audio.ts b/src/stores/audio.ts deleted file mode 100644 index 2990f14..0000000 --- a/src/stores/audio.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { execAsync } from 'ags/process'; - -import GSound from 'gi://GSound'; - -import { closeAllControlCenters } from './windowManager'; - -let volumeContext: GSound.Context | null = null; - -function getVolumeContext() { - if (volumeContext) return volumeContext; - const context = new GSound.Context(); - context.init(null); - volumeContext = context; - return context; -} - -export function playVolumeSound() { - try { - getVolumeContext().play_simple({ 'event.id': 'audio-volume-change' }, null); - } catch (err) { - volumeContext = null; - console.error('Failed to play volume sound', err); - } -} - -export async function setDefaultAudioEndpoint(nodeId: number) { - await execAsync(['wpctl', 'set-default', String(nodeId)]); -} - -export function openAudioControl() { - closeAllControlCenters(); - execAsync('pavucontrol').catch(console.error); -} diff --git a/src/stores/brightness.ts b/src/stores/brightness.ts deleted file mode 100644 index bb077c6..0000000 --- a/src/stores/brightness.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { createState } from 'ags'; -import { execAsync } from 'ags/process'; - -import GLib from 'gi://GLib'; - -import { appConfig } from '../lib/config'; -import { sendNotification } from '../lib/notification'; - -type BrightnessBackend = 'ddcutil' | 'brightnessctl'; -type ConfiguredBackend = BrightnessBackend | 'auto'; - -const DDC_VCP_BRIGHTNESS = '10'; -const KEYBOARD_STEP = 10; -const DEFAULT_RESTORE_BRIGHTNESS = 0.25; -const BRIGHTNESS_PRESETS = [0, 0.25, 0.5, 0.75, 1]; - -const [brightnessState, setBrightnessState] = createState(0.5); -export const brightness = brightnessState; - -let backend: BrightnessBackend | null = null; -let ddcBuses: string[] = []; -let pendingTarget: number | null = null; -let isSetting = false; -let setTimer: ReturnType | null = null; -let lastNonZeroBrightness: number | null = null; - -function rememberNonZeroBrightness(value: number) { - if (value > 0) lastNonZeroBrightness = value; -} - -function clampPercent(value: number) { - return Math.max(0, Math.min(100, Math.round(value))); -} - -function parsePercent(value: string) { - const match = value.match(/(\d+(?:\.\d+)?)\s*%/); - if (match) return clampPercent(Number(match[1])); - - const numeric = Number(value.trim()); - return Number.isFinite(numeric) ? clampPercent(numeric) : null; -} - -function hasProgram(name: string) { - return GLib.find_program_in_path(name) !== null; -} - -async function detectDdcBuses() { - if (!hasProgram('ddcutil')) return []; - - try { - const output = await execAsync(['ddcutil', 'detect']); - return [...output.matchAll(/I2C bus:\s*\/dev\/i2c-(\d+)/g)].map((match) => match[1]); - } catch (error) { - console.warn('DDC/CI detection failed:', error); - return []; - } -} - -async function resolveBackend() { - if (backend) return backend; - - const configured = (appConfig.brightness?.backend ?? 'auto') as ConfiguredBackend; - if (configured === 'ddcutil' || configured === 'auto') { - ddcBuses = await detectDdcBuses(); - if (ddcBuses.length > 0) { - backend = 'ddcutil'; - return backend; - } - if (configured === 'ddcutil') { - throw new Error('No DDC/CI-compatible display was detected'); - } - } - - if (configured === 'brightnessctl' || configured === 'auto') { - if (hasProgram('brightnessctl')) { - backend = 'brightnessctl'; - return backend; - } - } - - throw new Error(`Brightness backend '${configured}' is unavailable`); -} - -async function getDdcBrightnessDetails(bus: string) { - const output = await execAsync([ - 'ddcutil', - '--bus', - bus, - 'getvcp', - DDC_VCP_BRIGHTNESS, - '--terse', - ]); - const fields = output.trim().split(/\s+/); - const current = Number(fields[3]); - const maximum = Number(fields[4]); - if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) { - throw new Error(`Unable to parse DDC/CI brightness: ${output.trim()}`); - } - return { current, maximum }; -} - -async function getDdcBrightness() { - const bus = ddcBuses[0]; - if (!bus) throw new Error('No DDC/CI bus is available'); - const { current, maximum } = await getDdcBrightnessDetails(bus); - return clampPercent((current / maximum) * 100); -} - -async function getBrightnessctlBrightness() { - const output = await execAsync(['brightnessctl', '-m']); - const percent = parsePercent(output.split(',')[3] ?? output); - if (percent === null) throw new Error(`Unable to parse brightnessctl output: ${output.trim()}`); - return percent; -} - -async function getPercent() { - const selected = await resolveBackend(); - return selected === 'ddcutil' ? getDdcBrightness() : getBrightnessctlBrightness(); -} - -async function applyPercent(percent: number) { - const selected = await resolveBackend(); - if (selected === 'ddcutil') { - const displays = await Promise.all( - ddcBuses.map(async (bus) => ({ bus, ...(await getDdcBrightnessDetails(bus)) })), - ); - await Promise.all( - displays.map(({ bus, maximum }) => - execAsync([ - 'ddcutil', - '--bus', - bus, - 'setvcp', - DDC_VCP_BRIGHTNESS, - String(Math.round((percent / 100) * maximum)), - ]), - ), - ); - return; - } - - await execAsync(['brightnessctl', 'set', `${percent}%`]); -} - -function notify(percent: number) { - sendNotification({ - summary: `Brightness: ${percent}%`, - body: backend === 'ddcutil' ? 'DDC/CI display brightness' : 'System backlight brightness', - transient: true, - }); -} - -async function processSet() { - if (isSetting || pendingTarget === null) return; - - isSetting = true; - const target = pendingTarget; - pendingTarget = null; - - try { - await applyPercent(target); - const value = target / 100; - rememberNonZeroBrightness(value); - setBrightnessState(value); - } catch (error) { - console.error('Failed to set brightness:', error); - } finally { - isSetting = false; - if (pendingTarget !== null) void processSet(); - } -} - -export function setBrightness(value: number) { - const percent = clampPercent(value * 100); - const normalizedValue = percent / 100; - rememberNonZeroBrightness(normalizedValue); - setBrightnessState(normalizedValue); - pendingTarget = percent; - - if (setTimer) clearTimeout(setTimer); - setTimer = setTimeout(() => { - setTimer = null; - void processSet(); - }, 100); -} - -export function toggleBrightnessDim() { - const current = brightness(); - if (current > 0) { - lastNonZeroBrightness = current; - setBrightness(0); - return; - } - - setBrightness(lastNonZeroBrightness ?? DEFAULT_RESTORE_BRIGHTNESS); -} - -export function cycleBrightnessPreset() { - const current = brightness(); - const next = BRIGHTNESS_PRESETS.find((preset) => preset > current) ?? BRIGHTNESS_PRESETS[0]; - setBrightness(next); -} - -export async function changeBrightness(delta: number) { - try { - const next = clampPercent((await getPercent()) + delta); - pendingTarget = next; - if (setTimer) { - clearTimeout(setTimer); - setTimer = null; - } - await processSet(); - notify(next); - return next; - } catch (error) { - console.error('Failed to change brightness:', error); - throw error; - } -} - -export async function refreshBrightness() { - const percent = await getPercent(); - const value = percent / 100; - rememberNonZeroBrightness(value); - setBrightnessState(value); - return percent; -} - -export async function refreshBrightnessBackend() { - backend = null; - ddcBuses = []; - return refreshBrightness(); -} - -export async function getBrightnessBackend() { - return resolveBackend(); -} - -export const brightnessStep = KEYBOARD_STEP; - -void refreshBrightness().catch((error) => - console.error('Failed to fetch initial brightness:', error), -); diff --git a/src/stores/caffeine.ts b/src/stores/caffeine.ts deleted file mode 100644 index e4ce431..0000000 --- a/src/stores/caffeine.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { createState } from 'ags'; -import { execAsync } from 'ags/process'; - -import GLib from 'gi://GLib?version=2.0'; - -import { ryprlandRuntimeDir } from '../lib/paths'; - -const RUNTIME_DIR = `${ryprlandRuntimeDir}/rystal-shell`; -const CAFFEINE_REMOTE_FILE = `${RUNTIME_DIR}/caffeine-remote`; - -export type CaffeineState = 'disabled' | 'enabled' | 'remote'; - -const [caffeineStateValue, setCaffeineStateObj] = createState('disabled'); -export const caffeineState = caffeineStateValue; -let currentState: CaffeineState = 'disabled'; - -function setCaffeineState(val: CaffeineState) { - currentState = val; - setCaffeineStateObj(val); -} - -const IDLE_DAEMONS = ['hypridle', 'swayidle']; -let activeDaemon = 'hypridle'; - -function startInhibit() { - GLib.mkdir_with_parents(RUNTIME_DIR, 0o700); - const fd = GLib.creat(CAFFEINE_REMOTE_FILE, 0o644); - if (fd === -1) { - console.error(`Failed to create ${CAFFEINE_REMOTE_FILE}`); - } else { - GLib.close(fd); - } -} - -function stopInhibit() { - if ( - GLib.file_test(CAFFEINE_REMOTE_FILE, GLib.FileTest.EXISTS) && - GLib.remove(CAFFEINE_REMOTE_FILE) === -1 - ) { - console.error(`Failed to remove ${CAFFEINE_REMOTE_FILE}`); - } -} - -async function startDaemon() { - try { - await execAsync(['pidof', activeDaemon]); - } catch { - execAsync(['bash', '-c', `nohup ${activeDaemon} >/dev/null 2>&1 &`]).catch(console.error); - } -} - -function stopDaemon() { - execAsync(['killall', activeDaemon]).catch(() => {}); -} - -async function initCaffeine() { - let isDaemonRunning = false; - for (const daemon of IDLE_DAEMONS) { - try { - // eslint-disable-next-line no-await-in-loop - await execAsync(['pidof', daemon]); - activeDaemon = daemon; - isDaemonRunning = true; - break; - } catch { - // ignore - } - } - - const isInhibitRunning = GLib.file_test(CAFFEINE_REMOTE_FILE, GLib.FileTest.EXISTS); - - if (!isDaemonRunning) { - setCaffeineState('enabled'); - } else if (isInhibitRunning) { - setCaffeineState('remote'); - } else { - setCaffeineState('disabled'); - } -} -initCaffeine(); - -export function toggleCaffeine() { - if (currentState === 'disabled') { - // Disabled -> Enabled (Screen ON, No sleep) - stopDaemon(); - stopInhibit(); - setCaffeineState('enabled'); - } else if (currentState === 'enabled') { - // Enabled -> Remote (Screen OFF, No sleep) - startDaemon(); - startInhibit(); - setCaffeineState('remote'); - } else { - // Remote -> Disabled (Screen OFF, Sleep enabled) - startDaemon(); - stopInhibit(); - setCaffeineState('disabled'); - } -} diff --git a/src/stores/capture/recording.ts b/src/stores/capture/recording.ts new file mode 100644 index 0000000..bceceb6 --- /dev/null +++ b/src/stores/capture/recording.ts @@ -0,0 +1,259 @@ +import { createState } from 'ags'; +import { type Process, subprocess } from 'ags/process'; + +import Hyprland from 'gi://AstalHyprland'; +import Wp from 'gi://AstalWp'; +import GLib from 'gi://GLib'; + +import { appConfig } from '../../lib/config'; +import { sendNotification } from '../notification/send'; + +const [isRecordingState, setIsRecording] = createState(false); +export const isRecording = isRecordingState; + +export type RecordingMode = 'monitor' | 'slurp'; + +export type RecordingStartResult = + | { status: 'started'; path: string } + | { status: 'already-active' } + | { status: 'cancelled' } + | { status: 'failed'; error: string }; + +export type RecordingStopResult = + | { status: 'stopping' } + | { status: 'not-recording' } + | { status: 'already-stopping' } + | { status: 'failed'; error: string }; + +const SIGINT = 2; +const SIGKILL = 9; + +interface ActiveRecording { + process: ReturnType; + path: string; + stopRequested: boolean; + stderrLines: string[]; +} + +interface RecordingStartSession { + process: Process | null; + cancelled: boolean; +} + +let activeRecording: ActiveRecording | null = null; +let activeStartSession: RecordingStartSession | null = null; +let shuttingDown = false; + +function notifyFailure(body: string) { + sendNotification({ + summary: 'Recording failed', + body, + }); +} + +function finishRecording(recording: ActiveRecording, code: number, signaled: boolean) { + if (activeRecording !== recording) return; + + activeRecording = null; + setIsRecording(false); + + const stoppedNormally = + (!signaled && code === 0) || + (recording.stopRequested && signaled && (code === SIGINT || code === SIGKILL)); + + if (stoppedNormally) { + sendNotification({ + summary: 'Recording saved', + body: `Saved to ${recording.path}`, + }); + return; + } + + const detail = + recording.stderrLines.slice(-5).join('\n') || + `wf-recorder exited with ${signaled ? 'signal' : 'code'} ${code}`; + console.error(`Recording failed: ${detail}`); + notifyFailure(detail); +} + +function getRecordingPath() { + const now = GLib.DateTime.new_now_local(); + const format = appConfig.recorder?.filenameFormat || 'recording_%Y-%m-%d_%H.%M.%S.mp4'; + const filename = now.format(format) || `recording_${Date.now()}.mp4`; + + let savePath = appConfig.recorder?.savePath || '~/Videos'; + if (savePath === '~' || savePath.startsWith('~/')) { + savePath = `${GLib.get_home_dir()}${savePath.slice(1)}`; + } + savePath = GLib.canonicalize_filename(savePath, GLib.get_current_dir()); + + if (GLib.mkdir_with_parents(savePath, 0o755) === -1) { + throw new Error(`Failed to create directory: ${savePath}`); + } + + return `${savePath}/${filename}`; +} + +function addAudioOptions(cmd: string[]) { + if (appConfig.recorder?.recordAudio === false) return; + + if (appConfig.recorder?.audioSource === 'mic') { + cmd.push('-a'); + return; + } + + const speaker = Wp.get_default().audio.get_default_speaker(); + const speakerName = speaker?.get_pw_property('node.name')?.trim() || speaker?.name?.trim(); + if (!speakerName) throw new Error('Default speaker has no PipeWire node name'); + cmd.push(`--audio=${speakerName}.monitor`); +} + +async function addCaptureTarget( + cmd: string[], + mode: RecordingMode, + session: RecordingStartSession, +): Promise { + if (mode === 'monitor') { + const monitor = Hyprland.get_default().get_focused_monitor(); + if (!monitor) { + const error = 'No focused monitor found for recording'; + console.error(error); + notifyFailure(error); + return { status: 'failed', error }; + } + cmd.push('-o', monitor.name); + return null; + } + + const output: string[] = []; + const errors: string[] = []; + const process = subprocess({ + cmd: ['slurp'], + out: (line) => output.push(line), + err: (line) => errors.push(line), + }); + session.process = process; + + const result = await new Promise((resolve) => { + process.connect('exit', (_, code, signaled) => { + if (session.process === process) session.process = null; + if (session.cancelled || signaled || code !== 0) { + resolve({ status: 'cancelled' }); + return; + } + + const region = output.join('\n').trim(); + if (!region) { + resolve({ status: 'cancelled' }); + return; + } + cmd.push('--geometry', region); + resolve(null); + }); + }); + + if (result?.status === 'cancelled' && !session.cancelled) { + if (errors.length > 0) console.warn(`Slurp cancelled: ${errors.join('\n')}`); + sendNotification({ + summary: 'Recording cancelled', + body: 'Selection was cancelled', + transient: true, + }); + } + return result; +} + +export async function startRecord(mode: RecordingMode): Promise { + if (shuttingDown) return { status: 'failed', error: 'Application is shutting down' }; + if (activeRecording || activeStartSession) return { status: 'already-active' }; + + const session: RecordingStartSession = { process: null, cancelled: false }; + activeStartSession = session; + + try { + const fullPath = getRecordingPath(); + const cmd = ['wf-recorder', '--pixel-format', 'yuv420p', '-f', fullPath]; + addAudioOptions(cmd); + const targetResult = await addCaptureTarget(cmd, mode, session); + if (targetResult) return targetResult; + if (session.cancelled || shuttingDown) return { status: 'cancelled' }; + + const process = subprocess({ + cmd, + err: (line) => { + const recording = activeRecording; + if (recording?.process !== process) return; + recording.stderrLines.push(line); + if (recording.stderrLines.length > 20) recording.stderrLines.shift(); + }, + }); + + const recording: ActiveRecording = { + process, + path: fullPath, + stopRequested: false, + stderrLines: [], + }; + activeRecording = recording; + process.connect('exit', (_, code, signaled) => finishRecording(recording, code, signaled)); + + sendNotification({ + summary: 'Recording started', + body: `Recording to ${fullPath}`, + transient: true, + }); + setIsRecording(true); + return { status: 'started', path: fullPath }; + } catch (e) { + console.error('Failed to start recording', e); + const error = e instanceof Error ? e.message : String(e); + notifyFailure(error); + return { status: 'failed', error }; + } finally { + if (activeStartSession === session) activeStartSession = null; + } +} + +export function stopRecord(): RecordingStopResult { + const recording = activeRecording; + if (!recording) return { status: 'not-recording' }; + if (recording.stopRequested) return { status: 'already-stopping' }; + + recording.stopRequested = true; + try { + recording.process.signal(SIGINT); + return { status: 'stopping' }; + } catch (error) { + recording.stopRequested = false; + console.error('Failed to stop recording', error); + const detail = error instanceof Error ? error.message : String(error); + notifyFailure(detail); + return { status: 'failed', error: detail }; + } +} + +export function cleanupRecording(): RecordingStopResult { + shuttingDown = true; + const startSession = activeStartSession; + if (startSession) { + startSession.cancelled = true; + try { + startSession.process?.kill(); + } catch (error) { + console.error('Failed to cancel recording selection', error); + } + } + + const recording = activeRecording; + if (!recording) return { status: 'not-recording' }; + + recording.stopRequested = true; + try { + recording.process.kill(); + return { status: 'stopping' }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.error('Failed to terminate recording process', error); + return { status: 'failed', error: detail }; + } +} diff --git a/src/stores/capture/screenCapture.ts b/src/stores/capture/screenCapture.ts new file mode 100644 index 0000000..52a5fea --- /dev/null +++ b/src/stores/capture/screenCapture.ts @@ -0,0 +1,32 @@ +import { execAsync } from 'ags/process'; +import { type Timer, timeout } from 'ags/time'; + +import { closeAllControlCenters } from '../shell/windowManager'; +import { type RecordingMode, startRecord } from './recording'; + +export type ScreenshotMode = 'crop' | 'freeze' | 'monitor'; + +const CAPTURE_DELAY_MS = 300; + +let pendingCapture: Timer | null = null; + +function scheduleCapture(action: () => void) { + closeAllControlCenters(); + pendingCapture?.cancel(); + pendingCapture = timeout(CAPTURE_DELAY_MS, () => { + pendingCapture = null; + action(); + }); +} + +export function captureScreenshot(mode: ScreenshotMode) { + scheduleCapture(() => { + execAsync(`hyprcrop ${mode}`).catch(console.error); + }); +} + +export function beginRecording(mode: RecordingMode) { + scheduleCapture(() => { + void startRecord(mode); + }); +} diff --git a/src/stores/common/animatedList.ts b/src/stores/common/animatedList.ts new file mode 100644 index 0000000..a9dc8c2 --- /dev/null +++ b/src/stores/common/animatedList.ts @@ -0,0 +1,97 @@ +import { type Accessor, type Setter, createState, onCleanup } from 'ags'; +import { type Timer, timeout } from 'ags/time'; + +import { shellMotion } from '../../lib/motion'; + +export interface AnimatedListEntry { + id: string; + item: Accessor; + setItem: Setter; + revealed: Accessor; + setRevealed: Setter; +} + +function cancelTimer(timers: Map, id: string) { + timers.get(id)?.cancel(); + timers.delete(id); +} + +function uniqueItemsById(items: T[], idFor: (item: T) => string) { + const seenIds = new Set(); + return items.filter((item) => { + const id = idFor(item); + if (seenIds.has(id)) return false; + seenIds.add(id); + return true; + }); +} + +export function createAnimatedListEntries( + items: Accessor, + idFor: (item: T) => string, +): Accessor[]> { + const [entries, setEntries] = createState[]>([]); + const revealTimers = new Map(); + const removeTimers = new Map(); + + const createEntry = (item: T): AnimatedListEntry => { + const [itemState, setItem] = createState(item, { equals: () => false }); + const [revealed, setRevealed] = createState(false); + const id = idFor(item); + const timer = timeout(shellMotion.listRevealDelay, () => { + setRevealed(true); + revealTimers.delete(id); + }); + revealTimers.set(id, timer); + return { id, item: itemState, setItem, revealed, setRevealed }; + }; + + const scheduleRemoval = (entry: AnimatedListEntry) => { + cancelTimer(revealTimers, entry.id); + entry.setRevealed(false); + const timer = timeout(shellMotion.listDuration, () => { + setEntries(entries.peek().filter((candidate) => candidate.id !== entry.id)); + removeTimers.delete(entry.id); + }); + removeTimers.set(entry.id, timer); + }; + + const updateEntries = () => { + const nextItems = uniqueItemsById(items.peek(), idFor); + const previousEntries = entries.peek(); + const previousById = new Map(previousEntries.map((entry) => [entry.id, entry])); + const nextIds = new Set(nextItems.map(idFor)); + + const nextEntries = nextItems.map((item) => { + const id = idFor(item); + const previousEntry = previousById.get(id); + if (!previousEntry) return createEntry(item); + + previousEntry.setItem(item); + if (removeTimers.has(id)) { + cancelTimer(removeTimers, id); + previousEntry.setRevealed(true); + } + return previousEntry; + }); + + for (const entry of previousEntries) { + if (!nextIds.has(entry.id) && !removeTimers.has(entry.id)) scheduleRemoval(entry); + } + + setEntries([...nextEntries, ...previousEntries.filter((entry) => !nextIds.has(entry.id))]); + }; + + const unsubscribe = items.subscribe(updateEntries); + updateEntries(); + + onCleanup(() => { + unsubscribe(); + for (const timer of revealTimers.values()) timer.cancel(); + for (const timer of removeTimers.values()) timer.cancel(); + revealTimers.clear(); + removeTimers.clear(); + }); + + return entries; +} diff --git a/src/stores/connectivity/bluetooth.ts b/src/stores/connectivity/bluetooth.ts new file mode 100644 index 0000000..95644c8 --- /dev/null +++ b/src/stores/connectivity/bluetooth.ts @@ -0,0 +1,233 @@ +import { type Accessor, createState, onCleanup } from 'ags'; +import { type Timer, timeout } from 'ags/time'; + +import Bluetooth from 'gi://AstalBluetooth'; + +const DISCOVERY_DURATION_MS = 15_000; + +interface BluetoothDeviceSignalTracker { + sync: (devices: Bluetooth.Device[]) => void; + dispose: () => void; +} + +function connectBluetoothDevice(device: Bluetooth.Device): Promise { + return new Promise((resolve, reject) => { + try { + device.connect_device((_source, result) => { + try { + device.connect_device_finish(result); + resolve(); + } catch (reason) { + reject(reason); + } + }); + } catch (reason) { + reject(reason); + } + }); +} + +function disconnectBluetoothDevice(device: Bluetooth.Device): Promise { + return new Promise((resolve, reject) => { + try { + device.disconnect_device((_source, result) => { + try { + device.disconnect_device_finish(result); + resolve(); + } catch (reason) { + reject(reason); + } + }); + } catch (reason) { + reject(reason); + } + }); +} + +function createBluetoothDeviceSignalTracker( + onDeviceChanged: () => void, +): BluetoothDeviceSignalTracker { + const hooks = new Map(); + + const disconnectHooks = (device: Bluetooth.Device, signalIds: number[]) => { + signalIds.forEach((signalId) => device.disconnect(signalId)); + }; + + const sync = (devices: Bluetooth.Device[]) => { + const currentDevices = new Set(devices); + + hooks.forEach((signalIds, device) => { + if (currentDevices.has(device)) return; + disconnectHooks(device, signalIds); + hooks.delete(device); + }); + + devices.forEach((device) => { + if (hooks.has(device)) return; + hooks.set(device, [ + device.connect('notify::connected', onDeviceChanged), + device.connect('notify::paired', onDeviceChanged), + ]); + }); + }; + + const dispose = () => { + hooks.forEach((signalIds, device) => disconnectHooks(device, signalIds)); + hooks.clear(); + }; + + return { sync, dispose }; +} + +export interface BluetoothConfirmation { + title: string; + message: string; + confirmLabel: string; + onConfirm: () => Promise; +} + +export interface BluetoothPageState { + bluetooth: ReturnType; + adapter: ReturnType['adapter']; + connectedDevices: Accessor; + availableDevices: Accessor; + error: Accessor; + confirmation: Accessor; + discover: () => void; + connectDevice: (device: Bluetooth.Device) => Promise; + requestDisconnect: (device: Bluetooth.Device) => void; + requestForget: (device: Bluetooth.Device) => void; + clearConfirmation: () => void; + setError: (message: string) => void; +} + +export function createBluetoothPageState(active: Accessor): BluetoothPageState { + const bluetooth = Bluetooth.get_default(); + const adapter = bluetooth.adapter; + const [devices, setDevices] = createState(bluetooth.devices ?? []); + const [error, setError] = createState(''); + const [confirmation, setConfirmation] = createState(null); + const connectedDevices = devices.as((list) => list.filter((device) => device.connected)); + const availableDevices = devices.as((list) => + list.filter((device) => device.paired && !device.connected), + ); + let discoveryTimer: Timer | null = null; + let disposed = false; + const updateError = (message: string) => { + if (!disposed) setError(message); + }; + const refreshDeviceState = () => { + if (!disposed) setDevices([...(bluetooth.devices ?? [])]); + }; + const deviceSignalTracker = createBluetoothDeviceSignalTracker(refreshDeviceState); + + const refreshDevices = () => { + if (disposed) return; + const currentDevices = [...(bluetooth.devices ?? [])]; + deviceSignalTracker.sync(currentDevices); + setDevices(currentDevices); + }; + + const stopDiscovery = () => { + discoveryTimer?.cancel(); + discoveryTimer = null; + if (!adapter) return; + + try { + adapter.stop_discovery(); + } catch { + /* Adapter may already have stopped. */ + } + }; + + const discover = () => { + if (!adapter) return; + + updateError(''); + stopDiscovery(); + try { + adapter.start_discovery(); + discoveryTimer = timeout(DISCOVERY_DURATION_MS, () => { + discoveryTimer = null; + stopDiscovery(); + }); + } catch (reason) { + updateError(String(reason)); + } + }; + + const connectDevice = async (device: Bluetooth.Device) => { + updateError(''); + try { + await connectBluetoothDevice(device); + refreshDevices(); + } catch (reason) { + updateError(String(reason)); + } + }; + + const requestDisconnect = (device: Bluetooth.Device) => { + if (disposed) return; + setConfirmation({ + title: 'Disconnect Bluetooth device', + message: `Disconnect ${device.alias}?`, + confirmLabel: 'Disconnect', + onConfirm: async () => { + await disconnectBluetoothDevice(device); + refreshDevices(); + }, + }); + }; + + const requestForget = (device: Bluetooth.Device) => { + if (disposed || !adapter) return; + setConfirmation({ + title: 'Forget Bluetooth device', + message: `Remove the pairing for ${device.alias}?`, + confirmLabel: 'Forget', + onConfirm: async () => { + adapter.remove_device(device); + refreshDevices(); + }, + }); + }; + + const clearConfirmation = () => { + if (!disposed) setConfirmation(null); + }; + const hooks = [ + bluetooth.connect('notify::devices', refreshDevices), + bluetooth.connect('device-added', refreshDevices), + bluetooth.connect('device-removed', refreshDevices), + ]; + const unsubscribeActive = active.subscribe(() => { + if (active.peek()) discover(); + else stopDiscovery(); + }); + + if (active.peek()) discover(); + refreshDevices(); + + onCleanup(() => { + disposed = true; + hooks.forEach((hook) => bluetooth.disconnect(hook)); + deviceSignalTracker.dispose(); + unsubscribeActive(); + stopDiscovery(); + }); + + return { + bluetooth, + adapter, + connectedDevices, + availableDevices, + error, + confirmation, + discover, + connectDevice, + requestDisconnect, + requestForget, + clearConfirmation, + setError: updateError, + }; +} diff --git a/src/stores/connectivity/network.ts b/src/stores/connectivity/network.ts new file mode 100644 index 0000000..37bbfd0 --- /dev/null +++ b/src/stores/connectivity/network.ts @@ -0,0 +1,9 @@ +import { execAsync } from 'ags/process'; + +export function toggleWifi(enabled: boolean) { + execAsync(['nmcli', 'radio', 'wifi', enabled ? 'off' : 'on']).catch(console.error); +} + +export function toggleBluetooth(powered: boolean) { + execAsync(['rfkill', powered ? 'block' : 'unblock', 'bluetooth']).catch(console.error); +} diff --git a/src/stores/wifi.ts b/src/stores/connectivity/wifi.ts similarity index 100% rename from src/stores/wifi.ts rename to src/stores/connectivity/wifi.ts diff --git a/src/stores/connectivity/wifiPage.ts b/src/stores/connectivity/wifiPage.ts new file mode 100644 index 0000000..7a6555e --- /dev/null +++ b/src/stores/connectivity/wifiPage.ts @@ -0,0 +1,225 @@ +import { type Accessor, createState, onCleanup } from 'ags'; + +import Network from 'gi://AstalNetwork'; +import GObject from 'gi://GObject'; +import NM from 'gi://NM'; + +import { + connectWifi, + deleteWifiProfiles, + getWifiProfileDuplicates, + hasWifiProfile, + listWifiAccessPoints, +} from './wifi'; +import { openWifiPasswordDialog } from './wifiPasswordDialog'; + +export interface WifiConfirmation { + title: string; + message: string; + confirmLabel: string; + onConfirm: () => Promise; +} + +export interface WifiPageState { + wifi: Network.Wifi | null; + activeAccessPoint: Accessor; + availableAccessPoints: Accessor; + error: Accessor; + confirmation: Accessor; + scan: () => void; + selectAccessPoint: (accessPoint: Network.AccessPoint) => Promise; + retryPassword: () => void; + requestDisconnect: (accessPoint: Network.AccessPoint) => void; + requestForget: (accessPoint: Network.AccessPoint) => void; + clearConfirmation: () => void; + setError: (message: string) => void; +} + +export function createWifiPageState(monitorConnector: string): WifiPageState { + const network = Network.get_default(); + const wifi = network.wifi; + const [accessPoints, setAccessPoints] = createState( + wifi?.access_points ?? [], + ); + const [error, setError] = createState(''); + const [confirmation, setConfirmation] = createState(null); + let retryAccessPoint: Network.AccessPoint | null = null; + let pendingAccessPoint: Network.AccessPoint | null = null; + + const activeAccessPoint = accessPoints.as(() => wifi?.active_access_point ?? null); + const availableAccessPoints = accessPoints.as((list) => + list.filter((accessPoint) => accessPoint !== wifi?.active_access_point), + ); + + const refreshAccessPoints = () => { + if (wifi) setAccessPoints(listWifiAccessPoints(wifi)); + }; + + const scan = () => { + if (!wifi) return; + + setError(''); + try { + wifi.scan(); + refreshAccessPoints(); + } catch (reason) { + setError(String(reason)); + } + }; + + const deactivateWifi = () => + new Promise((resolve, reject) => { + if (!wifi) { + reject(new Error('No Wi-Fi adapter available')); + return; + } + + try { + wifi.deactivate_connection((_source, result) => { + try { + wifi.deactivate_connection_finish(result); + resolve(); + } catch (reason) { + reject(reason); + } + }); + } catch (reason) { + reject(reason); + } + }); + + const connect = async (accessPoint: Network.AccessPoint, password?: string) => { + if (!wifi) return; + + setError(''); + retryAccessPoint = null; + pendingAccessPoint = accessPoint; + await connectWifi(network, wifi, accessPoint, password); + refreshAccessPoints(); + }; + + const requestPassword = (accessPoint: Network.AccessPoint) => { + openWifiPasswordDialog({ + monitor: monitorConnector, + ssid: accessPoint.ssid || 'network', + submit: (password) => connect(accessPoint, password), + }); + }; + + const selectAccessPoint = async (accessPoint: Network.AccessPoint) => { + try { + const duplicates = getWifiProfileDuplicates(network, accessPoint); + if (duplicates.length) { + setConfirmation({ + title: 'Clean up duplicate Wi-Fi profiles', + message: `Keep ${accessPoint.ssid || 'this network'} and remove: ${duplicates.map((item) => item.id).join(', ')}?`, + confirmLabel: 'Remove and connect', + onConfirm: async () => { + await deleteWifiProfiles(duplicates); + await connect(accessPoint); + }, + }); + return; + } + if (accessPoint.requires_password && !hasWifiProfile(network, accessPoint)) { + requestPassword(accessPoint); + return; + } + await connect(accessPoint); + } catch (reason) { + pendingAccessPoint = null; + retryAccessPoint = accessPoint; + setError(String(reason)); + } + }; + + const retryPassword = () => { + if (retryAccessPoint) requestPassword(retryAccessPoint); + }; + + const requestDisconnect = (accessPoint: Network.AccessPoint) => { + setConfirmation({ + title: 'Disconnect Wi-Fi', + message: `Disconnect from ${accessPoint.ssid || 'this network'}?`, + confirmLabel: 'Disconnect', + onConfirm: deactivateWifi, + }); + }; + + const requestForget = (accessPoint: Network.AccessPoint) => { + if (!wifi) return; + setConfirmation({ + title: 'Forget Wi-Fi network', + message: `Remove the saved connection for ${accessPoint.ssid || 'this network'}?`, + confirmLabel: 'Forget', + onConfirm: async () => { + const connection = wifi.active_connection?.connection; + if (!connection) throw new Error('No saved connection profile was found'); + await deactivateWifi(); + await new Promise((resolve, reject) => { + connection.delete_async(null, (_source, result) => { + try { + connection.delete_finish(result); + resolve(); + } catch (reason) { + reject(reason); + } + }); + }); + refreshAccessPoints(); + }, + }); + }; + + const clearConfirmation = () => setConfirmation(null); + const hooks = wifi + ? [ + wifi.connect('notify::access-points', refreshAccessPoints), + wifi.connect('notify::active-access-point', refreshAccessPoints), + wifi.connect('access-point-added', refreshAccessPoints), + wifi.connect('access-point-removed', refreshAccessPoints), + ] + : []; + const deviceHook = wifi + ? wifi.device.connect('state-changed', (_device, state: NM.DeviceState) => { + const pending = pendingAccessPoint; + if (!pending) return; + if (state === NM.DeviceState.ACTIVATED) { + pendingAccessPoint = null; + } else if (state === NM.DeviceState.NEED_AUTH || state === NM.DeviceState.FAILED) { + pendingAccessPoint = null; + retryAccessPoint = pending; + setError( + `Could not connect to ${pending.ssid || 'this network'}. Enter the password and retry.`, + ); + } + }) + : null; + + if (wifi) scan(); + + onCleanup(() => { + if (!wifi) return; + hooks.forEach((hook) => wifi.disconnect(hook)); + if (deviceHook !== null) { + GObject.Object.prototype.disconnect.call(wifi.device, deviceHook); + } + pendingAccessPoint = null; + retryAccessPoint = null; + }); + + return { + wifi, + activeAccessPoint, + availableAccessPoints, + error, + confirmation, + scan, + selectAccessPoint, + retryPassword, + requestDisconnect, + requestForget, + clearConfirmation, + setError, + }; +} diff --git a/src/stores/connectivity/wifiPasswordDialog.ts b/src/stores/connectivity/wifiPasswordDialog.ts new file mode 100644 index 0000000..f03fe7a --- /dev/null +++ b/src/stores/connectivity/wifiPasswordDialog.ts @@ -0,0 +1,74 @@ +import { type Accessor, createState, onCleanup } from 'ags'; + +export interface WifiPasswordRequest { + monitor: string; + ssid: string; + submit: (password: string) => Promise; +} + +export interface WifiPasswordDialogState { + visible: Accessor; + ssid: Accessor; + connectLabel: Accessor; + busy: Accessor; + error: Accessor; + activation: Accessor; + submit: (password: string) => Promise; + close: () => void; +} + +const [wifiPasswordRequestState, setWifiPasswordRequest] = createState( + null, +); +const wifiPasswordRequest = wifiPasswordRequestState; + +export function openWifiPasswordDialog(request: WifiPasswordRequest) { + setWifiPasswordRequest(request); +} + +function closeWifiPasswordDialog(monitor?: string) { + const request = wifiPasswordRequest.peek(); + if (!monitor || request?.monitor === monitor) setWifiPasswordRequest(null); +} + +export function createWifiPasswordDialogState(monitorConnector: string): WifiPasswordDialogState { + const [busy, setBusy] = createState(false); + const [error, setError] = createState(''); + const [activation, setActivation] = createState(0); + const visible = wifiPasswordRequest.as((request) => request?.monitor === monitorConnector); + const ssid = wifiPasswordRequest.as((request) => request?.ssid ?? ''); + const connectLabel = wifiPasswordRequest.as((request) => + request ? `Connect to ${request.ssid}` : 'Connect', + ); + + const unsubscribe = wifiPasswordRequest.subscribe(() => { + const request = wifiPasswordRequest.peek(); + if (request?.monitor !== monitorConnector) return; + + setBusy(false); + setError(''); + setActivation((value) => value + 1); + }); + + const submit = async (password: string) => { + const request = wifiPasswordRequest.peek(); + if (!request || request.monitor !== monitorConnector || !password) return; + + setBusy(true); + setError(''); + try { + await request.submit(password); + closeWifiPasswordDialog(monitorConnector); + } catch (reason) { + setError(String(reason)); + } finally { + setBusy(false); + } + }; + + const close = () => closeWifiPasswordDialog(monitorConnector); + + onCleanup(unsubscribe); + + return { visible, ssid, connectLabel, busy, error, activation, submit, close }; +} diff --git a/src/stores/media/cava.ts b/src/stores/media/cava.ts new file mode 100644 index 0000000..258c58f --- /dev/null +++ b/src/stores/media/cava.ts @@ -0,0 +1,34 @@ +import AstalCava from 'gi://AstalCava'; + +let cavaInstance: AstalCava.Cava | null = null; +let activeConsumers = 0; + +function getCava() { + cavaInstance ??= AstalCava.get_default(); + return cavaInstance; +} + +export function isCavaAvailable() { + return Boolean(getCava()); +} + +export function acquireCava(onValuesChanged: () => void) { + const cava = getCava(); + if (!cava) return null; + + activeConsumers++; + if (activeConsumers === 1) cava.active = true; + const valuesHook = cava.connect('notify::values', onValuesChanged); + let released = false; + + return { + cava, + release: () => { + if (released) return; + released = true; + cava.disconnect(valuesHook); + activeConsumers = Math.max(0, activeConsumers - 1); + if (activeConsumers === 0) cava.active = false; + }, + }; +} diff --git a/src/stores/media/media.ts b/src/stores/media/media.ts new file mode 100644 index 0000000..2856f1e --- /dev/null +++ b/src/stores/media/media.ts @@ -0,0 +1,145 @@ +import { type Accessor, createState, onCleanup } from 'ags'; + +import Mpris from 'gi://AstalMpris'; +import Gdk from 'gi://Gdk'; +import Gio from 'gi://Gio'; + +import { loadTextureFromUri } from '../../lib/image'; +import { closeAllControlCenters, focusWindow } from '../shell/windowManager'; +import { fetchYouTubeThumbnail } from './mprisThumbnail'; + +export interface MediaCardState { + activePlayer: Accessor; + hasPlayers: Accessor; + canSwitch: Accessor; + switchPlayer: () => void; +} + +export function createMediaCardState(): MediaCardState { + const mpris = Mpris.get_default(); + const [players, setPlayers] = createState(mpris.get_players()); + const [activePlayer, setActivePlayer] = createState(null); + const hasPlayers = players.as((list) => list.length > 0); + const canSwitch = players.as((list) => list.length > 1); + + const refreshPlayers = () => { + const nextPlayers = mpris.get_players(); + setPlayers(nextPlayers); + const current = activePlayer.peek(); + if (!current || !nextPlayers.some((player) => player.bus_name === current.bus_name)) { + setActivePlayer(nextPlayers[0] ?? null); + } + }; + + const switchPlayer = () => { + const list = players.peek(); + const current = activePlayer.peek(); + if (!current || list.length <= 1) return; + + const currentIndex = list.findIndex((player) => player.bus_name === current.bus_name); + setActivePlayer(list[(currentIndex + 1) % list.length]); + }; + + const hook = mpris.connect('notify::players', refreshPlayers); + refreshPlayers(); + onCleanup(() => mpris.disconnect(hook)); + + return { activePlayer, hasPlayers, canSwitch, switchPlayer }; +} + +function artworkUri(path: string) { + if (path.startsWith('file://')) return path; + if (path.startsWith('/')) return `file://${path}`; + return null; +} + +export function createPlayerArtwork(player: Mpris.Player): Accessor { + const [artwork, setArtwork] = createState(null); + let disposed = false; + let updateGeneration = 0; + let lastYouTubeArt: string | null = null; + let thumbnailCancellable: Gio.Cancellable | null = null; + + const setArtworkFromPath = (path: string | null) => { + const uri = path ? artworkUri(path) : null; + if (!uri) { + setArtwork(null); + return; + } + + try { + setArtwork(loadTextureFromUri(uri, 160, 160)); + } catch (error) { + console.error(error); + setArtwork(null); + } + }; + + const updateArtwork = async () => { + const generation = ++updateGeneration; + thumbnailCancellable?.cancel(); + thumbnailCancellable = null; + const coverArt = player.cover_art; + if (coverArt) { + lastYouTubeArt = null; + setArtworkFromPath(coverArt); + return; + } + + try { + const cancellable = new Gio.Cancellable(); + thumbnailCancellable = cancellable; + const youtubeArt = await fetchYouTubeThumbnail(player, cancellable); + if (disposed || generation !== updateGeneration) return; + if (youtubeArt && youtubeArt === lastYouTubeArt) return; + lastYouTubeArt = youtubeArt; + setArtworkFromPath(youtubeArt); + } catch (error) { + if (disposed || generation !== updateGeneration) return; + console.error(error); + setArtwork(null); + } finally { + if (generation === updateGeneration) thumbnailCancellable = null; + } + }; + + const coverArtHook = player.connect('notify::cover-art', () => void updateArtwork()); + const metadataHook = player.connect('notify::metadata', () => void updateArtwork()); + void updateArtwork(); + + onCleanup(() => { + disposed = true; + updateGeneration++; + thumbnailCancellable?.cancel(); + thumbnailCancellable = null; + player.disconnect(coverArtHook); + player.disconnect(metadataHook); + setArtwork(null); + }); + + return artwork; +} + +export function focusMediaPlayer(player: Mpris.Player) { + try { + player.raise(); + } catch (error) { + console.error(error); + } + if (!player.entry) return; + + focusWindow(player.entry); + closeAllControlCenters(); +} + +export function playPrevious(player: Mpris.Player) { + player.previous(); +} + +export function togglePlayback(player: Mpris.Player) { + player.play_pause(); +} + +export function playNext(player: Mpris.Player) { + player.next(); +} diff --git a/src/stores/media/mprisThumbnail.ts b/src/stores/media/mprisThumbnail.ts new file mode 100644 index 0000000..5853de1 --- /dev/null +++ b/src/stores/media/mprisThumbnail.ts @@ -0,0 +1,120 @@ +import Mpris from 'gi://AstalMpris'; +import GLib from 'gi://GLib?version=2.0'; +import Gio from 'gi://Gio'; +import Soup from 'gi://Soup?version=3.0'; + +import { ryprlandCacheDir } from '../../lib/paths'; + +const DOWNLOAD_TIMEOUT_SECONDS = 10; +const YOUTUBE_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; +const thumbnailSession = new Soup.Session({ timeout: DOWNLOAD_TIMEOUT_SECONDS }); + +function getYouTubeVideoId(url: string) { + try { + const uri = GLib.Uri.parse(url, GLib.UriFlags.NONE); + const host = uri.get_host()?.toLowerCase(); + const path = uri.get_path(); + + if (host === 'youtu.be') { + const id = path.replace(/^\//, '').split('/')[0]; + return YOUTUBE_ID_PATTERN.test(id) ? id : null; + } + + if (!['youtube.com', 'www.youtube.com', 'm.youtube.com'].includes(host ?? '')) return null; + if (path !== '/watch') return null; + + const query = uri.get_query() ?? ''; + const value = query + .split('&') + .map((part) => part.split('=', 2)) + .find(([key]) => key === 'v')?.[1]; + if (!value) return null; + + const id = decodeURIComponent(value); + return YOUTUBE_ID_PATTERN.test(id) ? id : null; + } catch { + return null; + } +} + +function sendAndRead(message: Soup.Message, cancellable: Gio.Cancellable): Promise { + return new Promise((resolve, reject) => { + thumbnailSession.send_and_read_async( + message, + GLib.PRIORITY_DEFAULT, + cancellable, + (_session, result) => { + try { + resolve(thumbnailSession.send_and_read_finish(result)); + } catch (error) { + reject(error); + } + }, + ); + }); +} + +async function downloadThumbnailUrl(url: string, localPath: string, cancellable: Gio.Cancellable) { + try { + const message = Soup.Message.new('GET', url); + const bytes = await sendAndRead(message, cancellable); + if (cancellable.is_cancelled()) return false; + if (message.status_code < 200 || message.status_code >= 300) return false; + + const data = bytes.get_data(); + if (data && data.length > 0) { + GLib.file_set_contents(localPath, data); + return true; + } + } catch (error) { + if (cancellable.is_cancelled()) return false; + console.error(`Failed to download YouTube thumbnail from ${url}:`, error); + } + + return false; +} + +async function downloadThumbnail(id: string, localPath: string, cancellable: Gio.Cancellable) { + // The card renders at 80x80 (160x160 decode limit), so downloading the + // 1280x720 max-resolution image only increases JPEG/Pixbuf working memory. + const mediumQualityUrl = `https://img.youtube.com/vi/${id}/mqdefault.jpg`; + if (await downloadThumbnailUrl(mediumQualityUrl, localPath, cancellable)) { + return `file://${localPath}`; + } + if (cancellable.is_cancelled()) return null; + + const fallbackUrl = `https://img.youtube.com/vi/${id}/default.jpg`; + if (await downloadThumbnailUrl(fallbackUrl, localPath, cancellable)) { + return `file://${localPath}`; + } + + return null; +} + +export async function fetchYouTubeThumbnail( + player: Mpris.Player, + cancellable: Gio.Cancellable, +): Promise { + const busName = player.bus_name; + if (!busName) return null; + + let url = ''; + try { + const metaVariant = player.get_meta('xesam:url'); + if (metaVariant) { + url = metaVariant.get_string()[0]; + } + } catch (e) { + console.error('Failed to get xesam:url:', e); + } + + const id = getYouTubeVideoId(url); + if (!id) return null; + + const cacheDir = `${ryprlandCacheDir}/rystal-shell/media`; + const localPath = `${cacheDir}/${id}-mq.jpg`; + if (GLib.file_test(localPath, GLib.FileTest.EXISTS)) return `file://${localPath}`; + + GLib.mkdir_with_parents(cacheDir, 0o755); + return downloadThumbnail(id, localPath, cancellable); +} diff --git a/src/stores/mprisThumbnail.ts b/src/stores/mprisThumbnail.ts deleted file mode 100644 index b027f31..0000000 --- a/src/stores/mprisThumbnail.ts +++ /dev/null @@ -1,96 +0,0 @@ -import Mpris from 'gi://AstalMpris'; -import GLib from 'gi://GLib?version=2.0'; -import Soup from 'gi://Soup?version=3.0'; - -import { ryprlandCacheDir } from '../lib/paths'; - -const thumbnailSession = new Soup.Session(); -const thumbnailDownloads = new Map>(); - -function sendAndRead(message: Soup.Message): Promise { - return new Promise((resolve, reject) => { - thumbnailSession.send_and_read_async( - message, - GLib.PRIORITY_DEFAULT, - null, - (_session, result) => { - try { - resolve(thumbnailSession.send_and_read_finish(result)); - } catch (error) { - reject(error); - } - }, - ); - }); -} - -async function downloadThumbnailUrl(url: string, localPath: string) { - try { - const message = Soup.Message.new('GET', url); - const bytes = await sendAndRead(message); - if (message.status_code < 200 || message.status_code >= 300) return false; - - const data = bytes.get_data(); - if (data && data.length > 0) { - GLib.file_set_contents(localPath, data); - return true; - } - } catch (error) { - console.error(`Failed to download YouTube thumbnail from ${url}:`, error); - } - - return false; -} - -async function downloadThumbnail(id: string, localPath: string) { - // The card renders at 80x80 (160x160 decode limit), so downloading the - // 1280x720 max-resolution image only increases JPEG/Pixbuf working memory. - const mediumQualityUrl = `https://img.youtube.com/vi/${id}/mqdefault.jpg`; - if (await downloadThumbnailUrl(mediumQualityUrl, localPath)) return `file://${localPath}`; - - const fallbackUrl = `https://img.youtube.com/vi/${id}/default.jpg`; - if (await downloadThumbnailUrl(fallbackUrl, localPath)) return `file://${localPath}`; - - return null; -} - -export async function fetchYouTubeThumbnail(player: Mpris.Player): Promise { - const busName = player.bus_name; - if (!busName) return null; - - let url = ''; - try { - const metaVariant = player.get_meta('xesam:url'); - if (metaVariant) { - url = metaVariant.get_string()[0]; - } - } catch (e) { - console.error('Failed to get xesam:url:', e); - } - - if (!url.includes('youtube.com/watch?v=') && !url.includes('youtu.be/')) { - return null; - } - - let id = ''; - if (url.includes('youtu.be/')) { - id = url.split('youtu.be/')[1].split('?')[0]; - } else { - const match = url.match(/v=([^&]*)/); - if (match) id = match[1]; - } - - if (!id) return null; - - const cacheDir = `${ryprlandCacheDir}/rystal-shell/media`; - const localPath = `${cacheDir}/${id}-mq.jpg`; - if (GLib.file_test(localPath, GLib.FileTest.EXISTS)) return `file://${localPath}`; - - const inFlight = thumbnailDownloads.get(id); - if (inFlight) return inFlight; - - GLib.mkdir_with_parents(cacheDir, 0o755); - const download = downloadThumbnail(id, localPath).finally(() => thumbnailDownloads.delete(id)); - thumbnailDownloads.set(id, download); - return download; -} diff --git a/src/stores/network.ts b/src/stores/network.ts deleted file mode 100644 index 114f8fd..0000000 --- a/src/stores/network.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { execAsync } from 'ags/process'; - -export function toggleWifi(enabled: boolean) { - execAsync(['bash', '-c', `nmcli radio wifi ${enabled ? 'off' : 'on'}`]).catch(console.error); -} - -export function toggleBluetooth(powered: boolean) { - execAsync(['bash', '-c', `rfkill ${powered ? 'block' : 'unblock'} bluetooth`]).catch( - console.error, - ); -} diff --git a/src/stores/notification.ts b/src/stores/notification/notification.ts similarity index 64% rename from src/stores/notification.ts rename to src/stores/notification/notification.ts index 656c7fb..23e74d0 100644 --- a/src/stores/notification.ts +++ b/src/stores/notification/notification.ts @@ -1,10 +1,11 @@ -import { createState } from 'ags'; +import { createBinding, createState } from 'ags'; import Notifd from 'gi://AstalNotifd'; -import { appConfig } from '../lib/config'; +import { appConfig } from '../../lib/config'; const notifd = Notifd.get_default(); +export const doNotDisturb = createBinding(notifd, 'dontDisturb'); const DEFAULT_MAX_NOTIFICATIONS = 30; const configuredMaxNotifications = appConfig.notifications?.maxCount; const MAX_NOTIFICATIONS = @@ -14,11 +15,22 @@ const MAX_NOTIFICATIONS = ? configuredMaxNotifications : DEFAULT_MAX_NOTIFICATIONS; +function uniqueNotifications(notifications: Notifd.Notification[]) { + const seenIds = new Set(); + return notifications.filter((notification) => { + if (seenIds.has(notification.id)) return false; + seenIds.add(notification.id); + return true; + }); +} + function getInitialNotifications() { - const persistentNotifications = notifd - .get_notifications() - .filter((notification) => !notification.transient) - .sort((a, b) => b.time - a.time); + const persistentNotifications = uniqueNotifications( + notifd + .get_notifications() + .filter((notification) => !notification.transient) + .sort((a, b) => b.time - a.time), + ); const initialNotifications = persistentNotifications.slice(0, MAX_NOTIFICATIONS); persistentNotifications @@ -35,7 +47,10 @@ export const notifications = notificationsState; notifd.connect('notified', (_, id) => { const notification = notifd.get_notification(id); if (notification && !notification.transient) { - const nextNotifications = [notification, ...notifications.peek()]; + const nextNotifications = [ + notification, + ...notifications.peek().filter((current) => current.id !== id), + ]; const overflow = nextNotifications.slice(MAX_NOTIFICATIONS); setNotifications(nextNotifications.slice(0, MAX_NOTIFICATIONS)); overflow.forEach((oldNotification) => oldNotification.dismiss()); @@ -55,3 +70,8 @@ export function clearNotifications() { setNotifications([]); current.forEach((notification) => notification.dismiss()); } + +export function toggleDoNotDisturb() { + notifd.dontDisturb = !notifd.dontDisturb; + return notifd.dontDisturb; +} diff --git a/src/stores/notification/notificationPopup.ts b/src/stores/notification/notificationPopup.ts new file mode 100644 index 0000000..a804554 --- /dev/null +++ b/src/stores/notification/notificationPopup.ts @@ -0,0 +1,112 @@ +import { type Accessor, createState, onCleanup } from 'ags'; +import { type Timer, timeout } from 'ags/time'; + +import Hyprland from 'gi://AstalHyprland'; +import Notifd from 'gi://AstalNotifd'; + +import { shellMotion } from '../../lib/motion'; + +export interface NotificationPopupState { + popups: Accessor; + visible: Accessor; +} + +interface PendingTransientRelease { + notification: Notifd.Notification; + timer: Timer; +} + +const POPUP_TIMEOUT_MS = 5000; +const hyprland = Hyprland.get_default(); +const notifd = Notifd.get_default(); + +export function createNotificationPopupState( + monitorConnector: string | null, +): NotificationPopupState { + const [popups, setPopups] = createState([]); + const [visible, setVisible] = createState(false); + const expiryTimers = new Map(); + const pendingTransientReleases = new Map(); + let hideTimer: Timer | null = null; + + const cancelTimer = (timers: Map, id: number) => { + timers.get(id)?.cancel(); + timers.delete(id); + }; + + const cancelPendingTransientRelease = (id: number) => { + pendingTransientReleases.get(id)?.timer.cancel(); + pendingTransientReleases.delete(id); + }; + + const removePopup = (id: number, releaseTransient: boolean) => { + cancelTimer(expiryTimers, id); + + const notification = popups.peek().find((popup) => popup.id === id); + if (!notification) return; + + const remainingPopups = popups.peek().filter((popup) => popup.id !== id); + setPopups(remainingPopups); + if (remainingPopups.length === 0) { + hideTimer?.cancel(); + hideTimer = timeout(shellMotion.listDuration, () => { + hideTimer = null; + setVisible(false); + }); + } + if (!releaseTransient || !notification.transient) return; + + cancelPendingTransientRelease(id); + const timer = timeout(shellMotion.listDuration, () => { + pendingTransientReleases.delete(id); + notification.dismiss(); + }); + pendingTransientReleases.set(id, { notification, timer }); + }; + + const scheduleExpiry = (id: number) => { + cancelTimer(expiryTimers, id); + expiryTimers.set( + id, + timeout(POPUP_TIMEOUT_MS, () => { + expiryTimers.delete(id); + removePopup(id, true); + }), + ); + }; + + const notifiedHook = notifd.connect('notified', (_, id) => { + if (notifd.dont_disturb || hyprland.get_focused_monitor().name !== monitorConnector) return; + + const notification = notifd.get_notification(id); + if (!notification) return; + + cancelPendingTransientRelease(id); + hideTimer?.cancel(); + hideTimer = null; + setVisible(true); + setPopups([notification, ...popups.peek().filter((popup) => popup.id !== id)]); + scheduleExpiry(id); + }); + + const resolvedHook = notifd.connect('resolved', (_, id) => { + cancelPendingTransientRelease(id); + removePopup(id, false); + }); + + onCleanup(() => { + notifd.disconnect(notifiedHook); + notifd.disconnect(resolvedHook); + for (const timer of expiryTimers.values()) timer.cancel(); + for (const { notification, timer } of pendingTransientReleases.values()) { + timer.cancel(); + notification.dismiss(); + } + hideTimer?.cancel(); + hideTimer = null; + expiryTimers.clear(); + pendingTransientReleases.clear(); + }); + + return { popups, visible }; +} diff --git a/src/lib/notification.ts b/src/stores/notification/send.ts similarity index 75% rename from src/lib/notification.ts rename to src/stores/notification/send.ts index 566fe0a..96b9592 100644 --- a/src/lib/notification.ts +++ b/src/stores/notification/send.ts @@ -1,6 +1,6 @@ import Notifd from 'gi://AstalNotifd'; -import { rystalShellDataDir } from './paths'; +import { rystalShellDataDir } from '../../lib/paths'; const APP_NAME = 'Rystal Shell'; const APP_ICON = `${rystalShellDataDir}/assets/icon.png`; @@ -13,7 +13,7 @@ export function sendNotification(properties: Partial; + +export interface ControlCenterState { + visible: Accessor; + revealed: Accessor; + contentLoaded: Accessor; + page: Accessor; + wifiLoaded: Accessor; + bluetoothLoaded: Accessor; + soundLoaded: Accessor; + showAnimated: () => void; + hideAnimated: () => void; + openPage: (page: ControlCenterDetailPage) => void; + showMainPage: () => void; +} + +export function createControlCenterState(monitorConnector: string): ControlCenterState { + const [visible, setVisible] = createState(false); + const [revealed, setRevealed] = createState(false); + const [contentLoaded, setContentLoaded] = createState(false); + const [page, setPage] = createState('main'); + const [wifiLoaded, setWifiLoaded] = createState(false); + const [bluetoothLoaded, setBluetoothLoaded] = createState(false); + const [soundLoaded, setSoundLoaded] = createState(false); + let hideTimer: Timer | null = null; + + const cancelHideTimer = () => { + hideTimer?.cancel(); + hideTimer = null; + }; + + const showMainPage = () => setPage('main'); + + const openPage = (target: ControlCenterDetailPage) => { + if (target === 'wifi') setWifiLoaded(true); + else if (target === 'bluetooth') setBluetoothLoaded(true); + else setSoundLoaded(true); + setPage(target); + }; + + const hideAnimated = () => { + setRevealed(false); + showMainPage(); + deactivateSidePanel('control-center', monitorConnector); + + cancelHideTimer(); + hideTimer = timeout(shellMotion.panelDuration, () => { + hideTimer = null; + setVisible(false); + }); + }; + + const showAnimated = () => { + cancelHideTimer(); + setContentLoaded(true); + setVisible(true); + setRevealed(true); + }; + + onCleanup(() => { + cancelHideTimer(); + deactivateSidePanel('control-center', monitorConnector); + }); + + return { + visible, + revealed, + contentLoaded, + page, + wifiLoaded, + bluetoothLoaded, + soundLoaded, + showAnimated, + hideAnimated, + openPage, + showMainPage, + }; +} diff --git a/src/stores/panel/dateWeather.ts b/src/stores/panel/dateWeather.ts new file mode 100644 index 0000000..cbf430b --- /dev/null +++ b/src/stores/panel/dateWeather.ts @@ -0,0 +1,52 @@ +import { type Accessor, createState, onCleanup } from 'ags'; +import { type Timer, timeout } from 'ags/time'; + +import { shellMotion } from '../../lib/motion'; +import { deactivateSidePanel } from '../shell/windowManager'; + +export interface DateWeatherPopupState { + visible: Accessor; + revealed: Accessor; + loaded: Accessor; + showAnimated: () => void; + hideAnimated: () => void; +} + +export function createDateWeatherPopupState( + monitorConnector: string | null, +): DateWeatherPopupState { + const [visible, setVisible] = createState(false); + const [revealed, setRevealed] = createState(false); + const [loaded, setLoaded] = createState(false); + let hideTimer: Timer | null = null; + + const cancelHideTimer = () => { + hideTimer?.cancel(); + hideTimer = null; + }; + + const hideAnimated = () => { + setRevealed(false); + deactivateSidePanel('date-weather', monitorConnector); + + cancelHideTimer(); + hideTimer = timeout(shellMotion.panelDuration, () => { + hideTimer = null; + setVisible(false); + }); + }; + + const showAnimated = () => { + cancelHideTimer(); + setLoaded(true); + setVisible(true); + setRevealed(true); + }; + + onCleanup(() => { + cancelHideTimer(); + deactivateSidePanel('date-weather', monitorConnector); + }); + + return { visible, revealed, loaded, showAnimated, hideAnimated }; +} diff --git a/src/stores/panel/powerMenu.ts b/src/stores/panel/powerMenu.ts new file mode 100644 index 0000000..bbe62ea --- /dev/null +++ b/src/stores/panel/powerMenu.ts @@ -0,0 +1,317 @@ +import { type Accessor, createState, onCleanup } from 'ags'; +import { Gdk } from 'ags/gtk4'; +import { execAsync } from 'ags/process'; +import { type Timer, idle, timeout } from 'ags/time'; + +import GLib from 'gi://GLib'; + +import { shellMotion } from '../../lib/motion'; +import { activateSidePanel, activeSidePanel, deactivateSidePanel } from '../shell/windowManager'; + +type PowerAction = 'shutdown' | 'reboot' | 'logout' | 'sleep' | 'lock'; + +export interface PowerItem { + action: PowerAction; + label: string; + shortcut: string; + icon: string; + dangerous?: boolean; +} + +export const POWER_ITEMS: PowerItem[] = [ + { action: 'shutdown', label: 'Shutdown', shortcut: 'u', icon: 'power', dangerous: true }, + { action: 'reboot', label: 'Reboot', shortcut: 'r', icon: 'rotate-ccw', dangerous: true }, + { action: 'logout', label: 'Logout', shortcut: 'e', icon: 'log-out', dangerous: true }, + { action: 'sleep', label: 'Sleep', shortcut: 's', icon: 'moon' }, + { action: 'lock', label: 'Lock', shortcut: 'l', icon: 'lock' }, +]; + +export interface PowerMenuStateOptions { + monitorConnector: string | null; + focusItem: (index: number) => void; + focusConfirmation: (index: number) => void; +} + +export interface PowerMenuState { + visible: Accessor; + revealed: Accessor; + selectedIndex: Accessor; + confirmation: Accessor; + confirmationMotion: Accessor; + errorMessage: Accessor; + showAnimated: () => void; + hideAnimated: () => void; + requestAction: (item: PowerItem) => void; + confirmAction: () => void; + selectItem: (index: number) => void; + selectConfirmation: (index: number) => void; + handleKey: (keyval: number) => boolean; +} + +const CONFIRM_MOVE_MS = 300; +const CONFIRM_FADE_MS = 180; +const MOVE_INTERVAL_US = 83_333; + +async function executePowerAction(action: PowerAction) { + switch (action) { + case 'shutdown': + await execAsync(['systemctl', 'poweroff']); + break; + case 'reboot': + await execAsync(['systemctl', 'reboot']); + break; + case 'logout': { + const sessionId = GLib.getenv('XDG_SESSION_ID'); + if (!sessionId) throw new Error('XDG_SESSION_ID is not set'); + await execAsync(['loginctl', 'kill-session', sessionId]); + break; + } + case 'sleep': + await execAsync(['loginctl', 'lock-session']); + await execAsync(['systemctl', 'suspend']); + break; + case 'lock': + await execAsync(['loginctl', 'lock-session']); + break; + } +} + +export function createPowerMenuState({ + monitorConnector, + focusItem: focusItemWidget, + focusConfirmation, +}: PowerMenuStateOptions): PowerMenuState { + const [visible, setVisible] = createState(false); + const [revealed, setRevealed] = createState(false); + const [selectedIndex, setSelectedIndex] = createState(0); + const [confirmation, setConfirmation] = createState(null); + const [confirmationMotion, setConfirmationMotion] = createState(false); + const [errorMessage, setErrorMessage] = createState(''); + const transitionTimers = new Set(); + let hideTimer: Timer | null = null; + let focusTimer: Timer | null = null; + let executing = false; + let disposed = false; + let transitioning = false; + let lastMoveAt = 0; + let confirmationSelectedIndex = 0; + + function cancelHideTimer() { + hideTimer?.cancel(); + hideTimer = null; + } + + function cancelFocusTimer() { + focusTimer?.cancel(); + focusTimer = null; + } + + function scheduleFocus(callback: () => void) { + cancelFocusTimer(); + focusTimer = idle(() => { + focusTimer = null; + callback(); + }); + } + + function scheduleTransition(callback: () => void, delay: number) { + const transitionTimer = timeout(delay, () => { + transitionTimers.delete(transitionTimer); + callback(); + }); + transitionTimers.add(transitionTimer); + } + + function clearConfirmationTransition() { + for (const timer of transitionTimers) timer.cancel(); + transitionTimers.clear(); + transitioning = false; + setConfirmationMotion(false); + } + + function canMoveSelection() { + const now = GLib.get_monotonic_time(); + if (now - lastMoveAt < MOVE_INTERVAL_US) return false; + lastMoveAt = now; + return true; + } + + function focusItem(index: number) { + const nextIndex = (index + POWER_ITEMS.length) % POWER_ITEMS.length; + setSelectedIndex(nextIndex); + focusItemWidget(nextIndex); + } + + function moveItemSelection(delta: number) { + if (canMoveSelection()) focusItem(selectedIndex() + delta); + } + + function moveConfirmationSelection() { + if (!canMoveSelection()) return; + confirmationSelectedIndex = confirmationSelectedIndex === 0 ? 1 : 0; + focusConfirmation(confirmationSelectedIndex); + } + + function hideAnimated() { + if (executing) return; + clearConfirmationTransition(); + cancelFocusTimer(); + setRevealed(false); + setConfirmation(null); + setErrorMessage(''); + confirmationSelectedIndex = 0; + + deactivateSidePanel('power-menu', monitorConnector); + + cancelHideTimer(); + hideTimer = timeout(shellMotion.panelDuration, () => { + hideTimer = null; + setVisible(false); + }); + } + + function showAnimated() { + clearConfirmationTransition(); + cancelHideTimer(); + lastMoveAt = 0; + confirmationSelectedIndex = 0; + setConfirmation(null); + setErrorMessage(''); + setSelectedIndex(0); + setVisible(true); + setRevealed(true); + scheduleFocus(() => focusItem(0)); + } + + async function runAction(item: PowerItem) { + if (executing) return; + executing = true; + setErrorMessage(''); + setRevealed(false); + + try { + await executePowerAction(item.action); + if (disposed) return; + setVisible(false); + deactivateSidePanel('power-menu', monitorConnector); + } catch (error) { + if (disposed) return; + const message = error instanceof Error ? error.message : String(error); + console.error(`[PowerMenu] ${item.label} failed: ${message}`); + const active = activeSidePanel.get(); + if (active.panel !== 'power-menu' || active.monitor !== monitorConnector) return; + setConfirmation(null); + setErrorMessage(`${item.label} failed: ${message}`); + setVisible(true); + setRevealed(true); + activateSidePanel('power-menu', monitorConnector ?? ''); + scheduleFocus(() => focusItem(POWER_ITEMS.indexOf(item))); + } finally { + executing = false; + } + } + + function beginConfirmation(item: PowerItem) { + if (transitioning) return; + transitioning = true; + setErrorMessage(''); + confirmationSelectedIndex = 0; + focusItem(POWER_ITEMS.indexOf(item)); + setConfirmationMotion(true); + + scheduleTransition(() => { + setConfirmation(item); + scheduleTransition(() => { + setConfirmationMotion(false); + transitioning = false; + lastMoveAt = 0; + focusConfirmation(0); + }, CONFIRM_FADE_MS); + }, CONFIRM_MOVE_MS); + } + + function requestAction(item: PowerItem) { + if (transitioning) return; + if (item.dangerous) beginConfirmation(item); + else void runAction(item); + } + + function confirmAction() { + const item = confirmation(); + if (item) void runAction(item); + } + + function selectItem(index: number) { + setSelectedIndex(index); + } + + function selectConfirmation(index: number) { + confirmationSelectedIndex = index; + } + + function handleKey(keyval: number) { + if (transitioning) return true; + if (keyval === Gdk.KEY_Escape) { + hideAnimated(); + return true; + } + + const confirmationItem = confirmation(); + if (confirmationItem) { + if (keyval === Gdk.KEY_Left || keyval === Gdk.KEY_Right) { + moveConfirmationSelection(); + return true; + } + + const typed = Gdk.keyval_to_unicode(keyval); + if (typed > 0 && String.fromCodePoint(typed).toLowerCase() === confirmationItem.shortcut) { + void runAction(confirmationItem); + return true; + } + return false; + } + + if (keyval === Gdk.KEY_Left || keyval === Gdk.KEY_Up) { + moveItemSelection(-1); + return true; + } + if (keyval === Gdk.KEY_Right || keyval === Gdk.KEY_Down) { + moveItemSelection(1); + return true; + } + + const typed = Gdk.keyval_to_unicode(keyval); + if (typed <= 0) return false; + const shortcut = String.fromCodePoint(typed).toLowerCase(); + const item = POWER_ITEMS.find((candidate) => candidate.shortcut === shortcut); + if (!item) return false; + + focusItem(POWER_ITEMS.indexOf(item)); + requestAction(item); + return true; + } + + onCleanup(() => { + disposed = true; + cancelHideTimer(); + cancelFocusTimer(); + clearConfirmationTransition(); + deactivateSidePanel('power-menu', monitorConnector); + }); + + return { + visible, + revealed, + selectedIndex, + confirmation, + confirmationMotion, + errorMessage, + showAnimated, + hideAnimated, + requestAction, + confirmAction, + selectItem, + selectConfirmation, + handleKey, + }; +} diff --git a/src/stores/powerMenu.ts b/src/stores/powerMenu.ts deleted file mode 100644 index 2294ccf..0000000 --- a/src/stores/powerMenu.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { execAsync } from 'ags/process'; - -import GLib from 'gi://GLib'; - -export type PowerAction = 'shutdown' | 'reboot' | 'logout' | 'sleep' | 'lock'; - -export async function executePowerAction(action: PowerAction) { - switch (action) { - case 'shutdown': - await execAsync(['systemctl', 'poweroff']); - break; - case 'reboot': - await execAsync(['systemctl', 'reboot']); - break; - case 'logout': { - const sessionId = GLib.getenv('XDG_SESSION_ID'); - if (!sessionId) throw new Error('XDG_SESSION_ID is not set'); - await execAsync(['loginctl', 'kill-session', sessionId]); - break; - } - case 'sleep': - await execAsync(['loginctl', 'lock-session']); - await execAsync(['systemctl', 'suspend']); - break; - case 'lock': - await execAsync(['loginctl', 'lock-session']); - break; - } -} diff --git a/src/stores/recording.ts b/src/stores/recording.ts deleted file mode 100644 index d44d4e8..0000000 --- a/src/stores/recording.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { createState } from 'ags'; -import { execAsync, subprocess } from 'ags/process'; - -import Hyprland from 'gi://AstalHyprland'; -import Wp from 'gi://AstalWp'; -import GLib from 'gi://GLib'; - -import { appConfig } from '../lib/config'; -import { sendNotification } from '../lib/notification'; - -const [isRecordingState, setIsRecording] = createState(false); -export const isRecording = isRecordingState; - -const SIGINT = 2; - -interface ActiveRecording { - process: ReturnType; - path: string; - stopRequested: boolean; - lastError: string; -} - -let activeRecording: ActiveRecording | null = null; -let isStarting = false; - -function notifyFailure(body: string) { - sendNotification({ - summary: 'Recording failed', - body, - }); -} - -function finishRecording(recording: ActiveRecording, code: number, signaled: boolean) { - if (activeRecording !== recording) return; - - activeRecording = null; - setIsRecording(false); - - const stoppedNormally = - (!signaled && code === 0) || (recording.stopRequested && signaled && code === SIGINT); - - if (stoppedNormally) { - sendNotification({ - summary: 'Recording saved', - body: `Saved to ${recording.path}`, - }); - return; - } - - const detail = - recording.lastError || `wf-recorder exited with ${signaled ? 'signal' : 'code'} ${code}`; - console.error(`Recording failed: ${detail}`); - notifyFailure(detail); -} - -function getRecordingPath() { - const now = GLib.DateTime.new_now_local(); - const format = appConfig.recorder?.filenameFormat || 'recording_%Y-%m-%d_%H.%M.%S.mp4'; - const filename = now.format(format) || `recording_${Date.now()}.mp4`; - - let savePath = appConfig.recorder?.savePath || '~/Videos'; - if (savePath === '~' || savePath.startsWith('~/')) { - savePath = `${GLib.get_home_dir()}${savePath.slice(1)}`; - } - savePath = GLib.canonicalize_filename(savePath, GLib.get_current_dir()); - - if (GLib.mkdir_with_parents(savePath, 0o755) === -1) { - throw new Error(`Failed to create directory: ${savePath}`); - } - - return `${savePath}/${filename}`; -} - -function addAudioOptions(cmd: string[]) { - if (appConfig.recorder?.recordAudio === false) return; - - if (appConfig.recorder?.audioSource === 'mic') { - cmd.push('-a'); - return; - } - - const speaker = Wp.get_default().audio.get_default_speaker(); - cmd.push(speaker ? `--audio=${speaker.name?.trim()}.monitor` : '-a'); -} - -async function addCaptureTarget(cmd: string[], mode: 'monitor' | 'slurp') { - if (mode === 'monitor') { - const monitor = Hyprland.get_default().get_focused_monitor(); - if (monitor) { - cmd.push('-o', monitor.name); - } else { - console.error('No focused monitor found for recording'); - } - return true; - } - - try { - const region = await execAsync('slurp'); - cmd.push('--geometry', region.trim()); - return true; - } catch (error) { - console.warn('Cancelled Slurp [This is not an error. Emitted by user operation]', error); - sendNotification({ - summary: 'Recording cancelled', - body: 'Selection was cancelled', - transient: true, - }); - return false; - } -} - -export async function startRecord(mode: 'monitor' | 'slurp') { - if (activeRecording || isStarting) return; - isStarting = true; - - try { - const fullPath = getRecordingPath(); - const cmd = ['wf-recorder', '--pixel-format', 'yuv420p', '-f', fullPath]; - addAudioOptions(cmd); - if (!(await addCaptureTarget(cmd, mode))) return; - - const process = subprocess({ - cmd, - err: (line) => { - console.error(`wf-recorder: ${line}`); - if (activeRecording?.process === process) activeRecording.lastError = line; - }, - }); - - const recording: ActiveRecording = { - process, - path: fullPath, - stopRequested: false, - lastError: '', - }; - activeRecording = recording; - process.connect('exit', (_, code, signaled) => finishRecording(recording, code, signaled)); - - sendNotification({ - summary: 'Recording started', - body: `Recording to ${fullPath}`, - transient: true, - }); - setIsRecording(true); - } catch (e) { - console.error('Failed to start recording', e); - notifyFailure(e instanceof Error ? e.message : String(e)); - } finally { - isStarting = false; - } -} - -export function stopRecord() { - const recording = activeRecording; - if (!recording || recording.stopRequested) return; - - recording.stopRequested = true; - try { - recording.process.signal(SIGINT); - } catch (error) { - recording.stopRequested = false; - console.error('Failed to stop recording', error); - notifyFailure(error instanceof Error ? error.message : String(error)); - } -} diff --git a/src/stores/shell/barBackground.ts b/src/stores/shell/barBackground.ts new file mode 100644 index 0000000..ef75fba --- /dev/null +++ b/src/stores/shell/barBackground.ts @@ -0,0 +1,133 @@ +import { type Accessor, createExternal, createState } from 'ags'; +import { type Timer, interval } from 'ags/time'; + +import GLib from 'gi://GLib'; + +import { rystalShellConfigDir, rystalShellDataDir } from '../../lib/paths'; +import { activeSidePanel } from './windowManager'; + +export interface BarColors { + surface: string; + primary: string; +} + +export interface BarBackgroundGeometry { + dx: number; + bottomHeight: number; +} + +const BAR_WIDTH = 47; +const CONTROL_CENTER_WIDTH = 490; +const DATE_WEATHER_WIDTH = 900; +const WALLPAPER_PANEL_HEIGHT = 390; +const POWER_MENU_PANEL_HEIGHT = 350; +const ANIMATION_INTERVAL_MS = 1000 / 60; +const ANIMATION_SPEED = 0.22; +const configuredThemePath = `${rystalShellConfigDir}/theme.scss`; +const defaultThemePath = `${rystalShellDataDir}/styles/default/theme.scss`; + +function readBarColors(): BarColors { + try { + const themePath = GLib.file_test(configuredThemePath, GLib.FileTest.EXISTS) + ? configuredThemePath + : defaultThemePath; + const [success, bytes] = GLib.file_get_contents(themePath); + if (!success || !bytes) throw new Error(`Cannot read bar colors: ${themePath}`); + + const contents = new TextDecoder().decode(bytes); + let surface = '#191114'; + let primary = '#ffb0ce'; + + for (const line of contents.split('\n')) { + const surfaceMatch = line.trim().match(/^\$surface:\s*(#[0-9a-fA-F]{6})/); + const primaryMatch = line.trim().match(/^\$primary:\s*(#[0-9a-fA-F]{6})/); + if (surfaceMatch) surface = surfaceMatch[1]; + if (primaryMatch) primary = primaryMatch[1]; + } + + return { surface, primary }; + } catch (error) { + console.error(`Failed to read bar colors: ${error}`); + return { surface: '#191114', primary: '#ffb0ce' }; + } +} + +function getTargetGeometry(panel: string, isTargetMonitor: boolean): BarBackgroundGeometry { + if (!isTargetMonitor) return { dx: BAR_WIDTH, bottomHeight: 0 }; + + const dx = + panel === 'control-center' + ? BAR_WIDTH + CONTROL_CENTER_WIDTH + : panel === 'date-weather' + ? BAR_WIDTH + DATE_WEATHER_WIDTH + : BAR_WIDTH; + const bottomHeight = + panel === 'wallpaper-selector' + ? WALLPAPER_PANEL_HEIGHT + : panel === 'power-menu' + ? POWER_MENU_PANEL_HEIGHT + : 0; + + return { dx, bottomHeight }; +} + +const [barColorsState, setBarColors] = createState(readBarColors()); +export const barColors = barColorsState; + +export function reloadBarColors() { + setBarColors(readBarColors()); +} + +const monitorGeometries = new Map>(); + +function createMonitorGeometry(monitorConnector: string | null): Accessor { + const initialGeometry = { dx: BAR_WIDTH, bottomHeight: 0 }; + + return createExternal(initialGeometry, (setGeometry) => { + let currentGeometry = initialGeometry; + let targetGeometry = initialGeometry; + let animationTimer: Timer | null = null; + + const animate = () => { + const horizontalDiff = targetGeometry.dx - currentGeometry.dx; + const bottomDiff = targetGeometry.bottomHeight - currentGeometry.bottomHeight; + + if (Math.abs(horizontalDiff) < 1 && Math.abs(bottomDiff) < 1) { + currentGeometry = targetGeometry; + setGeometry(currentGeometry); + animationTimer?.cancel(); + animationTimer = null; + return; + } + + currentGeometry = { + dx: currentGeometry.dx + horizontalDiff * ANIMATION_SPEED, + bottomHeight: currentGeometry.bottomHeight + bottomDiff * ANIMATION_SPEED, + }; + setGeometry(currentGeometry); + }; + + const unsubscribePanel = activeSidePanel.subscribe(({ panel, monitor }) => { + targetGeometry = getTargetGeometry(panel, monitor === monitorConnector); + animationTimer ??= interval(ANIMATION_INTERVAL_MS, animate); + }); + + return () => { + unsubscribePanel(); + animationTimer?.cancel(); + animationTimer = null; + }; + }); +} + +export function createBarBackgroundGeometry( + monitorConnector: string | null, +): Accessor { + const key = monitorConnector ?? ''; + const existing = monitorGeometries.get(key); + if (existing) return existing; + + const geometry = createMonitorGeometry(monitorConnector); + monitorGeometries.set(key, geometry); + return geometry; +} diff --git a/src/stores/shell/scrollingLayout.ts b/src/stores/shell/scrollingLayout.ts new file mode 100644 index 0000000..7a117c4 --- /dev/null +++ b/src/stores/shell/scrollingLayout.ts @@ -0,0 +1,108 @@ +import { type Accessor, createExternal } from 'ags'; + +import Hyprland from 'gi://AstalHyprland'; + +export interface ScrollingLayoutInfo { + visible: boolean; + current: number; + total: number; +} + +interface HyprlandLayoutOption { + str?: string; +} + +const UPDATE_DELAY_MS = 10; +const hyprland = Hyprland.get_default(); + +export function createScrollingLayoutInfo(connector: string | null): Accessor { + const initialInfo: ScrollingLayoutInfo = { visible: false, current: 0, total: 0 }; + + return createExternal(initialInfo, (setInfo) => { + let currentLayout = 'scrolling'; + let updateTimer: ReturnType | null = null; + let disposed = false; + + const publishInfo = (current: number, total: number) => { + setInfo({ + visible: currentLayout === 'scrolling' && total > 0, + current, + total, + }); + }; + + const updateLayout = () => { + hyprland.message_async('j/getoption general:layout', (_, result) => { + try { + const output = hyprland.message_finish(result); + if (disposed) return; + + const option = JSON.parse(output) as HyprlandLayoutOption; + currentLayout = option.str ?? ''; + setInfo((info) => ({ + ...info, + visible: currentLayout === 'scrolling' && info.total > 0, + })); + } catch (error) { + console.error('Failed to read Hyprland layout', error); + } + }); + }; + + const updateInfo = () => { + if (updateTimer !== null) return; + + updateTimer = setTimeout(() => { + updateTimer = null; + if (disposed) return; + + const monitor = hyprland.monitors.find((candidate) => candidate.name === connector); + const workspace = monitor?.active_workspace; + if (!workspace) { + publishInfo(0, 0); + return; + } + + const clients = workspace.clients + .filter((client) => !client.floating) + .sort((clientA, clientB) => clientA.x - clientB.x); + if (clients.length === 0) { + publishInfo(0, 0); + return; + } + + const focusedClient = hyprland.focused_client; + const activeClient = + focusedClient?.workspace?.id === workspace.id ? focusedClient : workspace.last_client; + const index = activeClient + ? clients.findIndex((client) => client.address === activeClient.address) + : -1; + publishInfo(index >= 0 ? index + 1 : 0, clients.length); + }, UPDATE_DELAY_MS); + }; + + const hooks = [ + hyprland.connect('event', (_, event) => { + if (event === 'configreloaded' || event.includes('scrolling')) updateLayout(); + }), + hyprland.connect('notify::focused-workspace', updateInfo), + hyprland.connect('notify::focused-client', updateInfo), + hyprland.connect('client-added', updateInfo), + hyprland.connect('client-removed', updateInfo), + hyprland.connect('client-moved', updateInfo), + ]; + + updateLayout(); + updateInfo(); + + return () => { + disposed = true; + if (updateTimer !== null) clearTimeout(updateTimer); + hooks.forEach((hook) => hyprland.disconnect(hook)); + }; + }); +} + +export function toggleScrollingOverview() { + hyprland.dispatch('hl.plugin.scrolloverview.overview("toggle")', ''); +} diff --git a/src/lib/css.ts b/src/stores/shell/style.ts similarity index 78% rename from src/lib/css.ts rename to src/stores/shell/style.ts index 4ee4762..35f6677 100644 --- a/src/lib/css.ts +++ b/src/stores/shell/style.ts @@ -3,12 +3,12 @@ import { exec, execAsync } from 'ags/process'; import GLib from 'gi://GLib'; -import { reloadLauncherBackground } from '../stores/launcherImage'; -import { forceRedrawBar } from '../widget/bar'; -import { ryprlandRuntimeDir, rystalShellConfigDir, rystalShellDataDir } from './paths'; +import { ryprlandRuntimeDir, rystalShellConfigDir, rystalShellDataDir } from '../../lib/paths'; +import { reloadBarColors } from './barBackground'; -let globalCssProvider: Gtk.CssProvider | null = null; +let cssProvider: Gtk.CssProvider | null = null; let lastCompiledCss: string | null = null; + const styleEntry = `${rystalShellDataDir}/styles/style.scss`; const defaultThemeDir = `${rystalShellDataDir}/styles/default`; const defaultCssPath = `${rystalShellDataDir}/styles/default.css`; @@ -33,12 +33,6 @@ function reloadCss(cssInput: string) { throw new Error('Cannot reload CSS without a default display'); } - if (globalCssProvider) { - Gtk.StyleContext.remove_provider_for_display(display, globalCssProvider); - globalCssProvider.run_dispose(); - globalCssProvider = null; - } - const nextProvider = new Gtk.CssProvider(); if (GLib.file_test(cssInput, GLib.FileTest.EXISTS)) { nextProvider.load_from_path(cssInput); @@ -52,14 +46,19 @@ function reloadCss(cssInput: string) { Gtk.STYLE_PROVIDER_PRIORITY_USER, ); - globalCssProvider = nextProvider; - reloadLauncherBackground(); - forceRedrawBar(); + if (cssProvider) { + Gtk.StyleContext.remove_provider_for_display(display, cssProvider); + cssProvider.run_dispose(); + } + cssProvider = nextProvider; + reloadBarColors(); } function sassCommand() { return [ 'sass', + '--style=expanded', + '--no-source-map', '--load-path', rystalShellConfigDir, '--load-path', @@ -69,19 +68,20 @@ function sassCommand() { ]; } -export function compileAndReloadCss(): Promise { +export function compileAndReloadCss(): Promise { ensureRuntimeDir(); return execAsync(sassCommand()) .then(() => { const css = readCss(cssPath); - if (css === lastCompiledCss) return; + if (css === lastCompiledCss) return false; reloadCss(css); lastCompiledCss = css; + return true; }) - .catch((err) => { - console.error(`Error compiling CSS; keeping the active stylesheet: ${err}`); - throw err; + .catch((error) => { + console.error(`Error compiling CSS; keeping the active stylesheet: ${error}`); + throw error; }); } diff --git a/src/stores/windowManager.ts b/src/stores/shell/windowManager.ts similarity index 79% rename from src/stores/windowManager.ts rename to src/stores/shell/windowManager.ts index 5a68842..4deecc7 100644 --- a/src/stores/windowManager.ts +++ b/src/stores/shell/windowManager.ts @@ -1,31 +1,47 @@ -import { createState } from 'ags'; import { Astal } from 'ags/gtk4'; import app from 'ags/gtk4/app'; import Hyprland from 'gi://AstalHyprland'; +export type SidePanel = 'control-center' | 'date-weather' | 'wallpaper-selector' | 'power-menu'; + +export interface ActiveSidePanel { + panel: SidePanel | ''; + monitor: string; +} + +const activeSidePanelCallbacks: ((value: ActiveSidePanel) => void)[] = []; +let activeSidePanelValue: ActiveSidePanel = { panel: '', monitor: '' }; + +function setActiveSidePanel(value: ActiveSidePanel) { + activeSidePanelValue = value; + activeSidePanelCallbacks.forEach((callback) => callback(value)); +} + export const activeSidePanel = { - value: { panel: '', monitor: '' }, - callbacks: [] as ((val: { panel: string; monitor: string }) => void)[], get() { - return this.value; - }, - set(panel: string, monitor: string) { - this.value = { panel, monitor }; - this.callbacks.forEach((cb) => cb(this.value)); + return activeSidePanelValue; }, - subscribe(cb: (val: { panel: string; monitor: string }) => void) { - this.callbacks.push(cb); - cb(this.value); + subscribe(callback: (value: ActiveSidePanel) => void) { + activeSidePanelCallbacks.push(callback); + callback(activeSidePanelValue); return () => { - const index = this.callbacks.indexOf(cb); - if (index !== -1) this.callbacks.splice(index, 1); + const index = activeSidePanelCallbacks.indexOf(callback); + if (index !== -1) activeSidePanelCallbacks.splice(index, 1); }; }, }; -export const [animDx, setAnimDx] = createState(47); -export const setAnimBottomHeight = createState(0)[1]; +export function activateSidePanel(panel: SidePanel, monitor: string) { + setActiveSidePanel({ panel, monitor }); +} + +export function deactivateSidePanel(panel: SidePanel, monitor?: string | null) { + const active = activeSidePanel.get(); + if (active.panel !== panel) return; + if (monitor !== undefined && monitor !== null && active.monitor !== monitor) return; + setActiveSidePanel({ panel: '', monitor: '' }); +} type AnimatedWindow = Astal.Window & { hide_animated?: () => void; @@ -44,7 +60,7 @@ export function closeAllControlCenters() { else cc.set_visible(false); } }); - if (activeSidePanel.get().panel === 'control-center') activeSidePanel.set('', ''); + deactivateSidePanel('control-center'); } function closeAllDateWeathers() { @@ -55,7 +71,7 @@ function closeAllDateWeathers() { else dw.set_visible(false); } }); - if (activeSidePanel.get().panel === 'date-weather') activeSidePanel.set('', ''); + deactivateSidePanel('date-weather'); } function closeAllAppLaunchers() { @@ -73,7 +89,7 @@ function closeAllWallpaperSelectors() { else selector.set_visible(false); } }); - if (activeSidePanel.get().panel === 'wallpaper-selector') activeSidePanel.set('', ''); + deactivateSidePanel('wallpaper-selector'); } function closeAllPowerMenus() { @@ -84,7 +100,7 @@ function closeAllPowerMenus() { else menu.set_visible(false); } }); - if (activeSidePanel.get().panel === 'power-menu') activeSidePanel.set('', ''); + deactivateSidePanel('power-menu'); } export function toggleControlCenter(monitorName?: string | null) { @@ -106,7 +122,7 @@ export function toggleControlCenter(monitorName?: string | null) { closeAllAppLaunchers(); if (dw && dw.get_visible()) dw.hide_animated?.(); cc.show_animated?.(); - activeSidePanel.set('control-center', connector ?? ''); + activateSidePanel('control-center', connector ?? ''); } } else { if (cc.get_visible()) cc.hide_animated?.(); @@ -134,7 +150,7 @@ export function toggleDateWeather(monitorName?: string | null) { closeAllAppLaunchers(); if (cc && cc.get_visible()) cc.hide_animated?.(); dw.show_animated?.(); - activeSidePanel.set('date-weather', connector ?? ''); + activateSidePanel('date-weather', connector ?? ''); } } else { if (dw.get_visible()) dw.hide_animated?.(); @@ -183,7 +199,7 @@ export function toggleWallpaperSelector(monitorName?: string | null) { closeAllAppLaunchers(); closeAllPowerMenus(); selector.show_animated?.(); - activeSidePanel.set('wallpaper-selector', connector ?? ''); + activateSidePanel('wallpaper-selector', connector ?? ''); } } else if (selector.get_visible()) { selector.hide_animated?.(); @@ -209,7 +225,7 @@ export function togglePowerMenu(monitorName?: string | null) { closeAllWallpaperSelectors(); closeAllAppLaunchers(); menu.show_animated?.(); - activeSidePanel.set('power-menu', connector ?? ''); + activateSidePanel('power-menu', connector ?? ''); } } else if (menu.get_visible()) { menu.hide_animated?.(); diff --git a/src/stores/shell/workspace.ts b/src/stores/shell/workspace.ts new file mode 100644 index 0000000..eac637b --- /dev/null +++ b/src/stores/shell/workspace.ts @@ -0,0 +1,44 @@ +import { type Accessor, createBinding, createComputed, createExternal } from 'ags'; + +import Hyprland from 'gi://AstalHyprland'; + +export interface MonitorWorkspaceState { + workspaces: Accessor; + activeIndex: Accessor; + focusedWorkspaceId: Accessor; +} + +const hyprland = Hyprland.get_default(); +const focusedWorkspace = createBinding(hyprland, 'focused_workspace'); + +function getMonitorWorkspaces(connector: string | null) { + return hyprland + .get_workspaces() + .filter((workspace) => workspace.monitor?.name === connector) + .filter((workspace) => !workspace.name.startsWith('special')) + .sort((workspaceA, workspaceB) => workspaceA.id - workspaceB.id); +} + +export function createMonitorWorkspaceState(connector: string | null): MonitorWorkspaceState { + const workspaces = createExternal(getMonitorWorkspaces(connector), (setWorkspaces) => { + const update = () => setWorkspaces(getMonitorWorkspaces(connector)); + const hooks = [ + hyprland.connect('workspace-added', update), + hyprland.connect('workspace-removed', update), + hyprland.connect('notify::monitors', update), + ]; + + return () => hooks.forEach((hook) => hyprland.disconnect(hook)); + }); + const focusedWorkspaceId = focusedWorkspace.as((workspace) => workspace?.id ?? -1); + const activeIndex = createComputed(() => { + const index = workspaces().findIndex((workspace) => workspace.id === focusedWorkspaceId()); + return Math.max(index, 0); + }); + + return { workspaces, activeIndex, focusedWorkspaceId }; +} + +export function focusWorkspace(name: string) { + hyprland.dispatch(`hl.dsp.focus({ workspace = "${name}" })`, ''); +} diff --git a/src/stores/system/audio.ts b/src/stores/system/audio.ts new file mode 100644 index 0000000..6f55c53 --- /dev/null +++ b/src/stores/system/audio.ts @@ -0,0 +1,135 @@ +import { type Accessor, createBinding, createState, onCleanup } from 'ags'; +import { execAsync } from 'ags/process'; +import { type Timer, timeout } from 'ags/time'; + +import Wp from 'gi://AstalWp'; +import GSound from 'gi://GSound'; + +import { closeAllControlCenters } from '../shell/windowManager'; + +let volumeContext: GSound.Context | null = null; +let lastVolumeSoundAt = 0; +const audio = Wp.get_default().audio; +const DEVICE_REFRESH_DELAY_MS = 150; + +export const defaultSpeaker = createBinding(audio, 'default_speaker'); + +export interface SoundPageState { + speaker: Accessor; + microphone: Accessor; + speakers: Accessor; + microphones: Accessor; + selectSpeaker: (endpoint: Wp.Endpoint) => Promise; + selectMicrophone: (endpoint: Wp.Endpoint) => Promise; +} + +function getVolumeContext() { + if (volumeContext) return volumeContext; + const context = new GSound.Context(); + context.init(null); + volumeContext = context; + return context; +} + +function playVolumeSound() { + try { + getVolumeContext().play_simple({ 'event.id': 'audio-volume-change' }, null); + } catch (err) { + volumeContext = null; + console.error('Failed to play volume sound', err); + } +} + +function playVolumeFeedback() { + const now = Date.now(); + if (now - lastVolumeSoundAt <= 100) return; + + lastVolumeSoundAt = now; + playVolumeSound(); +} + +export function setEndpointVolume(endpoint: Wp.Endpoint, volume: number) { + endpoint.volume = Math.max(0, Math.min(1, volume)); + playVolumeFeedback(); +} + +export function setMicrophoneVolume(endpoint: Wp.Endpoint, volume: number) { + endpoint.volume = Math.max(0, Math.min(1, volume)); +} + +export function toggleEndpointMute(endpoint: Wp.Endpoint) { + endpoint.mute = !endpoint.mute; +} + +export function adjustVolume(endpoint: Wp.Endpoint, delta: number) { + setEndpointVolume(endpoint, endpoint.volume + delta); +} + +async function setDefaultAudioEndpoint(nodeId: number) { + await execAsync(['wpctl', 'set-default', String(nodeId)]); +} + +export function createSoundPageState(): SoundPageState { + const [speaker, setSpeaker] = createState(audio.default_speaker ?? null); + const [microphone, setMicrophone] = createState( + audio.default_microphone ?? null, + ); + const [speakers, setSpeakers] = createState([...(audio.speakers ?? [])]); + const [microphones, setMicrophones] = createState([...(audio.microphones ?? [])]); + let speakerRefreshTimer: Timer | null = null; + let microphoneRefreshTimer: Timer | null = null; + + const refreshSpeakers = () => { + setSpeakers([...(audio.speakers ?? [])]); + setSpeaker(audio.default_speaker ?? null); + }; + + const refreshMicrophones = () => { + setMicrophones([...(audio.microphones ?? [])]); + setMicrophone(audio.default_microphone ?? null); + }; + + const selectSpeaker = async (endpoint: Wp.Endpoint) => { + await setDefaultAudioEndpoint(endpoint.id); + setSpeaker(endpoint); + speakerRefreshTimer?.cancel(); + speakerRefreshTimer = timeout(DEVICE_REFRESH_DELAY_MS, () => { + speakerRefreshTimer = null; + refreshSpeakers(); + }); + }; + + const selectMicrophone = async (endpoint: Wp.Endpoint) => { + await setDefaultAudioEndpoint(endpoint.id); + setMicrophone(endpoint); + microphoneRefreshTimer?.cancel(); + microphoneRefreshTimer = timeout(DEVICE_REFRESH_DELAY_MS, () => { + microphoneRefreshTimer = null; + refreshMicrophones(); + }); + }; + + const hooks = [ + audio.connect('notify::default-speaker', refreshSpeakers), + audio.connect('notify::default-microphone', refreshMicrophones), + audio.connect('speaker-added', refreshSpeakers), + audio.connect('speaker-removed', refreshSpeakers), + audio.connect('microphone-added', refreshMicrophones), + audio.connect('microphone-removed', refreshMicrophones), + ]; + + onCleanup(() => { + hooks.forEach((hook) => audio.disconnect(hook)); + speakerRefreshTimer?.cancel(); + microphoneRefreshTimer?.cancel(); + speakerRefreshTimer = null; + microphoneRefreshTimer = null; + }); + + return { speaker, microphone, speakers, microphones, selectSpeaker, selectMicrophone }; +} + +export function openAudioControl() { + closeAllControlCenters(); + execAsync('pavucontrol').catch(console.error); +} diff --git a/src/stores/system/brightness.ts b/src/stores/system/brightness.ts new file mode 100644 index 0000000..b07cb92 --- /dev/null +++ b/src/stores/system/brightness.ts @@ -0,0 +1,193 @@ +import { createState } from 'ags'; +import { type Timer, timeout } from 'ags/time'; + +import { sendNotification } from '../notification/send'; +import { BrightnessBackendController, clampBrightnessPercent } from './brightnessBackend'; + +const KEYBOARD_STEP = 10; +const DEFAULT_RESTORE_BRIGHTNESS = 0.25; +const BRIGHTNESS_PRESETS = [0, 0.25, 0.5, 0.75, 1]; + +const [brightnessState, setBrightnessState] = createState(0.5); +export const brightness = brightnessState; + +let setTimer: Timer | null = null; +let scheduledTarget: number | null = null; +let lastNonZeroBrightness: number | null = null; + +interface BrightnessRequest { + resolve: (percent: number) => void; + reject: (error: Error) => void; +} + +interface PendingBrightnessApply { + percent: number; + requests: BrightnessRequest[]; +} + +let pendingApply: PendingBrightnessApply | null = null; +let applyWorker: Promise | null = null; +let changeQueue = Promise.resolve(); + +function rememberNonZeroBrightness(value: number) { + if (value > 0) lastNonZeroBrightness = value; +} + +function cancelSetTimer() { + setTimer?.cancel(); + setTimer = null; +} + +const brightnessBackend = new BrightnessBackendController(); + +function notify(percent: number) { + sendNotification({ + summary: `Brightness: ${percent}%`, + body: + brightnessBackend.current === 'ddcutil' + ? 'DDC/CI display brightness' + : 'System backlight brightness', + transient: true, + }); +} + +async function processApplyQueue() { + const request = pendingApply; + if (!request) return; + pendingApply = null; + + try { + await brightnessBackend.applyPercent(request.percent); + const value = request.percent / 100; + rememberNonZeroBrightness(value); + setBrightnessState(value); + request.requests.forEach(({ resolve }) => resolve(request.percent)); + } catch (error) { + const applyError = error instanceof Error ? error : new Error(String(error)); + request.requests.forEach(({ reject }) => reject(applyError)); + } + + await processApplyQueue(); +} + +function ensureApplyWorker() { + if (applyWorker) return; + + applyWorker = processApplyQueue().finally(() => { + applyWorker = null; + if (pendingApply) ensureApplyWorker(); + }); +} + +function requestBrightnessApply(percent: number) { + return new Promise((resolve, reject) => { + const request = { resolve, reject }; + if (pendingApply) { + pendingApply.percent = percent; + pendingApply.requests.push(request); + } else { + pendingApply = { percent, requests: [request] }; + } + ensureApplyWorker(); + }); +} + +async function waitForApplyQueue() { + if (applyWorker) await applyWorker; +} + +async function flushScheduledApply() { + cancelSetTimer(); + const target = scheduledTarget; + scheduledTarget = null; + if (target !== null) await requestBrightnessApply(target); + await waitForApplyQueue(); +} + +export function setBrightness(value: number) { + const percent = clampBrightnessPercent(value * 100); + const normalizedValue = percent / 100; + rememberNonZeroBrightness(normalizedValue); + setBrightnessState(normalizedValue); + scheduledTarget = percent; + + cancelSetTimer(); + setTimer = timeout(100, () => { + setTimer = null; + const target = scheduledTarget; + scheduledTarget = null; + if (target === null) return; + + void requestBrightnessApply(target).catch(async (error) => { + console.error('Failed to set brightness:', error); + try { + await refreshBrightness(); + } catch (refreshError) { + console.error('Failed to refresh brightness after apply failure:', refreshError); + } + }); + }); +} + +export function toggleBrightnessDim() { + const current = brightness(); + if (current > 0) { + lastNonZeroBrightness = current; + setBrightness(0); + return; + } + + setBrightness(lastNonZeroBrightness ?? DEFAULT_RESTORE_BRIGHTNESS); +} + +export function cycleBrightnessPreset() { + const current = brightness(); + const next = BRIGHTNESS_PRESETS.find((preset) => preset > current) ?? BRIGHTNESS_PRESETS[0]; + setBrightness(next); +} + +async function performBrightnessChange(delta: number) { + try { + await flushScheduledApply(); + const next = clampBrightnessPercent((await brightnessBackend.getPercent()) + delta); + await requestBrightnessApply(next); + notify(next); + return next; + } catch (error) { + console.error('Failed to change brightness:', error); + throw error; + } +} + +export function changeBrightness(delta: number): Promise { + const operation = changeQueue.then(() => performBrightnessChange(delta)); + changeQueue = operation.then( + () => {}, + () => {}, + ); + return operation; +} + +export async function refreshBrightness() { + await flushScheduledApply(); + const percent = await brightnessBackend.getPercent(); + const value = percent / 100; + rememberNonZeroBrightness(value); + setBrightnessState(value); + return percent; +} + +export async function refreshBrightnessBackend() { + brightnessBackend.reset(); + return refreshBrightness(); +} + +export async function getBrightnessBackend() { + return brightnessBackend.resolve(); +} + +export const brightnessStep = KEYBOARD_STEP; + +void refreshBrightness().catch((error) => + console.error('Failed to fetch initial brightness:', error), +); diff --git a/src/stores/system/brightnessBackend.ts b/src/stores/system/brightnessBackend.ts new file mode 100644 index 0000000..6a1395a --- /dev/null +++ b/src/stores/system/brightnessBackend.ts @@ -0,0 +1,142 @@ +import { execAsync } from 'ags/process'; + +import GLib from 'gi://GLib'; + +import { appConfig } from '../../lib/config'; + +export type BrightnessBackend = 'ddcutil' | 'brightnessctl'; +type ConfiguredBackend = BrightnessBackend | 'auto'; + +const DDC_VCP_BRIGHTNESS = '10'; + +function clampPercent(value: number) { + return Math.max(0, Math.min(100, Math.round(value))); +} + +function parsePercent(value: string) { + const match = value.match(/(\d+(?:\.\d+)?)\s*%/); + if (match) return clampPercent(Number(match[1])); + + const numeric = Number(value.trim()); + return Number.isFinite(numeric) ? clampPercent(numeric) : null; +} + +function hasProgram(name: string) { + return GLib.find_program_in_path(name) !== null; +} + +export class BrightnessBackendController { + private backend: BrightnessBackend | null = null; + private ddcBuses: string[] = []; + + get current() { + return this.backend; + } + + async resolve() { + if (this.backend) return this.backend; + + const configured = (appConfig.brightness?.backend ?? 'auto') as ConfiguredBackend; + if (configured === 'ddcutil' || configured === 'auto') { + this.ddcBuses = await this.detectDdcBuses(); + if (this.ddcBuses.length > 0) { + this.backend = 'ddcutil'; + return this.backend; + } + if (configured === 'ddcutil') { + throw new Error('No DDC/CI-compatible display was detected'); + } + } + + if (configured === 'brightnessctl' || configured === 'auto') { + if (hasProgram('brightnessctl')) { + this.backend = 'brightnessctl'; + return this.backend; + } + } + + throw new Error(`Brightness backend '${configured}' is unavailable`); + } + + async getPercent() { + const backend = await this.resolve(); + return backend === 'ddcutil' ? this.getDdcBrightness() : this.getBrightnessctlBrightness(); + } + + async applyPercent(percent: number) { + const backend = await this.resolve(); + if (backend === 'ddcutil') { + const displays = await Promise.all( + this.ddcBuses.map(async (bus) => ({ bus, ...(await this.getDdcBrightnessDetails(bus)) })), + ); + await Promise.all( + displays.map(({ bus, maximum }) => + execAsync([ + 'ddcutil', + '--bus', + bus, + 'setvcp', + DDC_VCP_BRIGHTNESS, + String(Math.round((percent / 100) * maximum)), + ]), + ), + ); + return; + } + + await execAsync(['brightnessctl', 'set', `${percent}%`]); + } + + reset() { + this.backend = null; + this.ddcBuses = []; + } + + private async detectDdcBuses() { + if (!hasProgram('ddcutil')) return []; + + try { + const output = await execAsync(['ddcutil', 'detect']); + return [...output.matchAll(/I2C bus:\s*\/dev\/i2c-(\d+)/g)].map((match) => match[1]); + } catch (error) { + console.warn('DDC/CI detection failed:', error); + return []; + } + } + + private async getDdcBrightnessDetails(bus: string) { + const output = await execAsync([ + 'ddcutil', + '--bus', + bus, + 'getvcp', + DDC_VCP_BRIGHTNESS, + '--terse', + ]); + const fields = output.trim().split(/\s+/); + const current = Number(fields[3]); + const maximum = Number(fields[4]); + if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) { + throw new Error(`Unable to parse DDC/CI brightness: ${output.trim()}`); + } + return { current, maximum }; + } + + private async getDdcBrightness() { + const bus = this.ddcBuses[0]; + if (!bus) throw new Error('No DDC/CI bus is available'); + const { current, maximum } = await this.getDdcBrightnessDetails(bus); + return clampPercent((current / maximum) * 100); + } + + private async getBrightnessctlBrightness() { + const output = await execAsync(['brightnessctl', '-m']); + const percent = parsePercent(output.split(',')[3] ?? output); + if (percent === null) throw new Error(`Unable to parse brightnessctl output: ${output.trim()}`); + return percent; + } +} + +export function clampBrightnessPercent(value: number) { + return clampPercent(value); +} diff --git a/src/stores/system/caffeine.ts b/src/stores/system/caffeine.ts new file mode 100644 index 0000000..60afc3f --- /dev/null +++ b/src/stores/system/caffeine.ts @@ -0,0 +1,110 @@ +import { createState } from 'ags'; + +import GLib from 'gi://GLib?version=2.0'; + +import { ryprlandRuntimeDir } from '../../lib/paths'; +import { type IdleDaemon, idleDaemonAdapter } from './idleDaemon'; + +const RUNTIME_DIR = `${ryprlandRuntimeDir}/rystal-shell`; +const CAFFEINE_REMOTE_FILE = `${RUNTIME_DIR}/caffeine-remote`; +export type CaffeineState = 'disabled' | 'enabled' | 'remote'; + +const [caffeineStateValue, setCaffeineState] = createState('disabled'); +const [caffeineBusyValue, setCaffeineBusy] = createState(true); +export const caffeineState = caffeineStateValue; +export const caffeineBusy = caffeineBusyValue; + +let currentState: CaffeineState = 'disabled'; +let activeDaemon: IdleDaemon = 'hypridle'; +let pendingOperations = 1; +let initializationFailed = false; + +function updateCaffeineState(state: CaffeineState) { + currentState = state; + setCaffeineState(state); +} + +function startInhibit() { + GLib.mkdir_with_parents(RUNTIME_DIR, 0o700); + const fd = GLib.creat(CAFFEINE_REMOTE_FILE, 0o644); + if (fd === -1) throw new Error(`Failed to create ${CAFFEINE_REMOTE_FILE}`); + GLib.close(fd); +} + +function stopInhibit() { + if (!GLib.file_test(CAFFEINE_REMOTE_FILE, GLib.FileTest.EXISTS)) return; + if (GLib.remove(CAFFEINE_REMOTE_FILE) === -1) { + throw new Error(`Failed to remove ${CAFFEINE_REMOTE_FILE}`); + } +} + +async function readCaffeineState() { + const runningDaemon = await idleDaemonAdapter.detectRunning(); + if (runningDaemon) activeDaemon = runningDaemon; + + const isInhibitRunning = GLib.file_test(CAFFEINE_REMOTE_FILE, GLib.FileTest.EXISTS); + if (!runningDaemon) return 'enabled'; + return isInhibitRunning ? 'remote' : 'disabled'; +} + +async function initializeCaffeine() { + updateCaffeineState(await readCaffeineState()); +} + +async function performToggle() { + if (currentState === 'disabled') { + await idleDaemonAdapter.stop(activeDaemon); + stopInhibit(); + updateCaffeineState('enabled'); + return; + } + + if (currentState === 'enabled') { + await idleDaemonAdapter.start(activeDaemon); + startInhibit(); + updateCaffeineState('remote'); + return; + } + + await idleDaemonAdapter.start(activeDaemon); + stopInhibit(); + updateCaffeineState('disabled'); +} + +let operationQueue = initializeCaffeine() + .catch((error) => { + initializationFailed = true; + console.error('Failed to initialize caffeine:', error); + }) + .finally(() => { + pendingOperations -= 1; + setCaffeineBusy(initializationFailed || pendingOperations > 0); + }); + +export function toggleCaffeine(): Promise { + if (initializationFailed) { + return Promise.reject(new Error('Caffeine state could not be initialized')); + } + pendingOperations += 1; + setCaffeineBusy(true); + + const operation = operationQueue.then(performToggle).catch(async (error) => { + console.error('Failed to toggle caffeine:', error); + try { + updateCaffeineState(await readCaffeineState()); + } catch (refreshError) { + initializationFailed = true; + console.error('Failed to refresh caffeine state:', refreshError); + } + throw error; + }); + + operationQueue = operation + .catch(() => {}) + .finally(() => { + pendingOperations -= 1; + setCaffeineBusy(initializationFailed || pendingOperations > 0); + }); + + return operation; +} diff --git a/src/stores/system/idleDaemon.ts b/src/stores/system/idleDaemon.ts new file mode 100644 index 0000000..be08e02 --- /dev/null +++ b/src/stores/system/idleDaemon.ts @@ -0,0 +1,129 @@ +import { type Process, execAsync, subprocess } from 'ags/process'; +import { timeout } from 'ags/time'; + +const IDLE_DAEMONS = ['hypridle', 'swayidle'] as const; +const SIGTERM = 15; +const PROCESS_POLL_INTERVAL_MS = 50; +const PROCESS_POLL_ATTEMPTS = 20; + +export type IdleDaemon = (typeof IDLE_DAEMONS)[number]; + +export interface IdleDaemonAdapter { + detectRunning(): Promise; + start(daemon: IdleDaemon): Promise; + stop(daemon: IdleDaemon): Promise; +} + +function delay(milliseconds: number) { + return new Promise((resolve) => { + timeout(milliseconds, resolve); + }); +} + +async function getProcessIds(daemon: IdleDaemon) { + try { + const output = await execAsync(['pidof', daemon]); + return output + .trim() + .split(/\s+/) + .map(Number) + .filter((pid) => Number.isInteger(pid) && pid > 0); + } catch { + return []; + } +} + +async function waitForRunningState(daemon: IdleDaemon, expected: boolean) { + for (let attempt = 0; attempt < PROCESS_POLL_ATTEMPTS; attempt++) { + // eslint-disable-next-line no-await-in-loop + if ((await getProcessIds(daemon)).length > 0 === expected) return true; + // eslint-disable-next-line no-await-in-loop + await delay(PROCESS_POLL_INTERVAL_MS); + } + return false; +} + +class ExecOnceIdleDaemonAdapter implements IdleDaemonAdapter { + private readonly managedProcessIds = new Map>(); + private readonly ownedProcesses = new Map(); + + async detectRunning() { + for (const daemon of IDLE_DAEMONS) { + // eslint-disable-next-line no-await-in-loop + const processIds = await getProcessIds(daemon); + if (processIds.length === 0) continue; + + if (!this.ownedProcesses.has(daemon) && !this.managedProcessIds.has(daemon)) { + this.managedProcessIds.set(daemon, new Set(processIds)); + } + return daemon; + } + return null; + } + + async start(daemon: IdleDaemon) { + if ((await getProcessIds(daemon)).length > 0) return; + + const process = subprocess({ + cmd: [daemon], + out: () => {}, + err: (line) => console.error(`${daemon}: ${line}`), + }); + this.ownedProcesses.set(daemon, process); + process.connect('exit', () => { + if (this.ownedProcesses.get(daemon) === process) this.ownedProcesses.delete(daemon); + }); + + if (await waitForRunningState(daemon, true)) return; + + try { + process.kill(); + } catch { + // The failed process may already have exited. + } + throw new Error(`Failed to start ${daemon}`); + } + + async stop(daemon: IdleDaemon) { + const ownedProcess = this.ownedProcesses.get(daemon); + if (ownedProcess) { + try { + ownedProcess.signal(SIGTERM); + } catch { + // Verify the process state below in case it exited concurrently. + } + + if (!(await waitForRunningState(daemon, false))) { + try { + ownedProcess.kill(); + } catch { + // Verify once more below. + } + } + if (!(await waitForRunningState(daemon, false))) { + throw new Error(`Failed to stop ${daemon}`); + } + return; + } + + const managedProcessIds = this.managedProcessIds.get(daemon); + const currentProcessIds = await getProcessIds(daemon); + if (currentProcessIds.length === 0) return; + if (!managedProcessIds) { + throw new Error(`Refusing to stop unowned ${daemon} process`); + } + + const targets = currentProcessIds.filter((pid) => managedProcessIds.has(pid)); + if (targets.length === 0) { + throw new Error(`The managed ${daemon} process has changed`); + } + + await execAsync(['kill', '-TERM', ...targets.map(String)]); + if (!(await waitForRunningState(daemon, false))) { + throw new Error(`Failed to stop ${daemon}`); + } + this.managedProcessIds.delete(daemon); + } +} + +export const idleDaemonAdapter: IdleDaemonAdapter = new ExecOnceIdleDaemonAdapter(); diff --git a/src/stores/powerProfile.ts b/src/stores/system/powerProfile.ts similarity index 100% rename from src/stores/powerProfile.ts rename to src/stores/system/powerProfile.ts diff --git a/src/stores/system.ts b/src/stores/system/system.ts similarity index 59% rename from src/stores/system.ts rename to src/stores/system/system.ts index 36eb276..4b82f97 100644 --- a/src/stores/system.ts +++ b/src/stores/system/system.ts @@ -1,10 +1,10 @@ -import { createState } from 'ags'; +import { createExternal } from 'ags'; import { execAsync } from 'ags/process'; -import { interval } from 'ags/time'; +import { idle, interval } from 'ags/time'; import GLib from 'gi://GLib?version=2.0'; -import { closeAllControlCenters } from './windowManager'; +import { closeAllControlCenters } from '../shell/windowManager'; export const userName = '@' + GLib.get_user_name(); @@ -16,9 +16,7 @@ export function getOsInfo(): string { if (success && bytes) { const text = new TextDecoder('utf-8').decode(bytes); const match = text.match(/PRETTY_NAME="([^"]+)"/); - if (match) { - osName = match[1]; - } + if (match) osName = match[1]; } } } catch (error) { @@ -40,21 +38,19 @@ export interface RamData { percent: number; } -const [cpuUsageState, setCpuUsage] = createState(0); -const [ramUsageState, setRamUsage] = createState({ used: 0, total: 0, percent: 0 }); -const [gpuUsageState, setGpuUsage] = createState(0); -const [uptimeState, setUptime] = createState('0m'); -export const cpuUsage = cpuUsageState; -export const ramUsage = ramUsageState; -export const gpuUsage = gpuUsageState; -export const uptime = uptimeState; - interface CpuCounters { total: number; idle: number; } -let previousCpuCounters: CpuCounters | null = null; +interface SystemMetrics { + cpu: number; + ram: RamData; + gpu: number; +} + +const EMPTY_RAM: RamData = { used: 0, total: 0, percent: 0 }; +const INITIAL_METRICS: SystemMetrics = { cpu: 0, ram: EMPTY_RAM, gpu: 0 }; const textDecoder = new TextDecoder('utf-8'); const gpuBusyPaths = Array.from( { length: 32 }, @@ -83,46 +79,36 @@ function readCpuCounters(): CpuCounters | null { }; } -function updateCpuUsage() { - const current = readCpuCounters(); - if (!current) return; - - const previous = previousCpuCounters; - previousCpuCounters = current; - if (!previous) return; - +function calculateCpuUsage(current: CpuCounters, previous: CpuCounters) { const totalDelta = current.total - previous.total; const idleDelta = current.idle - previous.idle; - if (totalDelta <= 0) return; - - setCpuUsage(Math.max(0, Math.min(100, 100 * (1 - idleDelta / totalDelta)))); + if (totalDelta <= 0) return 0; + return Math.max(0, Math.min(100, 100 * (1 - idleDelta / totalDelta))); } -function updateRamUsage() { +function readRamUsage(): RamData | null { const meminfo = readText('/proc/meminfo'); - if (!meminfo) return; + if (!meminfo) return null; const total = Number(meminfo.match(/^MemTotal:\s+(\d+)/m)?.[1]); const available = Number(meminfo.match(/^MemAvailable:\s+(\d+)/m)?.[1]); - if (!Number.isFinite(total) || !Number.isFinite(available) || total <= 0) return; + if (!Number.isFinite(total) || !Number.isFinite(available) || total <= 0) return null; const used = total - available; - setRamUsage({ + return { used: used / 1024 / 1024, total: total / 1024 / 1024, percent: used / total, - }); + }; } -function updateGpuUsage() { +function readGpuUsage() { let highestUsage = 0; - for (const path of gpuBusyPaths) { const usage = Number(readText(path)?.trim()); if (Number.isFinite(usage)) highestUsage = Math.max(highestUsage, usage); } - - if (highestUsage !== gpuUsage()) setGpuUsage(highestUsage); + return highestUsage; } function formatUptime(seconds: number) { @@ -141,19 +127,52 @@ function formatUptime(seconds: number) { return `up ${parts.join(', ')}`; } -function updateUptime() { +function readUptime() { const seconds = Number(readText('/proc/uptime')?.split(/\s+/)[0]); - if (!Number.isFinite(seconds) || seconds < 0) return; - - const formatted = formatUptime(seconds); - if (formatted !== uptime()) setUptime(formatted); + return Number.isFinite(seconds) && seconds >= 0 ? formatUptime(seconds) : null; } -function pollSystemMetrics() { - updateCpuUsage(); - updateRamUsage(); - updateGpuUsage(); -} +const systemMetrics = createExternal(INITIAL_METRICS, (setMetrics) => { + let previousCpuCounters = readCpuCounters(); + + function updateMetrics() { + const currentCpuCounters = readCpuCounters(); + const previousMetrics = systemMetrics.peek(); + const cpu = + currentCpuCounters && previousCpuCounters + ? calculateCpuUsage(currentCpuCounters, previousCpuCounters) + : previousMetrics.cpu; + previousCpuCounters = currentCpuCounters ?? previousCpuCounters; + + setMetrics({ + cpu, + ram: readRamUsage() ?? previousMetrics.ram, + gpu: readGpuUsage(), + }); + } + + const initialUpdate = idle(updateMetrics); + const poll = interval(2000, updateMetrics); + return () => { + initialUpdate.cancel(); + poll.cancel(); + }; +}); -const _systemPoll = interval(2000, pollSystemMetrics); -const _uptimePoll = interval(60_000, updateUptime); +export const cpuUsage = systemMetrics.as((metrics) => metrics.cpu); +export const ramUsage = systemMetrics.as((metrics) => metrics.ram); +export const gpuUsage = systemMetrics.as((metrics) => metrics.gpu); + +export const uptime = createExternal('0m', (setUptime) => { + function updateUptime() { + const value = readUptime(); + if (value !== null) setUptime(value); + } + + const initialUpdate = idle(updateUptime); + const poll = interval(60_000, updateUptime); + return () => { + initialUpdate.cancel(); + poll.cancel(); + }; +}); diff --git a/src/stores/time.ts b/src/stores/system/time.ts similarity index 60% rename from src/stores/time.ts rename to src/stores/system/time.ts index 21bf9ad..9aa8927 100644 --- a/src/stores/time.ts +++ b/src/stores/system/time.ts @@ -1,18 +1,24 @@ -import { createState } from 'ags'; +import { createExternal } from 'ags'; +import { type Timer, timeout } from 'ags/time'; const MINUTE_MS = 60_000; -const [now, setNow] = createState(Temporal.Now.zonedDateTimeISO()); - -function scheduleNextMinute() { - const delay = MINUTE_MS - (Date.now() % MINUTE_MS); - - setTimeout(() => { - setNow(Temporal.Now.zonedDateTimeISO()); - scheduleNextMinute(); - }, delay); -} - -scheduleNextMinute(); +const now = createExternal(Temporal.Now.zonedDateTimeISO(), (setNow) => { + let updateTimer: Timer | null = null; + + const scheduleNextMinute = () => { + const delay = MINUTE_MS - (Date.now() % MINUTE_MS); + updateTimer = timeout(delay, () => { + setNow(Temporal.Now.zonedDateTimeISO()); + scheduleNextMinute(); + }); + }; + + scheduleNextMinute(); + return () => { + updateTimer?.cancel(); + updateTimer = null; + }; +}); export const clockTime = now.as( (t) => `${String(t.hour).padStart(2, '0')}:${String(t.minute).padStart(2, '0')}`, diff --git a/src/stores/system/update.ts b/src/stores/system/update.ts new file mode 100644 index 0000000..5871c88 --- /dev/null +++ b/src/stores/system/update.ts @@ -0,0 +1,73 @@ +import { createExternal } from 'ags'; +import { execAsync } from 'ags/process'; +import { type Timer, timeout } from 'ags/time'; + +import { closeAllControlCenters } from '../shell/windowManager'; + +const NORMAL_INTERVAL_MS = 30 * 60_000; +const RETRY_INTERVAL_MS = 60_000; + +let requestRefresh: (() => Promise) | null = null; + +export function openUpdateManager() { + closeAllControlCenters(); + execAsync('kitty --title PacUpdate par_tui') + .then(() => requestRefresh?.()) + .catch(console.error); +} + +export const updatesPoll = createExternal('0', (setUpdates) => { + let refreshTimer: Timer | null = null; + let refreshPromise: Promise | null = null; + let active = true; + + function clearRefreshTimer() { + refreshTimer?.cancel(); + refreshTimer = null; + } + + function scheduleRefresh(delay: number) { + if (!active) return; + clearRefreshTimer(); + refreshTimer = timeout(delay, () => { + refreshTimer = null; + void refreshUpdates(); + }); + } + + async function runRefresh() { + try { + const output = await execAsync([ + 'bash', + '-c', + "ping -c 1 -W 2 archlinux.org >/dev/null && timeout 15 bash -c '(checkupdates 2>/dev/null; paru -Qu 2>/dev/null) | wc -l'", + ]); + if (!active) return; + if (output !== updatesPoll.peek()) setUpdates(output); + scheduleRefresh(NORMAL_INTERVAL_MS); + } catch (error) { + if (!active) return; + console.error(error); + scheduleRefresh(RETRY_INTERVAL_MS); + } + } + + function refreshUpdates() { + if (refreshPromise) return refreshPromise; + + clearRefreshTimer(); + refreshPromise = runRefresh().finally(() => { + refreshPromise = null; + }); + return refreshPromise; + } + + requestRefresh = refreshUpdates; + void refreshUpdates(); + + return () => { + active = false; + clearRefreshTimer(); + if (requestRefresh === refreshUpdates) requestRefresh = null; + }; +}); diff --git a/src/stores/system/weather.ts b/src/stores/system/weather.ts new file mode 100644 index 0000000..1614b3c --- /dev/null +++ b/src/stores/system/weather.ts @@ -0,0 +1,248 @@ +import { createExternal } from 'ags'; +import { type Timer, timeout } from 'ags/time'; + +import GLib from 'gi://GLib?version=2.0'; +import Gio from 'gi://Gio'; +import Soup from 'gi://Soup?version=3.0'; + +import { appConfig } from '../../lib/config'; + +export const LOCATION = appConfig.weather.location; + +const NORMAL_INTERVAL_MS = 30 * 60_000; +const RETRY_INTERVAL_MS = 60_000; +const weatherSession = new Soup.Session(); +const textDecoder = new TextDecoder('utf-8'); + +interface WeatherDescription { + value: string; +} + +interface CurrentCondition { + temp_C: string; + FeelsLikeC: string; + weatherDesc: WeatherDescription[]; + weatherCode: string; + humidity: string; + windspeedKmph: string; +} + +interface WeatherDay { + date: string; + maxtempC: string; + mintempC: string; + hourly: { weatherCode: string }[]; +} + +interface WeatherResponse { + current_condition?: CurrentCondition[]; + nearest_area?: { region?: WeatherDescription[] }[]; + weather?: WeatherDay[]; +} + +interface WeatherForecast { + date: string; + max: string; + min: string; + code: string; +} + +export interface WeatherInfo { + temp: string; + feelsLike: string; + desc: string; + code: string; + humidity: string; + wind: string; + region: string; + todayMax: string; + todayMin: string; + forecast: WeatherForecast[]; +} + +function sendAndRead(message: Soup.Message, cancellable: Gio.Cancellable): Promise { + return new Promise((resolve, reject) => { + weatherSession.send_and_read_async( + message, + GLib.PRIORITY_DEFAULT, + cancellable, + (_session, result) => { + try { + resolve(weatherSession.send_and_read_finish(result)); + } catch (error) { + reject(error); + } + }, + ); + }); +} + +const weatherJson = createExternal('{}', (setWeatherJson) => { + let refreshTimer: Timer | null = null; + let refreshPromise: Promise | null = null; + let requestCancellable: Gio.Cancellable | null = null; + let disposed = false; + + function clearRefreshTimer() { + refreshTimer?.cancel(); + refreshTimer = null; + } + + function scheduleRefresh(delay: number) { + if (disposed) return; + clearRefreshTimer(); + refreshTimer = timeout(delay, () => { + refreshTimer = null; + void refreshWeather(); + }); + } + + async function runRefresh() { + const url = + LOCATION === '' ? 'https://wttr.in/?format=j1' : `https://wttr.in/${LOCATION}?format=j1`; + requestCancellable = new Gio.Cancellable(); + + try { + const message = Soup.Message.new('GET', url); + const bytes = await sendAndRead(message, requestCancellable); + if (disposed) return; + if (message.status_code < 200 || message.status_code >= 300) { + throw new Error(`Weather fetch failed: ${message.status_code}`); + } + + const data = bytes.get_data(); + const output = data ? textDecoder.decode(data) : ''; + if (!output.trim()) throw new Error('Empty weather response'); + JSON.parse(output); + setWeatherJson(output); + scheduleRefresh(NORMAL_INTERVAL_MS); + } catch (error) { + if (disposed) return; + console.error('Weather fetch failed:', error); + scheduleRefresh(RETRY_INTERVAL_MS); + } finally { + requestCancellable = null; + } + } + + function refreshWeather() { + if (refreshPromise) return refreshPromise; + + clearRefreshTimer(); + refreshPromise = runRefresh().finally(() => { + refreshPromise = null; + }); + return refreshPromise; + } + + void refreshWeather(); + return () => { + disposed = true; + clearRefreshTimer(); + requestCancellable?.cancel(); + requestCancellable = null; + }; +}); + +function parseWeatherInfo(json: string): WeatherInfo | null { + try { + const data = JSON.parse(json) as WeatherResponse; + const current = data.current_condition?.[0]; + const today = data.weather?.[0]; + if (!current || !today) return null; + + const rawRegion = data.nearest_area?.[0]?.region?.[0]?.value ?? LOCATION; + // eslint-disable-next-line no-control-regex + const region = rawRegion.replace(/[\u0000-\u001F\u007F-\u009F]/g, ''); + + return { + temp: current.temp_C, + feelsLike: current.FeelsLikeC, + desc: current.weatherDesc[0]?.value ?? '', + code: current.weatherCode, + humidity: current.humidity, + wind: current.windspeedKmph, + region, + todayMax: today.maxtempC, + todayMin: today.mintempC, + forecast: (data.weather ?? []).slice(1, 3).map((day) => ({ + date: day.date, + max: day.maxtempC, + min: day.mintempC, + code: day.hourly[4]?.weatherCode ?? '119', + })), + }; + } catch { + return null; + } +} + +// eslint-disable-next-line complexity +export function getWeatherIcon(code: string) { + const c = parseInt(code); + if (isNaN(c)) return 'cloud'; + + switch (c) { + case 113: + return 'sun'; + case 116: + return 'cloud-sun'; + case 119: + case 122: + return 'cloud'; + case 143: + case 248: + case 260: + return 'cloud-fog'; + case 176: + case 263: + case 266: + case 293: + case 296: + case 299: + case 302: + case 305: + case 308: + return 'cloud-rain'; + case 200: + case 386: + case 389: + case 392: + case 395: + return 'cloud-lightning'; + case 227: + case 230: + case 320: + case 323: + case 326: + case 329: + case 332: + case 335: + case 338: + return 'snowflake'; + case 179: + case 182: + case 185: + case 281: + case 284: + case 311: + case 314: + case 317: + case 350: + case 362: + case 365: + case 368: + case 371: + case 374: + case 377: + return 'cloud-snow'; + case 353: + case 356: + case 359: + return 'cloud-drizzle'; + default: + return 'cloud'; + } +} + +export const weatherInfo = weatherJson.as(parseWeatherInfo); diff --git a/src/stores/update.ts b/src/stores/update.ts deleted file mode 100644 index 99ef698..0000000 --- a/src/stores/update.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { createState } from 'ags'; -import { execAsync } from 'ags/process'; - -import { closeAllControlCenters } from './windowManager'; - -const NORMAL_INTERVAL_MS = 30 * 60_000; -const RETRY_INTERVAL_MS = 60_000; - -let refreshTimer: ReturnType | null = null; -let refreshPromise: Promise | null = null; - -export function openUpdateManager() { - closeAllControlCenters(); - execAsync('kitty --title PacUpdate par_tui') - .then(() => refreshUpdates()) - .catch(console.error); -} - -const [updatesState, setUpdates] = createState('0'); -export const updatesPoll = updatesState; - -function clearRefreshTimer() { - if (refreshTimer === null) return; - clearTimeout(refreshTimer); - refreshTimer = null; -} - -function scheduleRefresh(delay: number) { - clearRefreshTimer(); - refreshTimer = setTimeout(() => { - refreshTimer = null; - void refreshUpdates(); - }, delay); -} - -async function runRefresh() { - try { - const out = await execAsync([ - 'bash', - '-c', - "ping -c 1 -W 2 archlinux.org >/dev/null && timeout 15 bash -c '(checkupdates 2>/dev/null; paru -Qu 2>/dev/null) | wc -l'", - ]); - if (out !== updatesPoll.peek()) setUpdates(out); - scheduleRefresh(NORMAL_INTERVAL_MS); - } catch (error) { - console.error(error); - scheduleRefresh(RETRY_INTERVAL_MS); - } -} - -function refreshUpdates(): Promise { - if (refreshPromise) return refreshPromise; - - clearRefreshTimer(); - refreshPromise = runRefresh().finally(() => { - refreshPromise = null; - }); - return refreshPromise; -} - -void refreshUpdates(); diff --git a/src/stores/wallpaper.ts b/src/stores/wallpaper/wallpaper.ts similarity index 59% rename from src/stores/wallpaper.ts rename to src/stores/wallpaper/wallpaper.ts index 2cf9797..0a84551 100644 --- a/src/stores/wallpaper.ts +++ b/src/stores/wallpaper/wallpaper.ts @@ -4,29 +4,25 @@ import { execAsync } from 'ags/process'; import GLib from 'gi://GLib'; import Gio from 'gi://Gio'; -import { ryprlandCacheDir, ryprlandWallpaperDir } from '../lib/paths'; - -export type Wallpaper = { +import { ryprlandWallpaperDir } from '../../lib/paths'; +import { + cancelWallpaperThumbnailWork, + ensureWallpaperThumbnails, + getWallpaperThumbnailPath, +} from './wallpaperThumbnail'; + +export interface Wallpaper { path: string; relativePath: string; searchText: string; thumbnailPath: string; size: number; modified: number; -}; +} -const THUMBNAIL_WIDTH = 384; -const THUMBNAIL_HEIGHT = 252; -const THUMBNAIL_VERSION = `v7-${THUMBNAIL_WIDTH}x${THUMBNAIL_HEIGHT}`; -const MAX_THUMBNAIL_WORKERS = 4; const SUPPORTED_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'gif']); -const thumbnailSubscribers = new Set<(path: string, thumbnailPath: string) => void>(); -const textEncoder = new TextEncoder(); - const wallpaperRoot = GLib.canonicalize_filename(ryprlandWallpaperDir, GLib.get_home_dir()); -const cacheRoot = `${ryprlandCacheDir}/wallpapers/thumbnails`; - const [wallpapersState, setWallpapers] = createState([]); const [wallpapersLoadingState, setWallpapersLoading] = createState(false); const [wallpaperApplyingState, setWallpaperApplying] = createState(false); @@ -36,16 +32,8 @@ export const wallpapersLoading = wallpapersLoadingState; export const wallpaperApplying = wallpaperApplyingState; export const wallpaperError = wallpaperErrorState; -type ThumbnailJob = { - wallpaper: Wallpaper; - generation: number; - priority: number; -}; - -let generation = 0; -let activeWorkers = 0; -let thumbnailQueue: ThumbnailJob[] = []; -const queuedThumbnails = new Map(); +let refreshGeneration = 0; +let refreshCancellable: Gio.Cancellable | null = null; function queryInfo( file: Gio.File, @@ -118,18 +106,12 @@ function isSupported(name: string) { return SUPPORTED_EXTENSIONS.has(extension); } -function thumbnailKey(path: string, size: number, modified: number) { - const payload = textEncoder.encode(`${path}\0${size}:${modified}\0${THUMBNAIL_VERSION}`); - return GLib.compute_checksum_for_data(GLib.ChecksumType.SHA256, payload); -} - function createWallpaper(path: string, relativePath: string, size: number, modified: number) { - const cacheKey = thumbnailKey(path, size, modified); return { path, relativePath, searchText: relativePath.toLocaleLowerCase(), - thumbnailPath: `${cacheRoot}/${cacheKey}.png`, + thumbnailPath: getWallpaperThumbnailPath(path, size, modified), size, modified, } satisfies Wallpaper; @@ -139,11 +121,10 @@ async function enumerateDirectory( directory: Gio.File, relativeDirectory: string, cancellable: Gio.Cancellable, - refreshGeneration: number, + generation: number, output: Wallpaper[], ): Promise { - if (refreshGeneration !== generation) return; - + if (generation !== refreshGeneration) return; const enumerator = await enumerateChildren( directory, 'standard::name,standard::type,standard::size,time::modified', @@ -153,7 +134,7 @@ async function enumerateDirectory( try { // Gio's asynchronous enumerator advances independently of these generation guards. // eslint-disable-next-line no-unmodified-loop-condition - while (refreshGeneration === generation) { + while (generation === refreshGeneration) { // Sequential batches keep directory enumeration ordered and bounded. // eslint-disable-next-line no-await-in-loop const infos = await nextFiles(enumerator, cancellable); @@ -167,7 +148,7 @@ async function enumerateDirectory( if (info.get_file_type() === Gio.FileType.DIRECTORY) { // Recurse sequentially so one refresh cannot open unbounded enumerators. // eslint-disable-next-line no-await-in-loop - await enumerateDirectory(child, relativePath, cancellable, refreshGeneration, output); + await enumerateDirectory(child, relativePath, cancellable, generation, output); } else if (info.get_file_type() === Gio.FileType.REGULAR && isSupported(name)) { const path = child.get_path(); if (!path) continue; @@ -181,101 +162,16 @@ async function enumerateDirectory( } } -function notifyThumbnailReady(path: string, thumbnailPath: string) { - for (const subscriber of thumbnailSubscribers) subscriber(path, thumbnailPath); -} - -async function generateThumbnail(job: ThumbnailJob) { - const { wallpaper, generation: jobGeneration } = job; - if (GLib.file_test(wallpaper.thumbnailPath, GLib.FileTest.IS_REGULAR)) { - if (jobGeneration === generation) notifyThumbnailReady(wallpaper.path, wallpaper.thumbnailPath); - return; - } - - GLib.mkdir_with_parents(cacheRoot, 0o755); - const temporaryPath = `${wallpaper.thumbnailPath}.tmp-${GLib.uuid_string_random()}`; - - try { - await execAsync([ - 'magick', - `${wallpaper.path}[0]`, - '-strip', - '-thumbnail', - `${THUMBNAIL_WIDTH}x${THUMBNAIL_HEIGHT}^`, - '-gravity', - 'center', - '-extent', - `${THUMBNAIL_WIDTH}x${THUMBNAIL_HEIGHT}`, - `png:${temporaryPath}`, - ]); - Gio.File.new_for_path(temporaryPath).move( - Gio.File.new_for_path(wallpaper.thumbnailPath), - Gio.FileCopyFlags.OVERWRITE, - null, - null, - ); - if (jobGeneration === generation) notifyThumbnailReady(wallpaper.path, wallpaper.thumbnailPath); - } catch (error) { - console.error(`Failed to generate wallpaper thumbnail for ${wallpaper.path}:`, error); - try { - Gio.File.new_for_path(temporaryPath).delete(null); - } catch { - // The failed command may not have created a temporary file. - } - } -} - -function finishThumbnailJob() { - activeWorkers--; - pumpThumbnailQueue(); -} - -function startThumbnailJob(job: ThumbnailJob) { - activeWorkers++; - void generateThumbnail(job).finally(finishThumbnailJob); -} - -function pumpThumbnailQueue() { - if (activeWorkers >= MAX_THUMBNAIL_WORKERS) return; - const job = thumbnailQueue.shift(); - if (!job) return; - queuedThumbnails.delete(job.wallpaper.path); - if (job.generation === generation) startThumbnailJob(job); - pumpThumbnailQueue(); -} - -export function ensureWallpaperThumbnails(items: Wallpaper[], priority = true) { - for (const wallpaper of items) { - if (GLib.file_test(wallpaper.thumbnailPath, GLib.FileTest.IS_REGULAR)) continue; - - const existing = queuedThumbnails.get(wallpaper.path); - if (existing) { - if (priority) existing.priority = 1; - continue; - } - - const job = { wallpaper, generation, priority: priority ? 1 : 0 }; - thumbnailQueue.push(job); - queuedThumbnails.set(wallpaper.path, job); - } - thumbnailQueue.sort((a, b) => b.priority - a.priority); - pumpThumbnailQueue(); -} - -export function subscribeThumbnailReady(subscriber: (path: string, thumbnailPath: string) => void) { - thumbnailSubscribers.add(subscriber); - return () => thumbnailSubscribers.delete(subscriber); -} - export async function refreshWallpapers() { - const refreshGeneration = ++generation; - thumbnailQueue = []; - queuedThumbnails.clear(); + const generation = ++refreshGeneration; + refreshCancellable?.cancel(); + cancelWallpaperThumbnailWork(); + const cancellable = new Gio.Cancellable(); + refreshCancellable = cancellable; setWallpapersLoading(true); setWallpaperError(''); const root = Gio.File.new_for_path(wallpaperRoot); - const cancellable = new Gio.Cancellable(); const found: Wallpaper[] = []; try { @@ -287,8 +183,8 @@ export async function refreshWallpapers() { ); if (info.get_file_type() !== Gio.FileType.DIRECTORY) throw new Error('Not a directory'); - await enumerateDirectory(root, '', cancellable, refreshGeneration, found); - if (refreshGeneration !== generation) return; + await enumerateDirectory(root, '', cancellable, generation, found); + if (generation !== refreshGeneration) return; found.sort((a, b) => a.relativePath.localeCompare(b.relativePath, undefined, { @@ -299,19 +195,23 @@ export async function refreshWallpapers() { setWallpapers(found); ensureWallpaperThumbnails(found, false); } catch (error) { - if (refreshGeneration !== generation) return; + if (generation !== refreshGeneration) return; console.error(`Failed to scan wallpaper directory ${wallpaperRoot}:`, error); setWallpapers([]); setWallpaperError(`Wallpaper directory is unavailable: ${wallpaperRoot}`); } finally { - if (refreshGeneration === generation) setWallpapersLoading(false); + if (generation === refreshGeneration) { + refreshCancellable = null; + setWallpapersLoading(false); + } } } export function cancelWallpaperWork() { - generation++; - thumbnailQueue = []; - queuedThumbnails.clear(); + refreshGeneration++; + refreshCancellable?.cancel(); + refreshCancellable = null; + cancelWallpaperThumbnailWork(); setWallpapersLoading(false); } @@ -326,8 +226,9 @@ export async function applyWallpaper(wallpaper: Wallpaper) { try { const rootPrefix = wallpaperRoot.endsWith('/') ? wallpaperRoot : `${wallpaperRoot}/`; - if (!wallpaper.path.startsWith(rootPrefix)) + if (!wallpaper.path.startsWith(rootPrefix)) { throw new Error('Wallpaper is outside the configured root'); + } const info = await queryInfo( Gio.File.new_for_path(wallpaper.path), @@ -335,8 +236,9 @@ export async function applyWallpaper(wallpaper: Wallpaper) { Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null, ); - if (info.get_file_type() !== Gio.FileType.REGULAR) + if (info.get_file_type() !== Gio.FileType.REGULAR) { throw new Error('Wallpaper no longer exists'); + } await execAsync(['theme-switch.sh', 'set', '--', wallpaper.path]); return true; diff --git a/src/stores/wallpaper/wallpaperSelector.ts b/src/stores/wallpaper/wallpaperSelector.ts new file mode 100644 index 0000000..45bfa27 --- /dev/null +++ b/src/stores/wallpaper/wallpaperSelector.ts @@ -0,0 +1,96 @@ +import { type Accessor, createState, onCleanup } from 'ags'; +import { type Timer, interval, timeout } from 'ags/time'; + +import { deactivateSidePanel } from '../shell/windowManager'; +import { cancelWallpaperWork, clearWallpaperError, refreshWallpapers } from './wallpaper'; + +const PANEL_HEIGHT = 390; +const HIDE_DELAY_MS = 420; +const ANIMATION_INTERVAL_MS = 1000 / 60; +const ANIMATION_SPEED = 0.22; + +export interface WallpaperSelectorStateOptions { + monitorConnector: string | null; + setCoverFlowActive: (active: boolean) => void; +} + +export interface WallpaperSelectorState { + visible: Accessor; + revealed: Accessor; + panelHeight: Accessor; + showAnimated: () => void; + hideAnimated: () => void; +} + +export function createWallpaperSelectorState({ + monitorConnector, + setCoverFlowActive, +}: WallpaperSelectorStateOptions): WallpaperSelectorState { + const [visible, setVisible] = createState(false); + const [revealed, setRevealed] = createState(false); + const [panelHeight, setPanelHeight] = createState(0); + let currentPanelHeight = 0; + let targetPanelHeight = 0; + let animationTimer: Timer | null = null; + let hideTimer: Timer | null = null; + + function cancelHideTimer() { + hideTimer?.cancel(); + hideTimer = null; + } + + function animatePanelTo(height: number) { + targetPanelHeight = height; + if (animationTimer) return; + + animationTimer = interval(ANIMATION_INTERVAL_MS, () => { + const difference = targetPanelHeight - currentPanelHeight; + if (Math.abs(difference) < 1) { + currentPanelHeight = targetPanelHeight; + setPanelHeight(currentPanelHeight); + animationTimer?.cancel(); + animationTimer = null; + return; + } + + currentPanelHeight += difference * ANIMATION_SPEED; + setPanelHeight(currentPanelHeight); + }); + } + + function hideAnimated() { + setRevealed(false); + animatePanelTo(0); + + deactivateSidePanel('wallpaper-selector', monitorConnector); + + cancelHideTimer(); + hideTimer = timeout(HIDE_DELAY_MS, () => { + hideTimer = null; + setCoverFlowActive(false); + cancelWallpaperWork(); + setVisible(false); + }); + } + + function showAnimated() { + cancelHideTimer(); + clearWallpaperError(); + setVisible(true); + setCoverFlowActive(true); + setRevealed(true); + animatePanelTo(PANEL_HEIGHT); + void refreshWallpapers(); + } + + onCleanup(() => { + cancelHideTimer(); + animationTimer?.cancel(); + animationTimer = null; + setCoverFlowActive(false); + cancelWallpaperWork(); + deactivateSidePanel('wallpaper-selector', monitorConnector); + }); + + return { visible, revealed, panelHeight, showAnimated, hideAnimated }; +} diff --git a/src/stores/wallpaper/wallpaperThumbnail.ts b/src/stores/wallpaper/wallpaperThumbnail.ts new file mode 100644 index 0000000..ef170f0 --- /dev/null +++ b/src/stores/wallpaper/wallpaperThumbnail.ts @@ -0,0 +1,178 @@ +import { type Process, subprocess } from 'ags/process'; + +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; + +import { ryprlandCacheDir } from '../../lib/paths'; +import type { Wallpaper } from './wallpaper'; + +const THUMBNAIL_WIDTH = 384; +const THUMBNAIL_HEIGHT = 252; +const THUMBNAIL_VERSION = `v7-${THUMBNAIL_WIDTH}x${THUMBNAIL_HEIGHT}`; +const MAX_THUMBNAIL_WORKERS = 4; +const cacheRoot = `${ryprlandCacheDir}/wallpapers/thumbnails`; +const thumbnailSubscribers = new Set<(path: string, thumbnailPath: string) => void>(); +const textEncoder = new TextEncoder(); + +interface ThumbnailJob { + wallpaper: Wallpaper; + generation: number; + priority: number; +} + +interface ActiveThumbnailJob { + job: ThumbnailJob; + process: Process; + temporaryPath: string; + cancelled: boolean; +} + +let generation = 0; +let thumbnailQueue: ThumbnailJob[] = []; +const queuedThumbnails = new Map(); +const activeThumbnailJobs = new Set(); + +function thumbnailKey(path: string, size: number, modified: number) { + const payload = textEncoder.encode(`${path}\0${size}:${modified}\0${THUMBNAIL_VERSION}`); + return GLib.compute_checksum_for_data(GLib.ChecksumType.SHA256, payload); +} + +export function getWallpaperThumbnailPath(path: string, size: number, modified: number) { + return `${cacheRoot}/${thumbnailKey(path, size, modified)}.png`; +} + +function notifyThumbnailReady(path: string, thumbnailPath: string) { + for (const subscriber of thumbnailSubscribers) subscriber(path, thumbnailPath); +} + +function deleteTemporaryThumbnail(path: string) { + try { + Gio.File.new_for_path(path).delete(null); + } catch { + // The process may not have created the file, or it may already have been moved. + } +} + +function waitForThumbnailProcess(process: Process) { + return new Promise<{ code: number; signaled: boolean }>((resolve) => { + process.connect('exit', (_, code, signaled) => resolve({ code, signaled })); + }); +} + +async function generateThumbnail(job: ThumbnailJob) { + const { wallpaper, generation: jobGeneration } = job; + if (GLib.file_test(wallpaper.thumbnailPath, GLib.FileTest.IS_REGULAR)) { + if (jobGeneration === generation) notifyThumbnailReady(wallpaper.path, wallpaper.thumbnailPath); + return; + } + + GLib.mkdir_with_parents(cacheRoot, 0o755); + const temporaryPath = `${wallpaper.thumbnailPath}.tmp-${GLib.uuid_string_random()}`; + const errors: string[] = []; + let activeJob: ActiveThumbnailJob | null = null; + + try { + if (jobGeneration !== generation) return; + + const process = subprocess({ + cmd: [ + 'magick', + `${wallpaper.path}[0]`, + '-strip', + '-thumbnail', + `${THUMBNAIL_WIDTH}x${THUMBNAIL_HEIGHT}^`, + '-gravity', + 'center', + '-extent', + `${THUMBNAIL_WIDTH}x${THUMBNAIL_HEIGHT}`, + `png:${temporaryPath}`, + ], + err: (line) => errors.push(line), + }); + activeJob = { job, process, temporaryPath, cancelled: false }; + activeThumbnailJobs.add(activeJob); + + const { code, signaled } = await waitForThumbnailProcess(process); + if (activeJob.cancelled || jobGeneration !== generation) return; + if (signaled || code !== 0) { + throw new Error(errors.join('\n') || `magick exited with status ${code}`); + } + + Gio.File.new_for_path(temporaryPath).move( + Gio.File.new_for_path(wallpaper.thumbnailPath), + Gio.FileCopyFlags.OVERWRITE, + null, + null, + ); + if (jobGeneration === generation) notifyThumbnailReady(wallpaper.path, wallpaper.thumbnailPath); + } catch (error) { + if (jobGeneration === generation && !activeJob?.cancelled) { + console.error(`Failed to generate wallpaper thumbnail for ${wallpaper.path}:`, error); + } + } finally { + if (activeJob) activeThumbnailJobs.delete(activeJob); + deleteTemporaryThumbnail(temporaryPath); + } +} + +function startThumbnailJob(job: ThumbnailJob) { + void generateThumbnail(job).finally(pumpThumbnailQueue); +} + +function pumpThumbnailQueue() { + while (activeThumbnailJobs.size < MAX_THUMBNAIL_WORKERS) { + const job = thumbnailQueue.shift(); + if (!job) return; + queuedThumbnails.delete(job.wallpaper.path); + if (job.generation === generation) startThumbnailJob(job); + } +} + +export function ensureWallpaperThumbnails(items: Wallpaper[], priority = true) { + const currentGeneration = generation; + for (const wallpaper of items) { + if (GLib.file_test(wallpaper.thumbnailPath, GLib.FileTest.IS_REGULAR)) continue; + if ( + [...activeThumbnailJobs].some( + (activeJob) => + activeJob.job.generation === currentGeneration && + activeJob.job.wallpaper.path === wallpaper.path, + ) + ) { + continue; + } + + const existing = queuedThumbnails.get(wallpaper.path); + if (existing) { + if (priority) existing.priority = 1; + continue; + } + + const job = { wallpaper, generation: currentGeneration, priority: priority ? 1 : 0 }; + thumbnailQueue.push(job); + queuedThumbnails.set(wallpaper.path, job); + } + thumbnailQueue.sort((a, b) => b.priority - a.priority); + pumpThumbnailQueue(); +} + +export function subscribeThumbnailReady(subscriber: (path: string, thumbnailPath: string) => void) { + thumbnailSubscribers.add(subscriber); + return () => thumbnailSubscribers.delete(subscriber); +} + +export function cancelWallpaperThumbnailWork() { + generation++; + thumbnailQueue = []; + queuedThumbnails.clear(); + + for (const activeJob of activeThumbnailJobs) { + activeJob.cancelled = true; + try { + activeJob.process.kill(); + } catch { + // The process may have exited immediately before cancellation. + } + deleteTemporaryThumbnail(activeJob.temporaryPath); + } +} diff --git a/src/stores/weather.ts b/src/stores/weather.ts deleted file mode 100644 index 1a9fb29..0000000 --- a/src/stores/weather.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { createState } from 'ags'; - -import GLib from 'gi://GLib?version=2.0'; -import Soup from 'gi://Soup?version=3.0'; - -import { appConfig } from '../lib/config'; - -export const LOCATION = appConfig.weather.location; - -const [weatherJson, setWeatherJson] = createState('{}'); - -const NORMAL_INTERVAL_MS = 30 * 60_000; -const RETRY_INTERVAL_MS = 60_000; -const weatherSession = new Soup.Session(); -const textDecoder = new TextDecoder('utf-8'); - -let refreshTimer: ReturnType | null = null; -let refreshPromise: Promise | null = null; - -function sendAndRead(message: Soup.Message): Promise { - return new Promise((resolve, reject) => { - weatherSession.send_and_read_async(message, GLib.PRIORITY_DEFAULT, null, (_session, result) => { - try { - resolve(weatherSession.send_and_read_finish(result)); - } catch (error) { - reject(error); - } - }); - }); -} - -function clearRefreshTimer() { - if (refreshTimer === null) return; - clearTimeout(refreshTimer); - refreshTimer = null; -} - -function scheduleRefresh(delay: number) { - clearRefreshTimer(); - refreshTimer = setTimeout(() => { - refreshTimer = null; - void refreshWeather(); - }, delay); -} - -async function runRefresh() { - const url = - LOCATION === '' ? 'https://wttr.in/?format=j1' : `https://wttr.in/${LOCATION}?format=j1`; - try { - const message = Soup.Message.new('GET', url); - const bytes = await sendAndRead(message); - if (message.status_code < 200 || message.status_code >= 300) { - throw new Error(`Weather fetch failed: ${message.status_code}`); - } - - const data = bytes.get_data(); - const out = data ? textDecoder.decode(data) : ''; - if (!out || out.trim() === '') throw new Error('Empty weather response'); - JSON.parse(out); - if (out !== weatherJson.peek()) setWeatherJson(out); - scheduleRefresh(NORMAL_INTERVAL_MS); - } catch (error) { - console.error('Weather fetch failed:', error); - scheduleRefresh(RETRY_INTERVAL_MS); - } -} - -function refreshWeather(): Promise { - if (refreshPromise) return refreshPromise; - - clearRefreshTimer(); - refreshPromise = runRefresh().finally(() => { - refreshPromise = null; - }); - return refreshPromise; -} - -void refreshWeather(); - -// eslint-disable-next-line complexity -export function getWeatherIcon(code: string) { - const c = parseInt(code); - if (isNaN(c)) return 'cloud'; - - switch (c) { - case 113: - return 'sun'; - case 116: - return 'cloud-sun'; - case 119: - case 122: - return 'cloud'; - case 143: - case 248: - case 260: - return 'cloud-fog'; - case 176: - case 263: - case 266: - case 293: - case 296: - case 299: - case 302: - case 305: - case 308: - return 'cloud-rain'; - case 200: - case 386: - case 389: - case 392: - case 395: - return 'cloud-lightning'; - case 227: - case 230: - case 320: - case 323: - case 326: - case 329: - case 332: - case 335: - case 338: - return 'snowflake'; - case 179: - case 182: - case 185: - case 281: - case 284: - case 311: - case 314: - case 317: - case 350: - case 362: - case 365: - case 368: - case 371: - case 374: - case 377: - return 'cloud-snow'; - case 353: - case 356: - case 359: - return 'cloud-drizzle'; - default: - return 'cloud'; - } -} - -export const weatherInfo = weatherJson.as((str) => { - try { - const data = JSON.parse(str); - if (!data.current_condition || data.current_condition.length === 0) return null; - - const current = data.current_condition[0]; - const today = data.weather[0]; - const rawRegion = data.nearest_area?.[0]?.region?.[0]?.value || LOCATION; - // eslint-disable-next-line no-control-regex - const region = rawRegion.replace(/[\u0000-\u001F\u007F-\u009F]/g, ''); - - return { - temp: current.temp_C, - feelsLike: current.FeelsLikeC, - desc: current.weatherDesc[0].value, - code: current.weatherCode, - humidity: current.humidity, - wind: current.windspeedKmph, - region: region, - todayMax: today.maxtempC, - todayMin: today.mintempC, - forecast: data.weather - .slice(1, 3) - .map( - (w: { - date: string; - maxtempC: string; - mintempC: string; - hourly: { weatherCode: string }[]; - }) => ({ - date: w.date, - max: w.maxtempC, - min: w.mintempC, - code: w.hourly[4].weatherCode, - }), - ), - }; - } catch { - return null; - } -}); diff --git a/src/stores/wifiPasswordDialog.ts b/src/stores/wifiPasswordDialog.ts deleted file mode 100644 index d87e010..0000000 --- a/src/stores/wifiPasswordDialog.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createState } from 'ags'; - -export interface WifiPasswordRequest { - monitor: string; - ssid: string; - submit: (password: string) => Promise; -} - -const [wifiPasswordRequestState, setWifiPasswordRequest] = createState( - null, -); -export const wifiPasswordRequest = wifiPasswordRequestState; - -export function openWifiPasswordDialog(request: WifiPasswordRequest) { - setWifiPasswordRequest(request); -} - -export function closeWifiPasswordDialog(monitor?: string) { - const request = wifiPasswordRequest(); - if (!monitor || request?.monitor === monitor) setWifiPasswordRequest(null); -} diff --git a/src/widget/app-launcher/index.tsx b/src/widget/app-launcher/index.tsx index 63caf0a..778225d 100644 --- a/src/widget/app-launcher/index.tsx +++ b/src/widget/app-launcher/index.tsx @@ -1,39 +1,81 @@ -import { createState } from 'ags'; +import { onCleanup } from 'ags'; import { Astal, Gdk, Gtk } from 'ags/gtk4'; import app from 'ags/gtk4/app'; +import { type Timer, idle } from 'ags/time'; -import Apps from 'gi://AstalApps'; -import GLib from 'gi://GLib'; - -import { ensureLauncherBackground, registerLauncherBackground } from '../../stores/launcherImage'; +import { createAppLauncherState } from '../../stores/application/appLauncher'; +import { + ensureLauncherBackground, + registerLauncherBackground, +} from '../../stores/application/launcherBackground'; import { AppList } from './widget/AppList'; import { SearchInput } from './widget/SearchInput'; -export default function AppLauncher(gdkmonitor: Gdk.Monitor) { - const [text, setText] = createState(''); - const [selectedIndex, setSelectedIndex] = createState(0); - const monitorConnector = gdkmonitor.get_connector(); - - let currentResults: Apps.Application[] = []; - const launcherBackground = new Gtk.Picture({ +function createLauncherBackground() { + const picture = new Gtk.Picture({ contentFit: Gtk.ContentFit.COVER, canTarget: false, canShrink: true, + hexpand: true, + vexpand: true, + halign: Gtk.Align.FILL, + valign: Gtk.Align.FILL, + widthRequest: 1, + heightRequest: 1, + }); + + const unregister = registerLauncherBackground(picture); + picture.connect('destroy', unregister); + + return picture; +} + +function resetLauncherState( + searchInput: Gtk.Entry, + setText: (text: string) => void, + setSelectedIndex: (index: number) => void, +) { + searchInput.set_text(''); + setText(''); + setSelectedIndex(0); +} + +function focusLauncher(searchInput: Gtk.Entry, appList: Gtk.ScrolledWindow) { + ensureLauncherBackground(); + return idle(() => { + searchInput.grab_focus(); + appList.get_vadjustment()?.set_value(0); }); - launcherBackground.set_hexpand(true); - launcherBackground.set_vexpand(true); - launcherBackground.set_halign(Gtk.Align.FILL); - launcherBackground.set_valign(Gtk.Align.FILL); - launcherBackground.set_size_request(1, 1); - const unregisterLauncherBackground = registerLauncherBackground(launcherBackground); - launcherBackground.connect('destroy', unregisterLauncherBackground); +} + +function addEscapeHandler(window: Astal.Window) { + const controller = new Gtk.EventControllerKey(); + controller.connect('key-pressed', (_, keyval) => { + if (keyval !== Gdk.KEY_Escape) return false; + + window.set_visible(false); + return true; + }); + window.add_controller(controller); +} + +export interface AppLauncherProps { + monitor: Gdk.Monitor; +} + +export default function AppLauncher({ monitor }: AppLauncherProps) { + const { text, setText, selectedIndex, setSelectedIndex, results } = createAppLauncherState(); + const monitorConnector = monitor.get_connector(); + let focusTimer: Timer | null = null; + + const launcherBackground = createLauncherBackground(); const searchInput = SearchInput({ text, setText, selectedIndex, setSelectedIndex, - getResults: () => currentResults, + results, monitorConnector, }); @@ -49,34 +91,28 @@ export default function AppLauncher(gdkmonitor: Gdk.Monitor) { const appList = AppList({ text, selectedIndex, + results, monitorConnector, - onResultsChanged: (results) => { - currentResults = results; - }, }); const win = ( { + focusTimer?.cancel(); + focusTimer = null; + if (!self.visible) { - (searchInput as Gtk.Entry).set_text(''); - setText(''); - setSelectedIndex(0); + resetLauncherState(searchInput, setText, setSelectedIndex); } else { - ensureLauncherBackground(); - GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { - searchInput.grab_focus(); - appList.get_vadjustment()?.set_value(0); - return GLib.SOURCE_REMOVE; - }); + focusTimer = focusLauncher(searchInput, appList); } }} > @@ -112,15 +148,11 @@ export default function AppLauncher(gdkmonitor: Gdk.Monitor) { ) as Astal.Window; - const winKeyCtrl = new Gtk.EventControllerKey(); - winKeyCtrl.connect('key-pressed', (_, keyval) => { - if (keyval === Gdk.KEY_Escape) { - win.set_visible(false); - return true; - } - return false; + addEscapeHandler(win); + onCleanup(() => { + focusTimer?.cancel(); + focusTimer = null; }); - win.add_controller(winKeyCtrl); return win; } diff --git a/src/widget/app-launcher/widget/AppItem.ts b/src/widget/app-launcher/widget/AppItem.ts deleted file mode 100644 index a438aa1..0000000 --- a/src/widget/app-launcher/widget/AppItem.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Gtk } from 'ags/gtk4'; - -import Apps from 'gi://AstalApps'; -import Pango from 'gi://Pango'; - -import { recordAppLaunch } from '../../../stores/application'; -import { toggleAppLauncher } from '../../../stores/windowManager'; - -export function createAppItem(res: Apps.Application, monitorConnector: string | null) { - const btn = new Gtk.Button({ - cssClasses: ['applauncher-item'], - canFocus: false, - }); - - const box = new Gtk.Box({ - orientation: Gtk.Orientation.HORIZONTAL, - spacing: 12, - }); - - const iconStr = res.iconName || 'application-x-executable'; - const iconProps: { cssClasses: string[]; file?: string; iconName?: string } = { - cssClasses: ['applauncher-item-icon'], - }; - - if (iconStr.startsWith('/')) { - iconProps.file = iconStr; - } else { - iconProps.iconName = iconStr; - } - - const icon = new Gtk.Image(iconProps); - - const textBox = new Gtk.Box({ - orientation: Gtk.Orientation.VERTICAL, - valign: Gtk.Align.CENTER, - }); - - const nameLabel = new Gtk.Label({ - label: res.name, - halign: Gtk.Align.START, - cssClasses: ['applauncher-item-name'], - }); - - textBox.append(nameLabel); - - if (res.description) { - const descLabel = new Gtk.Label({ - label: res.description, - halign: Gtk.Align.START, - cssClasses: ['applauncher-item-desc'], - ellipsize: Pango.EllipsizeMode.END, - maxWidthChars: 40, - }); - textBox.append(descLabel); - } - - box.append(icon); - box.append(textBox); - btn.set_child(box); - - btn.connect('clicked', () => { - toggleAppLauncher(monitorConnector); - recordAppLaunch(res); - res.launch(); - }); - - return btn; -} diff --git a/src/widget/app-launcher/widget/AppItem.tsx b/src/widget/app-launcher/widget/AppItem.tsx new file mode 100644 index 0000000..d3036b7 --- /dev/null +++ b/src/widget/app-launcher/widget/AppItem.tsx @@ -0,0 +1,61 @@ +import { Gtk } from 'ags/gtk4'; + +import Apps from 'gi://AstalApps'; +import Pango from 'gi://Pango'; + +import { recordAppLaunch } from '../../../stores/application/applicationCatalog'; +import { toggleAppLauncher } from '../../../stores/shell/windowManager'; + +export interface AppItemProps { + res: Apps.Application; + monitorConnector: string | null; +} + +function createImageProp(iconStr: string): Partial { + const iconProps: Partial = { + cssClasses: ['applauncher-item-icon'], + }; + + if (iconStr.startsWith('/')) { + iconProps.file = iconStr; + } else { + iconProps.iconName = iconStr; + } + + return iconProps; +} + +export function AppItem({ res, monitorConnector }: AppItemProps): Gtk.Button { + return ( + + ) as Gtk.Button; +} diff --git a/src/widget/app-launcher/widget/AppList.tsx b/src/widget/app-launcher/widget/AppList.tsx index 207798f..6b9b9e4 100644 --- a/src/widget/app-launcher/widget/AppList.tsx +++ b/src/widget/app-launcher/widget/AppList.tsx @@ -1,170 +1,133 @@ +import { type Accessor, For, createEffect, onCleanup } from 'ags'; import { Gtk } from 'ags/gtk4'; +import { type Timer, idle } from 'ags/time'; import Apps from 'gi://AstalApps'; -import GLib from 'gi://GLib'; -import { searchApps } from '../../../stores/application'; -import { createAppItem } from './AppItem'; +import { AppItem } from './AppItem'; import { SearchGoogleBtn } from './SearchGoogleBtn'; -interface State { - get: () => T; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - as: (cb: (v: T) => any) => any; - subscribe: (cb: () => void) => () => void; +export interface AppListProps { + text: Accessor; + selectedIndex: Accessor; + results: Accessor; + monitorConnector: string | null; } -export function AppList({ - text, - selectedIndex, - monitorConnector, - onResultsChanged, -}: { - text: State; - selectedIndex: State; - monitorConnector: string | null; - onResultsChanged: (results: Apps.Application[]) => void; -}) { - const searchGoogleBtn = SearchGoogleBtn({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - textState: text as any, - monitorConnector, - }); +function scrollToSelection(scrollWindow: Gtk.ScrolledWindow, targetChild: Gtk.Widget) { + const adjustment = scrollWindow.get_vadjustment(); + const viewport = scrollWindow.get_child(); + if (!adjustment || !viewport) return; - const appList = () as Gtk.Box; - const widgetMap = new Map(); + const itemHeight = targetChild.get_height() || 50; + const position = targetChild.translate_coordinates(viewport, 0, 0); + if (!position[0]) return; - function getAppKey(appInstance: Apps.Application) { - return appInstance.name + (appInstance.description || '') + (appInstance.iconName || ''); + const visibleY = position[2]; + const visibleBottom = visibleY + itemHeight; + const pageSize = adjustment.get_page_size(); + + if (visibleY < 0) { + adjustment.set_value(adjustment.get_value() + visibleY - 10); + } else if (visibleBottom > pageSize) { + adjustment.set_value(adjustment.get_value() + visibleBottom - pageSize + 10); } +} - let currentResults: Apps.Application[] = []; - - function populateApps() { - const safeT = text.get() || ''; - const q = safeT.trim().toLowerCase(); - - const rawResults = searchApps(q); - currentResults = []; - const seen = new Set(); - rawResults.forEach((res) => { - const key = getAppKey(res); - if (!seen.has(key)) { - seen.add(key); - currentResults.push(res); - } - }); - onResultsChanged(currentResults); - - const currentKeys = new Set(currentResults.map((res) => getAppKey(res))); - for (const [key, w] of widgetMap) { - if (!currentKeys.has(key)) { - appList.remove(w); - widgetMap.delete(key); - } +function updateSelection( + appList: Gtk.Box, + searchGoogleBtn: Gtk.Button, + scrollWindow: Gtk.ScrolledWindow, + selectedIndex: number, + currentResults: Apps.Application[], + query: string, +) { + let targetChild: Gtk.Widget | null = null; + let child = appList.get_first_child(); + let index = 0; + + while (child) { + if (index === selectedIndex) { + child.add_css_class('selected'); + targetChild = child; + } else { + child.remove_css_class('selected'); } + index++; + child = child.get_next_sibling(); + } - let prev: Gtk.Widget | null = null; - currentResults.forEach((res) => { - const key = getAppKey(res); - let w = widgetMap.get(key); - if (!w) { - w = createAppItem(res, monitorConnector); - widgetMap.set(key, w); - appList.append(w); - } - w.set_visible(true); - appList.reorder_child_after(w, prev); - prev = w; - }); - - GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { - updateSelection(); - return GLib.SOURCE_REMOVE; - }); + if (selectedIndex === currentResults.length && query !== '') { + searchGoogleBtn.add_css_class('selected'); + targetChild = searchGoogleBtn; + } else { + searchGoogleBtn.remove_css_class('selected'); } - const unsubscribeText = text.subscribe(() => populateApps()); + if (targetChild) scrollToSelection(scrollWindow, targetChild); +} - GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { - populateApps(); - return GLib.SOURCE_REMOVE; +export function AppList({ + text, + selectedIndex, + results, + monitorConnector, +}: AppListProps): Gtk.ScrolledWindow { + const searchGoogleBtn = SearchGoogleBtn({ + textState: text, + monitorConnector, }); - const scrollWindow = Object.assign(new Gtk.ScrolledWindow(), { - cssClasses: ['applauncher-scroll'], - hscrollbarPolicy: Gtk.PolicyType.NEVER, - vscrollbarPolicy: Gtk.PolicyType.AUTOMATIC, - vexpand: true, - minContentWidth: 400, - minContentHeight: 300, - maxContentHeight: 600, - propagateNaturalHeight: false, - child: ( + let appList!: Gtk.Box; + let scrollWindow!: Gtk.ScrolledWindow; + let selectionTimer: Timer | null = null; + + const widget = ( + (scrollWindow = self)} + > - {appList} + (appList = self)}> + + {(appInstance) => } + + {searchGoogleBtn} - ), - }) as Gtk.ScrolledWindow; - - function updateSelection() { - const idx = selectedIndex.get(); - let targetChild: Gtk.Widget | null = null; - - let child = appList.get_first_child(); - let i = 0; - while (child) { - if (child.get_visible()) { - if (i === idx) { - child.add_css_class('selected'); - targetChild = child; - } else { - child.remove_css_class('selected'); - } - i++; - } else { - child.remove_css_class('selected'); - } - child = child.get_next_sibling(); - } - - if (idx === currentResults.length && (text.get() || '').trim() !== '') { - searchGoogleBtn.add_css_class('selected'); - targetChild = searchGoogleBtn; - } else { - searchGoogleBtn.remove_css_class('selected'); - } - - if (targetChild) { - const vadj = scrollWindow.get_vadjustment(); - const viewport = scrollWindow.get_child(); - - if (vadj && viewport) { - const itemHeight = targetChild.get_height() || 50; - const res = targetChild.translate_coordinates(viewport, 0, 0); - if (Array.isArray(res) && res[0]) { - const visibleY = res[2]; - const visibleBottom = visibleY + itemHeight; - const pageSize = vadj.get_page_size(); - - if (visibleY < 0) { - vadj.set_value(vadj.get_value() + visibleY - 10); - } else if (visibleBottom > pageSize) { - vadj.set_value(vadj.get_value() + (visibleBottom - pageSize) + 10); - } - } - } - } - } - - const unsubscribeSelection = selectedIndex.subscribe(() => updateSelection()); + + ) as Gtk.ScrolledWindow; + + createEffect(() => { + selectedIndex(); + results(); + text(); + + selectionTimer?.cancel(); + selectionTimer = idle(() => { + selectionTimer = null; + updateSelection( + appList, + searchGoogleBtn, + scrollWindow, + selectedIndex.peek(), + results.peek(), + text.peek().trim(), + ); + }); + }); - scrollWindow.connect('destroy', () => { - unsubscribeText(); - unsubscribeSelection(); - widgetMap.clear(); + onCleanup(() => { + selectionTimer?.cancel(); + selectionTimer = null; }); - return scrollWindow; + return widget; } diff --git a/src/widget/app-launcher/widget/SearchGoogleBtn.tsx b/src/widget/app-launcher/widget/SearchGoogleBtn.tsx index 2dd5b75..c720cfe 100644 --- a/src/widget/app-launcher/widget/SearchGoogleBtn.tsx +++ b/src/widget/app-launcher/widget/SearchGoogleBtn.tsx @@ -1,23 +1,17 @@ +import type { Accessor } from 'ags'; import { Gtk } from 'ags/gtk4'; import Pango from 'gi://Pango'; -import { getDirectUrl, openQuery } from '../../../stores/application'; -import { toggleAppLauncher } from '../../../stores/windowManager'; +import { getDirectUrl, openQuery } from '../../../stores/application/query'; +import { toggleAppLauncher } from '../../../stores/shell/windowManager'; -interface TextState { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - as: (cb: (t: string) => boolean | string) => any; - peek: () => string | undefined; +export interface SearchGoogleBtnProps { + textState: Accessor; + monitorConnector: string | null; } -export function SearchGoogleBtn({ - textState, - monitorConnector, -}: { - textState: TextState; - monitorConnector: string | null; -}) { +export function SearchGoogleBtn({ textState, monitorConnector }: SearchGoogleBtnProps): Gtk.Button { return ( diff --git a/src/widget/bar/widget/RecordIndicator.tsx b/src/widget/bar/widget/RecordIndicator.tsx index 01d02ab..f32fae3 100644 --- a/src/widget/bar/widget/RecordIndicator.tsx +++ b/src/widget/bar/widget/RecordIndicator.tsx @@ -1,6 +1,6 @@ import { Gtk } from 'ags/gtk4'; -import { isRecording, stopRecord } from '../../../stores/recording'; +import { isRecording, stopRecord } from '../../../stores/capture/recording'; import { LucideIcon } from '../../../widget/common/lucide'; export default function RecordIndicator() { @@ -11,7 +11,7 @@ export default function RecordIndicator() { revealChild={isRecording} > diff --git a/src/widget/bar/widget/ScrollerIndicator.tsx b/src/widget/bar/widget/ScrollerIndicator.tsx index db4ee76..6f83b73 100644 --- a/src/widget/bar/widget/ScrollerIndicator.tsx +++ b/src/widget/bar/widget/ScrollerIndicator.tsx @@ -1,125 +1,36 @@ -import { createState } from 'ags'; import { Gdk, Gtk } from 'ags/gtk4'; -import Hyprland from 'gi://AstalHyprland'; - +import { + createScrollingLayoutInfo, + toggleScrollingOverview, +} from '../../../stores/shell/scrollingLayout'; import { LucideIcon } from '../../../widget/common/lucide'; -export default function ScrollerIndicator({ gdkmonitor }: { gdkmonitor: Gdk.Monitor }) { - const hypr = Hyprland.get_default(); - const connector = gdkmonitor.get_connector(); - - const [isVisible, setIsVisible] = createState(false); - const [info, setInfo] = createState({ current: 0, total: 0 }); - - let currentLayout = 'scrolling'; - let disposed = false; - - function updateLayout() { - hypr.message_async('j/getoption general:layout', (_, res) => { - try { - const out = hypr.message_finish(res); - if (disposed) return; - const data = JSON.parse(out); - currentLayout = data.str; - updateVisibility(); - } catch (error) { - console.error(error); - } - }); - } - - function updateVisibility() { - setIsVisible(currentLayout === 'scrolling' && info.get().total > 0); - } - - let updateTimeout: ReturnType | null = null; - function updateInfo() { - if (updateTimeout) return; - updateTimeout = setTimeout(() => { - updateTimeout = null; - if (disposed) return; - - const monitor = hypr.monitors.find((m) => m.name === connector); - if (!monitor) { - setInfo({ current: 0, total: 0 }); - updateVisibility(); - return; - } - - const fw = monitor.active_workspace; - if (!fw) { - setInfo({ current: 0, total: 0 }); - updateVisibility(); - return; - } - - const clients = fw.clients.filter((c) => !c.floating).sort((a, b) => a.x - b.x); - if (clients.length === 0) { - setInfo({ current: 0, total: 0 }); - updateVisibility(); - return; - } - - const focused = hypr.focused_client; - const activeClient = focused && focused.workspace?.id === fw.id ? focused : fw.last_client; - const index = activeClient - ? clients.findIndex((c) => c.address === activeClient.address) - : -1; - const displayIndex = index !== -1 ? index + 1 : 0; - - setInfo({ current: displayIndex, total: clients.length }); - updateVisibility(); - }, 10); - } - - const hooks = [ - hypr.connect('event', (_, event) => { - if (event === 'configreloaded' || event.includes('scrolling')) updateLayout(); - }), - hypr.connect('notify::focused-workspace', updateInfo), - hypr.connect('notify::focused-client', updateInfo), - hypr.connect('client-added', updateInfo), - hypr.connect('client-removed', updateInfo), - hypr.connect('client-moved', updateInfo), - ]; +export interface ScrollerIndicatorProps { + monitor: Gdk.Monitor; +} - updateLayout(); - updateInfo(); +export default function ScrollerIndicator({ monitor }: ScrollerIndicatorProps) { + const info = createScrollingLayoutInfo(monitor.get_connector()); return ( { - disposed = true; - if (updateTimeout !== null) { - clearTimeout(updateTimeout); - updateTimeout = null; - } - hooks.forEach((h) => hypr.disconnect(h)); - }} + revealChild={info.as(({ visible }) => visible)} > - diff --git a/src/widget/bar/widget/SysMetrics.tsx b/src/widget/bar/widget/SysMetrics.tsx index 9898e08..b9427b2 100644 --- a/src/widget/bar/widget/SysMetrics.tsx +++ b/src/widget/bar/widget/SysMetrics.tsx @@ -1,37 +1,41 @@ import { Gdk, Gtk } from 'ags/gtk4'; -import { cpuUsage, gpuUsage, ramUsage } from '../../../stores/system'; -import { toggleControlCenter } from '../../../stores/windowManager'; +import { toggleControlCenter } from '../../../stores/shell/windowManager'; +import { cpuUsage, gpuUsage, ramUsage } from '../../../stores/system/system'; import { LucideIcon } from '../../../widget/common/lucide'; -export default function SysMetrics({ gdkmonitor }: { gdkmonitor: Gdk.Monitor }) { - const toggleMenu = () => { - toggleControlCenter(gdkmonitor.get_connector()); - }; +export interface SysMetricsProps { + monitor: Gdk.Monitor; +} - const formatPercent = (val: number) => { - const rounded = Math.round(val); - const capped = rounded >= 100 ? 99 : rounded; - return capped.toString().padStart(2, '0'); +function formatPercent(value: number) { + const rounded = Math.round(value); + const capped = rounded >= 100 ? 99 : rounded; + return capped.toString().padStart(2, '0'); +} + +export default function SysMetrics({ monitor }: SysMetricsProps) { + const toggleMenu = () => { + toggleControlCenter(monitor.get_connector()); }; return ( diff --git a/src/widget/bar/widget/Tray.tsx b/src/widget/bar/widget/Tray.tsx index 18d2aaf..1aea722 100644 --- a/src/widget/bar/widget/Tray.tsx +++ b/src/widget/bar/widget/Tray.tsx @@ -1,70 +1,14 @@ -import { For, createBinding } from 'ags'; +import { For, createBinding, onCleanup } from 'ags'; import { Gtk } from 'ags/gtk4'; import AstalTray from 'gi://AstalTray'; +import { TrayItemButton } from './TrayItemButton'; + function isFcitxItem(item: AstalTray.TrayItem) { return item.id.toLowerCase().includes('fcitx'); } -function TrayItemButton({ - item, - onActivate, -}: { - item: AstalTray.TrayItem; - onActivate: () => void; -}) { - const button = ( - - - - ) as Gtk.MenuButton; - - const menuModel = item.menu_model; - const menu = menuModel - ? Gtk.PopoverMenu.new_from_model_full(menuModel, Gtk.PopoverMenuFlags.NESTED) - : new Gtk.Popover({ - child: ( - ) as Gtk.Box; - expander = new Gtk.Popover(); - expander.set_has_arrow(false); - expander.set_position(Gtk.PositionType.RIGHT); - expander.add_css_class('tray-expander'); - expander.set_child(expandedItems); + expander = new Gtk.Popover({ + hasArrow: false, + position: Gtk.PositionType.RIGHT, + cssClasses: ['tray-expander'], + }); + expander.set_parent(trigger); - trigger.connect('destroy', () => expander?.unparent()); + expander.set_child(expandedItems); + + onCleanup(() => expander?.unparent()); return ( void; +} + +export function TrayItemButton({ item, onActivate }: TrayItemButtonProps) { + const button = ( + + { + gesture.set_state(Gtk.EventSequenceState.CLAIMED); + item.activate(0, 0); + onActivate(); + }} + /> + button.popup()} /> + + + ) as Gtk.MenuButton; + + const menuModel = item.menu_model; + const menu = menuModel + ? Gtk.PopoverMenu.new_from_model_full(menuModel, Gtk.PopoverMenuFlags.NESTED) + : new Gtk.Popover({ + child: ( + -