Compare commits

..

10 Commits

Author SHA1 Message Date
Olivier 186f6ddc82 Fix: Add explicit GI_TYPELIB_PATH for AstalNotifd typelib resolution 2025-12-03 18:12:32 -04:00
Olivier fa9367c43a safe2 2025-12-01 06:46:13 -04:00
Olivier c8d6711466 safe 2025-12-01 06:45:17 -04:00
retrozinndev f50cc9928f 🔧 chore(hypr/scripts/exec): use gio to launch app from desktop file instead of gtk 2025-11-17 21:36:29 -03:00
retrozinndev b42ad1fa8f 🔧 chore(scripts): rename socket script to "socket.sh" 2025-11-14 14:08:51 -03:00
João Dias e14707ec48 feat: add socket communication support for nix builds
includes socket interface support in colorshell nix builds! thanks to @conroy-cheers in #28 :D
2025-11-13 21:42:16 -03:00
Conroy Cheers 523af750b0 fix(nix): add dart-sass runtime dep 2025-11-14 10:40:24 +11:00
Conroy Cheers e05dab3ed6 feat: prepend socket interface to nix build output 2025-11-14 00:07:44 +11:00
retrozinndev f9e65c6b5b feat(notification): dismiss popup on unhover if set on config 2025-11-11 18:34:39 -03:00
retrozinndev cd8a39fc9f perf(wallpaper): use hyprpaper reload instead of unloading, preloading and then setting wallpaper
this is much fastergit add src/modules src/config.ts
2025-11-11 18:33:58 -03:00
27 changed files with 680 additions and 166 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ if uwsm check is-active; then
fi fi
if [[ $1 =~ [.]desktop$ ]]; then if [[ $1 =~ [.]desktop$ ]]; then
gtk-launch $@ gio launch $@
exit 0 exit 0
fi fi
+1 -1
View File
@@ -41,7 +41,7 @@
... ...
}: }:
let let
colorshell = pkgs.callPackage ./nix/package.nix { inherit inputs'; }; colorshell = pkgs.callPackage ./nix/colorshell.nix { inherit inputs'; };
in in
{ {
packages = { packages = {
+217
View File
@@ -0,0 +1,217 @@
{
inputs',
lib,
stdenv,
stdenvNoCC,
moreutils,
pnpm_10,
buildNpmPackage,
wrapGAppsHook4,
gobject-introspection,
glib,
gjs,
libadwaita,
pywal16,
dart-sass,
psmisc,
socat,
}:
let
packageJSON = lib.importJSON ../package.json;
pname = packageJSON.name;
version = packageJSON.version;
# Cleaned sources from this repository
src = lib.fileset.toSource {
root = ../.;
fileset = lib.fileset.difference ../. (
lib.fileset.unions [
../flake.nix
../flake.lock
../result
../build
./.
]
);
};
# Derivation building just the gresources file
colorshellResources = stdenv.mkDerivation {
pname = "${pname}-resources.gresource";
inherit version;
inherit src;
buildInputs = [
glib
];
buildPhase = ''
runHook preBuild
glib-compile-resources resources.gresource.xml \
--sourcedir ./resources \
--target resources.gresource
runHook postBuild
'';
installPhase = ''
runHook preInstall
cp resources.gresource $out
runHook postInstall
'';
};
# Cleaned sources, with FHS paths patched out.
colorshellSrc = stdenvNoCC.mkDerivation {
pname = "${pname}-src";
inherit version;
inherit src;
postPatch = ''
# Copy the ags JS lib from the Nix store into the source tree so pnpm can
# treat it as a local file: dependency (no /nix/store path inside pnpm).
cp -R ${inputs'.ags.packages.ags.jsPackage} ags-js-lib
# Point the devDependency at the local copy instead of the FHS path.
substituteInPlace package.json \
--replace-fail "file:/usr/share/ags/js" "file:./ags-js-lib"
# Update the lockfile to reference the local copy as well.
# We need to keep specifiers in sync with package.json to satisfy
# pnpm's frozen-lockfile check.
substituteInPlace pnpm-lock.yaml \
--replace-fail "file:/usr/share/ags/js" "file:./ags-js-lib" \
--replace-fail "../../../../usr/share/ags/js" "./ags-js-lib"
'';
installPhase = ''
mkdir $out
cp -rp * $out
'';
};
in
buildNpmPackage (finalAttrs: {
inherit pname version;
src = colorshellSrc;
sourceRoot = "${finalAttrs.src.name}";
npmConfigHook = pnpm_10.configHook;
npmDeps = finalAttrs.pnpmDeps;
pnpmDeps = pnpm_10.fetchDeps {
inherit (finalAttrs)
pname
version
src
sourceRoot
;
fetcherVersion = 2;
# Hash updated after local pnpmDeps build
hash = "sha256-m/aPNvv26r0DUvRUR4TL2GwwAHKvEIkc8Nvlm/jpnPc=";
# fetcher version 2 fails if there are no *-exec files in the output
preFixup = ''
touch $out/.dummy-exec
'';
};
nativeBuildInputs = [
wrapGAppsHook4
gobject-introspection
inputs'.ags.packages.default
moreutils
];
buildInputs = [
glib
gjs
libadwaita
inputs'.astal.packages.astal4
inputs'.astal.packages.apps
inputs'.astal.packages.auth
inputs'.astal.packages.battery
inputs'.astal.packages.bluetooth
inputs'.astal.packages.hyprland
inputs'.astal.packages.io
inputs'.astal.packages.mpris
inputs'.astal.packages.network
inputs'.astal.packages.notifd
inputs'.astal.packages.tray
inputs'.astal.packages.wireplumber
];
buildPhase = ''
runHook preBuild
# Allow incremental or repeated builds: don't fail if ./build already exists
mkdir -p build
outPath=./build/${packageJSON.name}
ags bundle ./src/app.ts $outPath \
--gtk 4 \
--root ./src \
--define "DEVEL=false" \
--define "COLORSHELL_VERSION='${finalAttrs.version}'" \
--define "GRESOURCES_FILE='${colorshellResources}'"
# add socket-communication support on executable
{
head -n1 $outPath
sed '1{/^#!.*$/d}' ${../scripts/socket.sh}
cat "$outPath" | sed '/^#!.*$/d'
} | sponge $outPath
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/bin
cp -rp build/${packageJSON.name} $out/bin/
runHook postInstall
'';
preFixup = ''
gappsWrapperArgs+=(
--prefix PATH : ${
lib.makeBinPath [
# runtime executables
pywal16 # provides `wal` for colorshell's wallpaper module
dart-sass
glib
psmisc
socat
]
}
--prefix GI_TYPELIB_PATH : "${
lib.makeSearchPath "lib/girepository-1.0" (with inputs'.astal.packages; [
astal4
apps
auth
battery
bluetooth
hyprland
io
mpris
network
notifd
tray
wireplumber
])
}"
)
'';
passthru = {
resources = colorshellResources;
};
})
@@ -3,6 +3,7 @@
lib, lib,
stdenv, stdenv,
stdenvNoCC, stdenvNoCC,
moreutils,
pnpm_10, pnpm_10,
buildNpmPackage, buildNpmPackage,
wrapGAppsHook4, wrapGAppsHook4,
@@ -10,6 +11,8 @@
glib, glib,
gjs, gjs,
libadwaita, libadwaita,
dart-sass,
socat,
}: }:
let let
packageJSON = lib.importJSON ../package.json; packageJSON = lib.importJSON ../package.json;
@@ -94,7 +97,7 @@ buildNpmPackage (finalAttrs: {
; ;
fetcherVersion = 2; fetcherVersion = 2;
hash = "sha256-m/aPNvv26r0DUvRUR4TL2GwwAHKvEIkc8Nvlm/jpnPc="; hash = "sha256-Z5JP7hPEjLY9wGnWe6kM6T1qk3UUSlJnoxdDqS/ksnw=";
# fetcher version 2 fails if there are no *-exec files in the output # fetcher version 2 fails if there are no *-exec files in the output
preFixup = '' preFixup = ''
@@ -106,6 +109,7 @@ buildNpmPackage (finalAttrs: {
wrapGAppsHook4 wrapGAppsHook4
gobject-introspection gobject-introspection
inputs'.ags.packages.default inputs'.ags.packages.default
moreutils
]; ];
buildInputs = [ buildInputs = [
@@ -129,25 +133,42 @@ buildNpmPackage (finalAttrs: {
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild
mkdir -p $out/bin mkdir build
ags bundle ./src/app.ts $out/bin/${packageJSON.name} \ outPath=./build/${packageJSON.name}
ags bundle ./src/app.ts $outPath \
--gtk 4 \ --gtk 4 \
--root ./src \ --root ./src \
--define "DEVEL=false" \ --define "DEVEL=false" \
--define "COLORSHELL_VERSION='${finalAttrs.version}'" \ --define "COLORSHELL_VERSION='${finalAttrs.version}'" \
--define "GRESOURCES_FILE='${colorshellResources}'" --define "GRESOURCES_FILE='${colorshellResources}'"
# add socket-communication support on executable
{
head -n1 $outPath
sed '1{/^#!.*$/d}' ${../scripts/socket.sh}
cat "$outPath" | sed '/^#!.*$/d'
} | sponge $outPath
runHook postBuild runHook postBuild
''; '';
# the above buildPhase installs for us installPhase = ''
dontInstall = true; runHook preInstall
mkdir -p $out/bin
cp -rp build/${packageJSON.name} $out/bin/
runHook postInstall
'';
preFixup = '' preFixup = ''
gappsWrapperArgs+=( gappsWrapperArgs+=(
--prefix PATH : ${ --prefix PATH : ${
lib.makeBinPath [ lib.makeBinPath [
# runtime executables # runtime executables
dart-sass
glib
socat
] ]
} }
) )
+25 -5
View File
@@ -7,6 +7,9 @@
padding: 28px; padding: 28px;
background: colors.$bg-translucent; background: colors.$bg-translucent;
border-radius: $radius $radius 0 0; border-radius: $radius $radius 0 0;
max-width: 1600px;
margin-left: auto;
margin-right: auto;
& entry { & entry {
background: transparent; background: transparent;
@@ -22,29 +25,46 @@
} }
& flowbox { & flowbox {
padding: 16px 24px; padding: 16px 36px;
& > flowboxchild { & > flowboxchild {
& > button { & > button {
padding: 10px; padding: 10px;
border-radius: 24px; border-radius: 24px;
background: linear-gradient(
135deg,
color.change($color: colors.$bg-primary, $alpha: 0.9),
color.change($color: colors.$bg-secondary, $alpha: 0.7)
);
border: 1px solid transparent;
& image { & image {
-gtk-icon-size: 64px; -gtk-icon-size: 64px;
} }
& label { & label.app-name {
margin-top: 24px; margin-top: 24px;
font-size: 16px;
text-shadow: 1px 1px 1px rgba(colors.$bg-primary, .2); text-shadow: 1px 1px 1px rgba(colors.$bg-primary, .2);
font-weight: 500; font-weight: 500;
} }
} }
&:focus > button, &:focus > button,
&:selected > button, &:selected > button {
& > button:hover { background: linear-gradient(
background-color: rgba($color: colors.$bg-secondary, $alpha: .5); 135deg,
color.change($color: colors.$bg-secondary, $alpha: 0.95),
color.change($color: colors.$bg-tertiary, $alpha: 0.8)
);
border-color: colors.$bg-tertiary;
box-shadow: 0 0 0 1px colors.$bg-tertiary;
& label.app-name {
font-weight: 700;
}
} }
} }
} }
} }
+22 -17
View File
@@ -1,3 +1,4 @@
@use "sass:color";
@use "./colors"; @use "./colors";
.runner .popup-window-container { .runner .popup-window-container {
@@ -29,16 +30,18 @@
} }
} }
& scrolledwindow {
margin: 6px;
}
& list { & list {
padding: 0 12px;
& .result { & .result {
padding: 10px; padding: 10px;
background: colors.$bg-primary; background: linear-gradient(
margin: 2px 0; 135deg,
border-radius: 14px; color.change($color: colors.$bg-primary, $alpha: 0.9),
color.change($color: colors.$bg-secondary, $alpha: 0.7)
);
margin: 6px 0;
border-radius: 18px;
border: 1px solid transparent;
& image { & image {
-gtk-icon-size: 28px; -gtk-icon-size: 28px;
@@ -47,7 +50,7 @@
& .title { & .title {
font-weight: 500; font-weight: 500;
font-size: 16px; font-size: 17px;
} }
& .description { & .description {
@@ -57,17 +60,19 @@
} }
& > *:selected .result, & > *:selected .result,
& > *:active .result, & > *:active .result {
& > *:hover .result { background: linear-gradient(
background: colors.$bg-secondary; 135deg,
} color.change($color: colors.$bg-secondary, $alpha: 0.95),
color.change($color: colors.$bg-tertiary, $alpha: 0.8)
);
& > *:first-child { border-color: colors.$bg-tertiary;
margin-top: 12px; box-shadow: 0 0 0 1px colors.$bg-tertiary;
}
&:last-child { & .title {
margin-bottom: 0; font-weight: 700;
}
} }
} }
} }
+1 -13
View File
@@ -43,19 +43,7 @@ sh ./scripts/build.sh -o "${outdir:-./build/release}" -b -r "${gresource_file:-\
if [[ $socket_support ]]; then if [[ $socket_support ]]; then
echo "[info] adding socket communication support" echo "[info] adding socket communication support"
script="\ script="\
#!/usr/bin/bash `cat ./scripts/colorshell-socket-interface.sh`
if gdbus introspect --session \\
--dest io.github.retrozinndev.colorshell \\
--object-path /io/github/retrozinndev/colorshell > /dev/null 2>&1; then
if command -v socat > /dev/null 2>&1; then
echo \"\$@\" | socat - \"\${XDG_RUNTIME_DIR:-/run/user/\$(id -u)}/colorshell.sock\"
exit 0
else
echo \"[warn] \`socat\` not installed, falling back to remote instance communication\"
fi
fi
`cat "${outdir:-./build/release}/colorshell" | sed -e 's/^#.*//'`" # remove shebang `cat "${outdir:-./build/release}/colorshell" | sed -e 's/^#.*//'`" # remove shebang
echo -en "$script" > "${outdir:-./build/release}/colorshell" echo -en "$script" > "${outdir:-./build/release}/colorshell"
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
if gdbus introspect --session \
--dest io.github.retrozinndev.colorshell \
--object-path /io/github/retrozinndev/colorshell > /dev/null 2>&1; then
if command -v socat > /dev/null 2>&1; then
echo "$@" | socat - "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/colorshell.sock"
exit 0
else
echo "[warn] \`socat\` not installed, falling back to remote instance communication"
fi
fi
+11 -2
View File
@@ -5,10 +5,19 @@ file="${1:-./build/colorshell}"
function start() { function start() {
if Is_running; then if Is_running; then
echo "[info] killing previous instance" echo "[info] killing previous instance"
colorshell quit || killall gjs colorshell quit || killall gjs 2>/dev/null || true
fi fi
echo "[info] starting" echo "[info] starting"
exec "$file"
# Always run through nix develop to ensure proper environment variables are set
# This is needed because the manually built executable doesn't have wrapGAppsHook
if command -v nix > /dev/null 2>&1; then
# Use nix develop -c to run with proper environment
# The -c flag runs the command in the devshell environment
exec nix develop -c bash -c "exec \"$file\" \"\$@\"" -- "$@"
else
exec "$file" "$@"
fi
} }
if [[ -f $file ]]; then if [[ -f $file ]]; then
+68 -15
View File
@@ -67,7 +67,7 @@ export class Shell extends Adw.Application {
super({ super({
applicationId: "io.github.retrozinndev.colorshell", applicationId: "io.github.retrozinndev.colorshell",
flags: Gio.ApplicationFlags.HANDLES_COMMAND_LINE, flags: Gio.ApplicationFlags.HANDLES_COMMAND_LINE,
version: COLORSHELL_VERSION ?? "0.0.0-unknown", version: (typeof COLORSHELL_VERSION !== "undefined" ? COLORSHELL_VERSION : "0.0.0-unknown"),
}); });
setConsoleLogDomain("Colorshell"); setConsoleLogDomain("Colorshell");
@@ -168,21 +168,74 @@ you should use the socket in the XDG_RUNTIME_DIR/colorshell.sock for a faster re
private init(): void { private init(): void {
// load gresource from build-defined path // load gresource from build-defined path
try { try {
const gresourcesPath: string = GRESOURCES_FILE.startsWith('/') ? GRESOURCES_FILE : (GRESOURCES_FILE.split('/').filter(s => // Handle missing GRESOURCES_FILE define (when running via AGS Home Manager)
s !== "" if (typeof GRESOURCES_FILE === "undefined" || !GRESOURCES_FILE) {
).map(path => { // Try to find gresource in common locations
// support environment variables at runtime // When symlinked via Home Manager, configDir is ~/.config/ags -> ../colorshell/src
if(/^\$/.test(path)) { // We need to resolve the actual path of the colorshell directory
const env = GLib.getenv(path.replace(/^\$/, "")); const configDir = GLib.get_user_config_dir();
if(env === null) const agsConfigFile = Gio.File.new_for_path(`${configDir}/ags/app.ts`);
throw new Error(`Couldn't get environment variable: ${path}`); let colorshellBuildPath: string | null = null;
return env; // Try to resolve the symlink to find the actual colorshell directory
// When Home Manager symlinks ~/.config/ags -> ../colorshell/src,
// we need to go up one level from src to find the colorshell root
try {
// Get the real path of app.ts (resolves symlinks)
const appTsFile = Gio.File.new_for_path(`${configDir}/ags/app.ts`);
if (appTsFile.query_exists(null)) {
const realPath = appTsFile.get_path()!;
// realPath will be something like /home/olivier/NixOS-New/colorshell/src/app.ts
// Go up from src/ to get colorshell root, then to build/
const srcDir = GLib.path_get_dirname(realPath);
const colorshellDir = GLib.path_get_dirname(srcDir);
colorshellBuildPath = `${colorshellDir}/build/resources.gresource`;
}
} catch (e) {
// If symlink resolution fails, try absolute paths
} }
return path;
}).join('/')); const possiblePaths = [
this.#gresource = Gio.Resource.load(gresourcesPath); colorshellBuildPath,
Gio.resources_register(this.#gresource); `${GLib.get_home_dir()}/NixOS-New/colorshell/build/resources.gresource`, // Absolute path (most reliable)
`${configDir}/../colorshell/build/resources.gresource`, // Relative to ~/.config/ags
`${GLib.get_user_config_dir()}/colorshell/build/resources.gresource`,
`${GLib.get_home_dir()}/.config/colorshell/build/resources.gresource`,
].filter(p => p !== null) as string[];
let gresourcesPath: string | null = null;
for (const path of possiblePaths) {
const file = Gio.File.new_for_path(path);
if (file.query_exists(null)) {
gresourcesPath = path;
break;
}
}
if (!gresourcesPath) {
console.warn("Colorshell: GRESOURCES_FILE not defined and couldn't find gresource file. Icons may not be available.");
return;
}
this.#gresource = Gio.Resource.load(gresourcesPath);
Gio.resources_register(this.#gresource);
} else {
const gresourcesPath: string = GRESOURCES_FILE.startsWith('/') ? GRESOURCES_FILE : (GRESOURCES_FILE.split('/').filter(s =>
s !== ""
).map(path => {
// support environment variables at runtime
if(/^\$/.test(path)) {
const env = GLib.getenv(path.replace(/^\$/, ""));
if(env === null)
throw new Error(`Couldn't get environment variable: ${path}`);
return env;
}
return path;
}).join('/'));
this.#gresource = Gio.Resource.load(gresourcesPath);
Gio.resources_register(this.#gresource);
}
// add icons // add icons
Gtk.IconTheme.get_for_display(Gdk.Display.get_default()!) Gtk.IconTheme.get_for_display(Gdk.Display.get_default()!)
+3 -3
View File
@@ -44,8 +44,8 @@ export namespace Cli {
name: "version", name: "version",
alias: "v", alias: "v",
help: "print the current colorshell version", help: "print the current colorshell version",
onCalled: () => `colorshell by retrozinndev, version ${COLORSHELL_VERSION onCalled: () => `colorshell by retrozinndev, version ${(typeof COLORSHELL_VERSION !== "undefined" ? COLORSHELL_VERSION : "unknown")
}${DEVEL ? "(devel)" : ""}` }${(typeof DEVEL !== "undefined" && DEVEL) ? "(devel)" : ""}`
} }
] ]
}, },
@@ -110,7 +110,7 @@ export namespace Cli {
initialized = true; initialized = true;
rootScope = scope; rootScope = scope;
DEVEL && modules.push(devel); (typeof DEVEL !== "undefined" && DEVEL) && modules.push(devel);
scope.run(() => { scope.run(() => {
if(communicationMethod instanceof Gio.SocketService) { if(communicationMethod instanceof Gio.SocketService) {
+1 -1
View File
@@ -23,7 +23,7 @@ const generalConfigDefaults = {
position_v: "top", position_v: "top",
/** dismisses notification popup when unhovered after hovering /** dismisses notification popup when unhovered after hovering
* @default false */ * @default false */
dismiss_after_unhover: false dismiss_on_unhover: false
}, },
night_light: { night_light: {
+3 -3
View File
@@ -1,7 +1,7 @@
declare const SRC: string declare const SRC: string
declare const DEVEL: boolean; declare const DEVEL: boolean | undefined;
declare const GRESOURCES_FILE: string; declare const GRESOURCES_FILE: string | undefined;
declare const COLORSHELL_VERSION: string; declare const COLORSHELL_VERSION: string | undefined;
declare module "inline:*" { declare module "inline:*" {
const content: string const content: string
+2 -1
View File
@@ -5,8 +5,9 @@ import AstalApps from "gi://AstalApps";
import AstalHyprland from "gi://AstalHyprland"; import AstalHyprland from "gi://AstalHyprland";
// Check if uwsm exists and is active, handling errors gracefully
export const uwsmIsActive: boolean = await execAsync( export const uwsmIsActive: boolean = await execAsync(
"uwsm check is-active" "sh -c 'which uwsm > /dev/null 2>&1 && uwsm check is-active'"
).then(() => true).catch(() => false); ).then(() => true).catch(() => false);
const astalApps: AstalApps.Apps = new AstalApps.Apps(); const astalApps: AstalApps.Apps = new AstalApps.Apps();
+3 -3
View File
@@ -37,7 +37,7 @@ Audio Controls:
Media Controls: Media Controls:
media: manage colorshell's active player, see "media help". media: manage colorshell's active player, see "media help".
${DEVEL ? ` ${(typeof DEVEL !== "undefined" && DEVEL) ? `
Development Tools: Development Tools:
dev: tools to help debugging colorshell dev: tools to help debugging colorshell
` : ""} ` : ""}
@@ -60,8 +60,8 @@ export function handleArguments(cmd: RemoteCaller, args: Array<string>): number
case "version": case "version":
case "v": case "v":
cmd.print_literal(`colorshell by retrozinndev, version ${COLORSHELL_VERSION cmd.print_literal(`colorshell by retrozinndev, version ${(typeof COLORSHELL_VERSION !== "undefined" ? COLORSHELL_VERSION : "unknown")
}${DEVEL ? " (devel)" : ""}\nhttps://github.com/retrozinndev/colorshell`); }${(typeof DEVEL !== "undefined" && DEVEL) ? " (devel)" : ""}\nhttps://github.com/retrozinndev/colorshell`);
return 0; return 0;
case "dev": case "dev":
+3 -6
View File
@@ -133,8 +133,7 @@ class Config<K extends string, V = any> extends GObject.Object {
public bindProperty(path: string, expectType: "number"): Accessor<number>; public bindProperty(path: string, expectType: "number"): Accessor<number>;
public bindProperty(path: string, expectType: "string"): Accessor<string>; public bindProperty(path: string, expectType: "string"): Accessor<string>;
public bindProperty(path: string, expectType: "object"): Accessor<object>; public bindProperty(path: string, expectType: "object"): Accessor<object>;
public bindProperty(path: string, expectType: "any"): Accessor<any>; public bindProperty(path: string, expectType?: "any"): Accessor<any>;
public bindProperty(path: string, expectType: undefined): Accessor<any>;
public bindProperty(propertyPath: string, expectType?: ValueTypes): Accessor<boolean|number|string|object|any> { public bindProperty(propertyPath: string, expectType?: ValueTypes): Accessor<boolean|number|string|object|any> {
return new Accessor(() => this.getProperty(propertyPath, expectType as never), (callback: () => void) => { return new Accessor(() => this.getProperty(propertyPath, expectType as never), (callback: () => void) => {
@@ -147,8 +146,7 @@ class Config<K extends string, V = any> extends GObject.Object {
public getProperty(path: string, expectType: "number"): number; public getProperty(path: string, expectType: "number"): number;
public getProperty(path: string, expectType: "string"): string; public getProperty(path: string, expectType: "string"): string;
public getProperty(path: string, expectType: "object"): object; public getProperty(path: string, expectType: "object"): object;
public getProperty(path: string, expectType: "any"): any; public getProperty(path: string, expectType?: "any"): any;
public getProperty(path: string, expectType: undefined): any;
public getProperty(path: string, expectType?: ValueTypes): boolean|number|string|object|any { public getProperty(path: string, expectType?: ValueTypes): boolean|number|string|object|any {
return this._getProperty(path, this.#entries, expectType); return this._getProperty(path, this.#entries, expectType);
@@ -158,8 +156,7 @@ class Config<K extends string, V = any> extends GObject.Object {
public getPropertyDefault(path: string, expectType: "number"): number; public getPropertyDefault(path: string, expectType: "number"): number;
public getPropertyDefault(path: string, expectType: "string"): string; public getPropertyDefault(path: string, expectType: "string"): string;
public getPropertyDefault(path: string, expectType: "object"): object; public getPropertyDefault(path: string, expectType: "object"): object;
public getPropertyDefault(path: string, expectType: "any"): any; public getPropertyDefault(path: string, expectType?: "any"): any;
public getPropertyDefault(path: string, expectType: undefined): any;
public getPropertyDefault(path: string, expectType?: ValueTypes): boolean|number|string|object|any { public getPropertyDefault(path: string, expectType?: ValueTypes): boolean|number|string|object|any {
return this._getProperty(path, this.defaults, expectType); return this._getProperty(path, this.defaults, expectType);
+14 -2
View File
@@ -120,7 +120,13 @@ export class NightLight extends GObject.Object {
} }
public applyIdentity(): void { public applyIdentity(): void {
this.dispatch("identity"); try {
this.dispatch("identity");
} catch (e) {
// hyprsunset not available, skip
console.warn("Night Light: hyprsunset not available, cannot apply identity");
return;
}
if(!this.#identity) { if(!this.#identity) {
this.#identity = true; this.#identity = true;
@@ -133,7 +139,13 @@ export class NightLight extends GObject.Object {
private dispatch(call: "identity"): string; private dispatch(call: "identity"): string;
private dispatch(call: "temperature"|"gamma"|"identity", val?: number): string { private dispatch(call: "temperature"|"gamma"|"identity", val?: number): string {
return exec(`hyprctl hyprsunset ${call}${val != null ? ` ${val}` : ""}`); try {
return exec(`hyprctl hyprsunset ${call}${val != null ? ` ${val}` : ""}`);
} catch (e) {
// hyprsunset not available, return empty string
console.warn(`Night Light: hyprsunset not available, skipping ${call} command`);
return "";
}
} }
private async dispatchAsync(call: "temperature", val: number): Promise<string>; private async dispatchAsync(call: "temperature", val: number): Promise<string>;
+128 -52
View File
@@ -4,7 +4,7 @@ import GObject, { register, getter, gtype, property, setter } from "ags/gobject"
import Gio from "gi://Gio?version=2.0"; import Gio from "gi://Gio?version=2.0";
import GLib from "gi://GLib?version=2.0"; import GLib from "gi://GLib?version=2.0";
import { createSubscription, encoder } from "./utils"; import { createSubscription } from "./utils";
import { Notifications } from "./notifications"; import { Notifications } from "./notifications";
import { generalConfig } from "../config"; import { generalConfig } from "../config";
import { createRoot, getScope, Scope } from "ags"; import { createRoot, getScope, Scope } from "ags";
@@ -70,7 +70,7 @@ class Wallpaper extends GObject.Object {
@setter(String) @setter(String)
set wallpaper(newValue: string) { this.setWallpaper(newValue); } set wallpaper(newValue: string) { this.setWallpaper(newValue); }
public get wallpapersPath() { return this.#wallpapersPath; } get wallpapersPath() { return this.#wallpapersPath; }
@property(gtype<WallpaperPositioning>(String)) @property(gtype<WallpaperPositioning>(String))
positioning: WallpaperPositioning = "cover"; positioning: WallpaperPositioning = "cover";
@@ -98,6 +98,10 @@ class Wallpaper extends GObject.Object {
generalConfig.bindProperty("wallpaper.color_mode", "string"), generalConfig.bindProperty("wallpaper.color_mode", "string"),
() => { () => {
const mode = generalConfig.getProperty("wallpaper.color_mode", "string"); const mode = generalConfig.getProperty("wallpaper.color_mode", "string");
if(this.colorMode === mode)
return;
if(!mode || (mode !== "darken" && mode !== "lighten")) { if(!mode || (mode !== "darken" && mode !== "lighten")) {
Notifications.getDefault().sendNotification({ Notifications.getDefault().sendNotification({
appName: "colorshell", appName: "colorshell",
@@ -118,6 +122,9 @@ class Wallpaper extends GObject.Object {
const positioning = generalConfig const positioning = generalConfig
.getProperty("wallpaper.positioning", "string") as WallpaperPositioning; .getProperty("wallpaper.positioning", "string") as WallpaperPositioning;
if(this.positioning === positioning)
return;
if(!positioning || (positioning !== "contain" && if(!positioning || (positioning !== "contain" &&
positioning !== "cover" && positioning !== "cover" &&
positioning !== "tile")) { positioning !== "tile")) {
@@ -125,7 +132,7 @@ class Wallpaper extends GObject.Object {
Notifications.getDefault().sendNotification({ Notifications.getDefault().sendNotification({
appName: "colorshell", appName: "colorshell",
summary: "Couldn't update wallpaper position", summary: "Couldn't update wallpaper position",
body: "Invalid position value. Possible values are: \"cover\", \"contain\" or \"tile\"" body: "Invalid position value. Possible values are: \"cover\"(default), \"contain\" or \"tile\""
}); });
return; return;
} }
@@ -154,60 +161,98 @@ class Wallpaper extends GObject.Object {
return this.instance; return this.instance;
} }
private writeChanges(): void { private writeChanges(): Promise<void> {
this.#hyprpaperFile.replace_async(null, false, return new Promise((resolve, reject) => {
Gio.FileCreateFlags.REPLACE_DESTINATION, try {
GLib.PRIORITY_DEFAULT, null, (_, result) => { const content = `# This file was automatically generated by colorshell
const res = this.#hyprpaperFile.replace_finish(result);
if(!res) { preload = ${this.#wallpaper}
console.error(`Wallpaper: an error occurred when trying to replace the hyprpaper file`); splash = ${this.#splash}
wallpaper = , ${this.positioning === "cover" ? "" : `${this.positioning}:`}${this.#wallpaper}
`;
// Use synchronous file writing for reliability
const filePath = this.#hyprpaperFile.get_path();
if(!filePath) {
reject(new Error("Could not get hyprpaper file path"));
return; return;
} }
// success // Ensure directory exists
res.write_bytes_async(encoder.encode(`# This file was automatically generated by color-shell const parentDir = this.#hyprpaperFile.get_parent();
if(parentDir && !parentDir.query_exists(null)) {
parentDir.make_directory_with_parents(null);
}
preload = ${this.#wallpaper} // Write file synchronously using GLib
splash = ${this.#splash} const success = GLib.file_set_contents(filePath, content);
wallpaper = , ${this.#wallpaper}`.split('\n').map(str => str.trimStart()).join('\n')), if(success) {
GLib.PRIORITY_DEFAULT, null, (_, asyncRes) => { resolve();
if(_!.write_finish(asyncRes)) res.flush(null); } else {
res.close(null); reject(new Error("Failed to write hyprpaper config file"));
} }
); } catch (e: any) {
reject(new Error(`Failed to write config file: ${e.message}`));
return;
} }
); });
} }
public getData(): WalData { public getData(): WalData {
const content = readFile(`${GLib.getenv("XDG_CACHE_HOME")}/wal/colors.json`); const cacheHome = GLib.getenv("XDG_CACHE_HOME") || `${GLib.get_home_dir()}/.cache`;
const content = readFile(`${cacheHome}/wal/colors.json`);
return JSON.parse(content) as WalData; return JSON.parse(content) as WalData;
} }
public async getWallpaper(): Promise<string|undefined> { public async getWallpaper(): Promise<string|undefined> {
return await execAsync("hyprctl hyprpaper listactive").then(stdout => { return await execAsync("sh -c \"hyprctl hyprpaper listactive | tail -n 1\"").then(stdout => {
const lineSplit = stdout.split('\n');
stdout = lineSplit[lineSplit.length - 1];
const loaded = stdout.split('=')[1]?.trim(); const loaded = stdout.split('=')[1]?.trim();
if(!loaded) if(!loaded)
console.warn(`Wallpaper: Couldn't get wallpaper. There is(are) no loaded wallpaper(s)`); console.warn(`Wallpaper: Couldn't get wallpaper. There is(are) no loaded wallpaper(s)`);
return loaded; return loaded;
}).catch((err: Error) => { }).catch((e: Error) => {
console.error(`Wallpaper: Couldn't get wallpaper. Stderr: \n${err.message}`); console.error(`Wallpaper: Couldn't get wallpaper. Stderr: \n${e.message}`);
return undefined; return undefined;
}); });
} }
public reloadColors(): void { public reloadColors(): void {
execAsync(`wal -t --cols16 ${this.colorMode} -i "${this.#wallpaper}"`).then(() => { const cacheHome = GLib.getenv("XDG_CACHE_HOME") || `${GLib.get_home_dir()}/.cache`;
const colorsKittyPath = `${cacheHome}/wal/colors-kitty.conf`;
const kittyConfigPath = `${GLib.get_user_config_dir()}/kitty/kitty.conf`;
const runWal = (extraArgs: string = "") =>
execAsync(`wal -t --cols16 ${this.colorMode} ${extraArgs} -i "${this.#wallpaper}"`);
// First try default backend; if it fails (e.g. some images on aarch64),
// fall back to a more forgiving backend like "colorz".
runWal().catch((e: Error) => {
console.error(`Wallpaper: Couldn't update shell colors with default backend. Stderr: ${e.message}`);
console.log("Wallpaper: Falling back to pywal backend 'colorz'");
return runWal("--backend colorz");
}).then(() => {
console.log("Wallpaper: reloaded shell colors"); console.log("Wallpaper: reloaded shell colors");
// First, try to set colors on all existing kitty instances
execAsync(`kitty @ set-colors --all ${colorsKittyPath}`).then(() => {
console.log("Wallpaper: reloaded colors in existing kitty instances");
}).catch((e: Error) => {
// It's okay if this fails (e.g., no kitty instances running)
console.log(`Wallpaper: Couldn't reload kitty colors in existing instances: ${e.message}`);
});
// Then, update the configured colors for future kitty instances
// This is critical - it tells kitty to use these colors for new windows
execAsync(`kitty @ set-colors --configured ${colorsKittyPath}`).then(() => {
console.log("Wallpaper: configured colors for future kitty instances");
}).catch((e: Error) => {
// If no kitty instances are running, we can't set configured colors
// In this case, new instances should still pick up colors from the include directive
console.log(`Wallpaper: Couldn't set configured colors (new instances will use include directive): ${e.message}`);
});
}).catch((e: Error) => { }).catch((e: Error) => {
console.error(`Wallpaper: Couldn't update shell colors. Stderr: ${e.message}`); console.error(`Wallpaper: Couldn't update shell colors even with fallback backend. Stderr: ${e.message}`);
}); });
} }
@@ -215,37 +260,68 @@ class Wallpaper extends GObject.Object {
if(this.wallpaper.trim() === "") if(this.wallpaper.trim() === "")
return; return;
await execAsync(`hyprctl hyprpaper wallpaper \", ${this.positioning}:${this.wallpaper}\"`); const wallpaperPath = this.#wallpaper.trim();
this.reloadColors();
write && this.writeChanges(); try {
// Write config file first if needed
if(write) {
await this.writeChanges();
}
// Unload all current wallpapers
await execAsync(`hyprctl hyprpaper unload all`).catch(() => {
// Ignore errors - this is usually fine
});
// Preload the new wallpaper
await execAsync(`hyprctl hyprpaper preload "${wallpaperPath}"`);
// Set wallpaper on all monitors
await execAsync(`hyprctl hyprpaper wallpaper ", ${wallpaperPath}"`);
// Note: We don't need to reload or restart hyprpaper here
// The preload and wallpaper commands should apply the change immediately
// The config file is written for persistence across hyprpaper restarts
} catch (e: any) {
console.error(`Wallpaper: Error reloading wallpaper: ${e.message}`);
console.error(`Wallpaper: Stack trace: ${e.stack}`);
Notifications.getDefault().sendNotification({
appName: "colorshell",
summary: "Failed to set wallpaper",
body: `Error: ${e.message}`
});
throw e;
}
} }
public setWallpaper(path: string|Gio.File, write: boolean = true): void { public setWallpaper(path: string|Gio.File, write: boolean = true): void {
path = typeof path === "string" ? path : path.peek_path()!; path = typeof path === "string" ? path : path.peek_path()!;
execAsync("hyprctl hyprpaper unload all").then(() => if(!GLib.file_test(path, GLib.FileTest.EXISTS)) {
execAsync(`hyprctl hyprpaper preload ${path}`).then(() => console.error("Wallpaper: file does not exist, skipped");
execAsync(`hyprctl hyprpaper wallpaper \", ${this.positioning}:${path}\"`).then(() => { return;
this.#wallpaper = path; }
this.reloadColors();
write && this.writeChanges(); this.#wallpaper = path;
}).catch((e: Error) => { this.reloadWallpaper(write).catch((e: Error) => {
console.error(`Wallpaper: Couldn't set wallpaper. Stderr: ${e.message}`); console.error(`Wallpaper: Couldn't set wallpaper. Stderr: ${e.message}`);
})
).catch((e: Error) => {
console.error(`Wallpaper: Couldn't preload image. Stderr: ${e.message}`);
})
).catch((e: Error) => {
console.error(`Wallpaper: Couldn't unload images from memory. Stderr: ${e.message}`);
}); });
this.reloadColors();
} }
public async pickWallpaper(): Promise<string|undefined> { public async pickWallpaper(): Promise<string|undefined> {
return (await execAsync(`zenity --file-selection`).then(wall => { return (await execAsync(`zenity --file-selection`).then(wall => {
if(!wall.trim()) return undefined; const trimmedWall = wall.trim();
if(!trimmedWall) return undefined;
this.setWallpaper(wall); // Ensure path is absolute
return wall; const absolutePath = GLib.path_is_absolute(trimmedWall)
? trimmedWall
: GLib.build_filenamev([GLib.get_current_dir(), trimmedWall]);
this.setWallpaper(absolutePath);
return absolutePath;
}).catch((e: Error) => { }).catch((e: Error) => {
console.error(`Wallpaper: Couldn't pick wallpaper, is \`zenity\` installed? Stderr: ${e.message}`); console.error(`Wallpaper: Couldn't pick wallpaper, is \`zenity\` installed? Stderr: ${e.message}`);
return undefined; return undefined;
+37 -2
View File
@@ -276,11 +276,14 @@ export function openRunner(props: RunnerProps, placeholders?: Array<Result>): As
props.height ??= 420; props.height ??= 420;
let clickTimeout: GLib.Source|undefined; let clickTimeout: GLib.Source|undefined;
let lastMouseX: number|null = null;
let lastMouseY: number|null = null;
let lastKeyboardNavTime: number = 0;
if(!instance) if(!instance)
instance = Windows.getDefault().createWindowForFocusedMonitor((mon, root) => instance = Windows.getDefault().createWindowForFocusedMonitor((mon, root) =>
<PopupWindow namespace={"runner"} monitor={mon} widthRequest={props.width} <PopupWindow namespace={"runner"} monitor={mon} widthRequest={props.width}
heightRequest={props.height} exclusivity={Astal.Exclusivity.IGNORE} halign={Gtk.Align.CENTER} exclusivity={Astal.Exclusivity.IGNORE} halign={Gtk.Align.CENTER}
marginTop={(AstalHyprland.get_default().get_monitor(mon)?.height / 2) - (props.height! / 2)} marginTop={(AstalHyprland.get_default().get_monitor(mon)?.height / 2) - (props.height! / 2)}
valign={Gtk.Align.START} hexpand orientation={Gtk.Orientation.VERTICAL} valign={Gtk.Align.START} hexpand orientation={Gtk.Orientation.VERTICAL}
$={() => { $={() => {
@@ -302,12 +305,14 @@ export function openRunner(props: RunnerProps, placeholders?: Array<Result>): As
case Gdk.KEY_Up: case Gdk.KEY_Up:
selectPreviousItem(listbox); selectPreviousItem(listbox);
gtkEntry?.grab_focus(); gtkEntry?.grab_focus();
lastKeyboardNavTime = Date.now();
return; return;
case Gdk.KEY_Right: case Gdk.KEY_Right:
case Gdk.KEY_Down: case Gdk.KEY_Down:
selectNextItem(listbox); selectNextItem(listbox);
gtkEntry?.grab_focus(); gtkEntry?.grab_focus();
lastKeyboardNavTime = Date.now();
return; return;
} }
@@ -374,6 +379,36 @@ export function openRunner(props: RunnerProps, placeholders?: Array<Result>): As
child.closeOnClick && child.closeOnClick &&
Runner.close(); Runner.close();
} }
}} $={(self) => {
// Hover-based selection: only triggers when the mouse actually moves
const motion = Gtk.EventControllerMotion.new();
self.add_controller(motion);
motion.connect("motion", (_controller, x, y) => {
const now = Date.now();
// While user is actively navigating with keyboard,
// don't let hover steal selection
if(lastKeyboardNavTime && now - lastKeyboardNavTime < 200)
return;
// First motion: just record pointer position, don't change selection
if(lastMouseX === null && lastMouseY === null) {
lastMouseX = x;
lastMouseY = y;
return;
}
// Ignore synthetic events that don't actually move the pointer
if(x === lastMouseX && y === lastMouseY)
return;
lastMouseX = x;
lastMouseY = y;
const row = self.get_row_at_y(y);
row && self.select_row(row as Gtk.ListBoxRow);
});
}} }}
/> />
</Gtk.ScrolledWindow> </Gtk.ScrolledWindow>
+26 -5
View File
@@ -9,12 +9,19 @@ import GObject from "ags/gobject";
import AstalNotifd from "gi://AstalNotifd"; import AstalNotifd from "gi://AstalNotifd";
import Pango from "gi://Pango?version=1.0"; import Pango from "gi://Pango?version=1.0";
import GLib from "gi://GLib?version=2.0"; import GLib from "gi://GLib?version=2.0";
import { generalConfig } from "../config";
function getNotificationImage(notif: AstalNotifd.Notification|HistoryNotification): (string|undefined) { function getNotificationImage(notif: AstalNotifd.Notification|HistoryNotification): (string|undefined) {
const img = notif.image || notif.appIcon; // AstalNotifd.Notification uses snake_case properties (app_icon, image),
// while our HistoryNotification uses camelCase (appIcon, image).
const anyNotif = notif as any;
const img: string | undefined =
anyNotif.image ||
anyNotif.app_icon ||
anyNotif.appIcon;
if(!img || !img.includes('/')) if (typeof img !== "string" || !img.includes("/"))
return undefined; return undefined;
return pathToURI(img); return pathToURI(img);
@@ -51,9 +58,23 @@ export function NotificationWidget({ notification, actionClicked, holdOnHover, s
<Gtk.EventControllerMotion onEnter={() => holdOnHover && <Gtk.EventControllerMotion onEnter={() => holdOnHover &&
Notifications.getDefault().holdNotification(notification.id) Notifications.getDefault().holdNotification(notification.id)
} onLeave={() => holdOnHover && } onLeave={() => {
Notifications.getDefault().releaseNotification(notification.id) if(!holdOnHover)
} return;
const dismissOnUnhover = generalConfig
.getProperty("notifications.dismiss_on_unhover", "boolean");
if(dismissOnUnhover) {
setTimeout(() =>
Notifications.getDefault().removeNotification(notification.id),
600);
return;
}
Notifications.getDefault().releaseNotification(notification.id);
}}
/> />
<Gtk.GestureClick onReleased={(gesture) => <Gtk.GestureClick onReleased={(gesture) =>
gesture.get_current_button() === Gdk.BUTTON_PRIMARY && gesture.get_current_button() === Gdk.BUTTON_PRIMARY &&
+10 -7
View File
@@ -24,9 +24,10 @@ export const AppsWindow = (mon: number) => {
const [results, setResults] = createState(getApps() as Array<AstalApps.Application>); const [results, setResults] = createState(getApps() as Array<AstalApps.Application>);
return <PopupWindow namespace="apps-window" layer={Astal.Layer.OVERLAY} return <PopupWindow namespace="apps-window" layer={Astal.Layer.OVERLAY}
exclusivity={Astal.Exclusivity.IGNORE} monitor={mon} marginTop={64} exclusivity={Astal.Exclusivity.IGNORE} monitor={mon} marginTop={64}
class={"apps-window"} orientation={Gtk.Orientation.VERTICAL} class={"apps-window"} orientation={Gtk.Orientation.VERTICAL}
cssBackgroundWindow="background: rgba(0, 0, 0, .2);" cssBackgroundWindow="background: rgba(0, 0, 0, .2);" halign={Gtk.Align.FILL}
valign={Gtk.Align.FILL} hexpand vexpand
actionKeyPressed={(self, key) => { actionKeyPressed={(self, key) => {
const entry = getPopupWindowContainer(self).get_first_child()! const entry = getPopupWindowContainer(self).get_first_child()!
.get_first_child()!.get_first_child()! as Gtk.SearchEntry; .get_first_child()!.get_first_child()! as Gtk.SearchEntry;
@@ -36,8 +37,8 @@ export const AppsWindow = (mon: number) => {
entry.grab_focus(); entry.grab_focus();
}}> }}>
<Gtk.Box hexpand={false} halign={Gtk.Align.CENTER}> <Gtk.Box hexpand halign={Gtk.Align.CENTER}>
<Gtk.SearchEntry hexpand={false} onSearchChanged={(self) => { <Gtk.SearchEntry hexpand onSearchChanged={(self) => {
setResults(getAstalApps().fuzzy_query(self.text.trim())); setResults(getAstalApps().fuzzy_query(self.text.trim()));
}} onStopSearch={(self) => (self.get_root() as Astal.Window)?.close()} /> }} onStopSearch={(self) => (self.get_root() as Astal.Window)?.close()} />
</Gtk.Box> </Gtk.Box>
@@ -46,9 +47,11 @@ export const AppsWindow = (mon: number) => {
hscrollbarPolicy={Gtk.PolicyType.NEVER} overlayScrolling hscrollbarPolicy={Gtk.PolicyType.NEVER} overlayScrolling
propagateNaturalHeight={false} hexpand vexpand> propagateNaturalHeight={false} hexpand vexpand>
<Gtk.Box hexpand={false} vexpand={false}> <Gtk.Box hexpand vexpand>
<Gtk.FlowBox rowSpacing={60} columnSpacing={60} activateOnSingleClick <Gtk.FlowBox orientation={Gtk.Orientation.HORIZONTAL}
minChildrenPerLine={1} homogeneous onChildActivated={(_, child) => rowSpacing={32} columnSpacing={32} activateOnSingleClick
minChildrenPerLine={4} maxChildrenPerLine={10} hexpand homogeneous
onChildActivated={(_, child) =>
child.get_child()!.activate() // pass activation to button child.get_child()!.activate() // pass activation to button
}> }>
+4 -4
View File
@@ -21,7 +21,7 @@ export const Bar = (mon: number) => {
halign={Gtk.Align.START} spacing={widgetSpacing} halign={Gtk.Align.START} spacing={widgetSpacing}
$type="start"> $type="start">
<Apps /> <Apps monitor={mon} />
<Workspaces /> <Workspaces />
<FocusedClient /> <FocusedClient />
</Gtk.Box> </Gtk.Box>
@@ -29,14 +29,14 @@ export const Bar = (mon: number) => {
spacing={widgetSpacing} halign={Gtk.Align.CENTER} spacing={widgetSpacing} halign={Gtk.Align.CENTER}
$type="center"> $type="center">
<Clock /> <Clock monitor={mon} />
<Media /> <Media monitor={mon} />
</Gtk.Box> </Gtk.Box>
<Gtk.Box class={"widgets-right"} homogeneous={false} <Gtk.Box class={"widgets-right"} homogeneous={false}
spacing={widgetSpacing} halign={Gtk.Align.END} spacing={widgetSpacing} halign={Gtk.Align.END}
$type="end"> $type="end">
<Tray /> <Tray />
<Status /> <Status monitor={mon} />
</Gtk.Box> </Gtk.Box>
</Gtk.CenterBox> </Gtk.CenterBox>
</Gtk.Box> </Gtk.Box>
+2 -2
View File
@@ -4,10 +4,10 @@ import { createBinding } from "ags";
import { tr } from "../../../i18n/intl"; import { tr } from "../../../i18n/intl";
export const Apps = () => export const Apps = ({ monitor }: { monitor: number }) =>
<Gtk.Button class={createBinding(Windows.getDefault(), "openWindows").as((openWindows) => <Gtk.Button class={createBinding(Windows.getDefault(), "openWindows").as((openWindows) =>
`apps ${Object.hasOwn(openWindows, "apps-window") ? "open" : ""}` `apps ${Object.hasOwn(openWindows, "apps-window") ? "open" : ""}`
)} iconName={"applications-other-symbolic"} halign={Gtk.Align.CENTER} )} iconName={"applications-other-symbolic"} halign={Gtk.Align.CENTER}
hexpand tooltipText={tr("apps")} onClicked={() => hexpand tooltipText={tr("apps")} onClicked={() =>
Windows.getDefault().open("apps-window")} Windows.getDefault().open("apps-window", false, monitor)}
/>; />;
+2 -2
View File
@@ -5,10 +5,10 @@ import { time } from "../../../modules/utils";
import { generalConfig } from "../../../config"; import { generalConfig } from "../../../config";
export const Clock = () => export const Clock = ({ monitor }: { monitor: number }) =>
<Gtk.Button class={createBinding(Windows.getDefault(), "openWindows").as((wins) => <Gtk.Button class={createBinding(Windows.getDefault(), "openWindows").as((wins) =>
`clock ${wins.includes("center-window") ? "open" : ""}`)} `clock ${wins.includes("center-window") ? "open" : ""}`)}
onClicked={() => Windows.getDefault().toggle("center-window")} onClicked={() => Windows.getDefault().toggle("center-window", monitor)}
label={time((dt) => dt.format( label={time((dt) => dt.format(
generalConfig.getProperty("clock.date_format", "string")) generalConfig.getProperty("clock.date_format", "string"))
?? "An error occurred" ?? "An error occurred"
+2 -2
View File
@@ -11,7 +11,7 @@ import AstalMpris from "gi://AstalMpris";
import Pango from "gi://Pango?version=1.0"; import Pango from "gi://Pango?version=1.0";
export const Media = () => export const Media = ({ monitor }: { monitor: number }) =>
<Gtk.Box class={"media"} visible={createBinding(Player.getDefault(), "player").as(p => p.available)}> <Gtk.Box class={"media"} visible={createBinding(Player.getDefault(), "player").as(p => p.available)}>
<Gtk.EventControllerScroll $={(self) => { <Gtk.EventControllerScroll $={(self) => {
self.set_flags(Gtk.EventControllerScrollFlags.VERTICAL) self.set_flags(Gtk.EventControllerScrollFlags.VERTICAL)
@@ -42,7 +42,7 @@ export const Media = () =>
return true; return true;
}} }}
/> />
<Gtk.GestureClick onReleased={() => Windows.getDefault().toggle("center-window")} /> <Gtk.GestureClick onReleased={() => Windows.getDefault().toggle("center-window", monitor)} />
<Gtk.EventControllerMotion onEnter={(self) => { <Gtk.EventControllerMotion onEnter={(self) => {
const revealer = self.get_widget()!.get_last_child() as Gtk.Revealer; const revealer = self.get_widget()!.get_last_child() as Gtk.Revealer;
revealer.set_reveal_child(true); revealer.set_reveal_child(true);
+2 -2
View File
@@ -14,12 +14,12 @@ import AstalNetwork from "gi://AstalNetwork";
import AstalWp from "gi://AstalWp"; import AstalWp from "gi://AstalWp";
export const Status = () => export const Status = ({ monitor }: { monitor: number }) =>
<Gtk.Button class={createBinding(Windows.getDefault(), "openWindows").as((openWins) => <Gtk.Button class={createBinding(Windows.getDefault(), "openWindows").as((openWins) =>
openWins.includes("control-center") ? openWins.includes("control-center") ?
"open status" "open status"
: "status" : "status"
)} onClicked={() => Windows.getDefault().toggle("control-center")}> )} onClicked={() => Windows.getDefault().toggle("control-center", monitor)}>
<Gtk.Box> <Gtk.Box>
<Gtk.Box class={"volume-indicators"} spacing={5}> <Gtk.Box class={"volume-indicators"} spacing={5}>
+53 -10
View File
@@ -17,8 +17,10 @@ import AstalHyprland from "gi://AstalHyprland";
export type WindowInstance = { instance?: Astal.Window, connections: Array<number> }; export type WindowInstance = { instance?: Astal.Window, connections: Array<number> };
export type WindowData = { export type WindowData = {
create: () => (Astal.Window | Array<Astal.Window>); create: () => (Astal.Window | Array<Astal.Window>);
createForMonitor?: (mon: number) => Astal.Window;
instance?: WindowInstance | Array<WindowInstance>; instance?: WindowInstance | Array<WindowInstance>;
status?: "open" | "closed"; status?: "open" | "closed";
preferredMonitor?: number | null;
}; };
@@ -41,12 +43,12 @@ export class Windows extends GObject.Object {
#scope!: ReturnType<typeof getScope>; #scope!: ReturnType<typeof getScope>;
#windows: Record<string, WindowData> = { #windows: Record<string, WindowData> = {
"bar": { create: this.createWindowForMonitors(Bar) }, "bar": { create: this.createWindowForMonitors(Bar) },
"osd": { create: this.createWindowForFocusedMonitor(OSD), }, "osd": { create: this.createWindowForFocusedMonitor(OSD), createForMonitor: this.createWindowForMonitor(OSD) },
"control-center": { create: this.createWindowForFocusedMonitor(ControlCenter), }, "control-center": { create: this.createWindowForFocusedMonitor(ControlCenter), createForMonitor: this.createWindowForMonitor(ControlCenter) },
"center-window": { create: this.createWindowForFocusedMonitor(CenterWindow), }, "center-window": { create: this.createWindowForFocusedMonitor(CenterWindow), createForMonitor: this.createWindowForMonitor(CenterWindow) },
"logout-menu": { create: this.createWindowForFocusedMonitor(LogoutMenu), }, "logout-menu": { create: this.createWindowForFocusedMonitor(LogoutMenu), createForMonitor: this.createWindowForMonitor(LogoutMenu) },
"floating-notifications": { create: this.createWindowForFocusedMonitor(FloatingNotifications), }, "floating-notifications": { create: this.createWindowForFocusedMonitor(FloatingNotifications), createForMonitor: this.createWindowForMonitor(FloatingNotifications) },
"apps-window": { create: this.createWindowForFocusedMonitor(AppsWindow) } "apps-window": { create: this.createWindowForFocusedMonitor(AppsWindow), createForMonitor: this.createWindowForMonitor(AppsWindow) }
}; };
@signal(String) windowOpen(_name: string) {} @signal(String) windowOpen(_name: string) {}
@@ -244,6 +246,29 @@ export class Windows extends GObject.Object {
} }
} }
/**
* Creates a window instance for a specific monitor
* @param create generates the window. use provided monitor number in the returned window
* @returns a function that when called with a monitor ID, returns a Astal.Window instance
*/
public createWindowForMonitor(create: (mon: number, scope: ReturnType<typeof getScope>) => GObject.Object|Astal.Window): ((mon: number) => Astal.Window) {
return (mon: number) => {
return createRoot((dispose) => {
const scope = getScope();
const instance = create(mon, scope) as Astal.Window;
const connection = instance.connect("close-request", () => dispose());
this.#scope.onMount(dispose)
scope.onCleanup(() =>
GObject.signal_handler_is_connected(instance, connection) &&
instance.disconnect(connection)
);
return instance;
});
}
}
public addWindow(name: string, create: () => Astal.Window|Array<Astal.Window>): void { public addWindow(name: string, create: () => Astal.Window|Array<Astal.Window>): void {
this.#windows[name] = { create }; this.#windows[name] = { create };
} }
@@ -264,7 +289,7 @@ export class Windows extends GObject.Object {
return this.openWindows.includes(name); return this.openWindows.includes(name);
} }
public open(name: string, ignoreOpenStatus: boolean = false): void { public open(name: string, ignoreOpenStatus: boolean = false, monitor?: number | null): void {
if(this.isOpen(name) && !ignoreOpenStatus) return; if(this.isOpen(name) && !ignoreOpenStatus) return;
const window = this.#windows[name]; const window = this.#windows[name];
@@ -273,8 +298,22 @@ export class Windows extends GObject.Object {
return; return;
} }
// Store preferred monitor if provided
if(monitor !== undefined) {
window.preferredMonitor = monitor;
}
this.#windows[name].status = "open"; this.#windows[name].status = "open";
const windowInstance = window.create();
// Use createForMonitor if monitor is specified and available, otherwise use default create
let windowInstance: Astal.Window | Array<Astal.Window>;
if(monitor !== null && monitor !== undefined && window.createForMonitor) {
windowInstance = window.createForMonitor(monitor);
} else if(window.preferredMonitor !== null && window.preferredMonitor !== undefined && window.createForMonitor) {
windowInstance = window.createForMonitor(window.preferredMonitor);
} else {
windowInstance = window.create();
}
if(Array.isArray(windowInstance)) { if(Array.isArray(windowInstance)) {
window.instance = windowInstance.map(wi => { window.instance = windowInstance.map(wi => {
@@ -309,8 +348,12 @@ export class Windows extends GObject.Object {
this.notify("open-windows"); this.notify("open-windows");
} }
public toggle(name: string): void { public toggle(name: string, monitor?: number | null): void {
this.isOpen(name) ? this.close(name) : this.open(name); if(this.isOpen(name)) {
this.close(name);
} else {
this.open(name, false, monitor);
}
} }
public closeAll(): void { public closeAll(): void {